The new cmd/tunnel client keeps the API URL and tunnel identity in ~/.config/tunnel/config.json (0600), provisions the key/CSR and certificate, terminates TLS, proxies to local routes, reloads routes without a restart, and runs as a background service. `tunnel import` takes over an existing official CLI profile, preserving its tunnel ID, token, key and certificate. Server hardening: per-IP and per-tunnel rate limits for creates and binds, a CA cool-down after repeated ACME failures, a startup sweep for stale _acme-challenge records, admin metrics, a 60-second half-close timeout, and separate prioritized control / ordered data bridge queues (with `end` ordered after the final bytes). The dashboard shows the new metrics.
213 lines
6.4 KiB
Go
213 lines
6.4 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"io/fs"
|
|
"net/http"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.tzgyn.dev/TZGyn/tunnel/internal/dashboard"
|
|
)
|
|
|
|
// ---- admin API: GET /api/admin/overview, bearer ADMIN_TOKEN.
|
|
|
|
type adminServer struct {
|
|
Domain string `json:"domain"`
|
|
Version string `json:"version"`
|
|
Go string `json:"go"`
|
|
Issuer string `json:"issuer"`
|
|
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
}
|
|
|
|
type adminCA struct {
|
|
Expires string `json:"expires"`
|
|
}
|
|
|
|
type adminTotals struct {
|
|
Tunnels int `json:"tunnels"`
|
|
Online int `json:"online"`
|
|
Bridges int `json:"bridges"`
|
|
Visitors int `json:"visitors"`
|
|
}
|
|
|
|
type adminTunnel struct {
|
|
ID string `json:"id"`
|
|
Hostname string `json:"hostname"`
|
|
State string `json:"state"`
|
|
Routes []string `json:"routes"`
|
|
Visitors int `json:"visitors"`
|
|
CertificateExpiry string `json:"certificate_expiry,omitempty"`
|
|
CertificateState string `json:"certificate_state,omitempty"`
|
|
CertificateIssuer string `json:"certificate_issuer,omitempty"`
|
|
CertificateError string `json:"certificate_error,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
}
|
|
|
|
type adminBridge struct {
|
|
Session string `json:"session"`
|
|
Tunnel string `json:"tunnel"`
|
|
ClientVersion string `json:"client_version,omitempty"`
|
|
Routes []string `json:"routes"`
|
|
Visitors int `json:"visitors"`
|
|
MaxConns int `json:"max_conns"`
|
|
ConnectedAt string `json:"connected_at"`
|
|
}
|
|
|
|
type adminOverview struct {
|
|
Server adminServer `json:"server"`
|
|
CA adminCA `json:"ca"`
|
|
Totals adminTotals `json:"totals"`
|
|
Metrics adminMetrics `json:"metrics"`
|
|
Tunnels []adminTunnel `json:"tunnels"`
|
|
Bridges []adminBridge `json:"bridges"`
|
|
}
|
|
|
|
type adminMetrics struct {
|
|
CreateRequests uint64 `json:"create_requests"`
|
|
BindRequests uint64 `json:"bind_requests"`
|
|
RateLimited uint64 `json:"rate_limited"`
|
|
IssuanceAttempts uint64 `json:"issuance_attempts"`
|
|
IssuanceReady uint64 `json:"issuance_ready"`
|
|
IssuanceFailed uint64 `json:"issuance_failed"`
|
|
CertificatesReady int `json:"certificates_ready"`
|
|
CertificatesIssuing int `json:"certificates_issuing"`
|
|
CertificatesFailed int `json:"certificates_failed"`
|
|
CertificatesRetrying int `json:"certificates_retrying"`
|
|
}
|
|
|
|
// adminAuthorized enforces the bearer ADMIN_TOKEN; 503 when unset.
|
|
func (a *API) adminAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
|
if a.AdminToken == "" {
|
|
writeJSON(w, http.StatusServiceUnavailable, errInvalidRequest{
|
|
Tag: "ServiceUnavailableError", Message: "admin token not configured (set ADMIN_TOKEN)",
|
|
})
|
|
return false
|
|
}
|
|
scheme, token, _ := strings.Cut(r.Header.Get("Authorization"), " ")
|
|
if strings.EqualFold(scheme, "bearer") && token != "" &&
|
|
subtle.ConstantTimeCompare([]byte(token), []byte(a.AdminToken)) == 1 {
|
|
return true
|
|
}
|
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
writeJSON(w, http.StatusUnauthorized, errUnauthorized{
|
|
Tag: "UnauthorizedError", Message: "Invalid admin token",
|
|
})
|
|
return false
|
|
}
|
|
|
|
func (a *API) adminOverviewHandler(w http.ResponseWriter, r *http.Request) {
|
|
if !a.adminAuthorized(w, r) {
|
|
return
|
|
}
|
|
|
|
bridgeInfos := a.Hub.BridgeInfos()
|
|
routes := map[string][]string{}
|
|
visitors := map[string]int{}
|
|
for _, b := range bridgeInfos {
|
|
routes[b.TunnelID] = b.Routes
|
|
visitors[b.TunnelID] += b.Visitors
|
|
}
|
|
|
|
overview := adminOverview{
|
|
Server: adminServer{
|
|
Domain: a.Domain,
|
|
Version: a.Version,
|
|
Go: runtime.Version(),
|
|
Issuer: a.issuerName(),
|
|
UptimeSeconds: int64(time.Since(a.StartedAt).Seconds()),
|
|
},
|
|
CA: adminCA{Expires: a.caExpiry()},
|
|
Totals: adminTotals{Bridges: len(bridgeInfos)},
|
|
}
|
|
for _, b := range bridgeInfos {
|
|
overview.Totals.Visitors += b.Visitors
|
|
overview.Bridges = append(overview.Bridges, adminBridge{
|
|
Session: b.Session,
|
|
Tunnel: b.TunnelID,
|
|
ClientVersion: b.ClientVersion,
|
|
Routes: b.Routes,
|
|
Visitors: b.Visitors,
|
|
MaxConns: b.MaxConns,
|
|
ConnectedAt: b.ConnectedAt.UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
sort.Slice(overview.Bridges, func(i, j int) bool {
|
|
return overview.Bridges[i].Session < overview.Bridges[j].Session
|
|
})
|
|
|
|
tunnels := a.Store.Tunnels()
|
|
overview.Totals.Tunnels = len(tunnels)
|
|
for _, t := range tunnels {
|
|
state := "offline"
|
|
if visitors[t.ID] > 0 || routes[t.ID] != nil {
|
|
state = "online"
|
|
overview.Totals.Online++
|
|
}
|
|
entry := adminTunnel{
|
|
ID: t.ID,
|
|
Hostname: t.Hostname,
|
|
State: state,
|
|
Routes: routes[t.ID],
|
|
Visitors: visitors[t.ID],
|
|
CreatedAt: t.CreatedAt,
|
|
}
|
|
if t.Certificate != nil {
|
|
entry.CertificateExpiry = t.Certificate.Expiry
|
|
entry.CertificateState = certState(t.Certificate).Type
|
|
entry.CertificateIssuer = t.Certificate.Issuer
|
|
entry.CertificateError = t.Certificate.Reason
|
|
switch {
|
|
case certReady(t.Certificate):
|
|
overview.Metrics.CertificatesReady++
|
|
if t.Certificate.State == "failed" {
|
|
overview.Metrics.CertificatesRetrying++
|
|
}
|
|
case t.Certificate.State == "failed":
|
|
overview.Metrics.CertificatesFailed++
|
|
default:
|
|
overview.Metrics.CertificatesIssuing++
|
|
}
|
|
}
|
|
overview.Tunnels = append(overview.Tunnels, entry)
|
|
}
|
|
|
|
overview.Metrics.CreateRequests = a.createCount.Load()
|
|
overview.Metrics.BindRequests = a.bindCount.Load()
|
|
overview.Metrics.RateLimited = a.rateLimited.Load()
|
|
overview.Metrics.IssuanceAttempts = a.issueAttempts.Load()
|
|
overview.Metrics.IssuanceReady = a.issueReady.Load()
|
|
overview.Metrics.IssuanceFailed = a.issueFailed.Load()
|
|
|
|
writeJSON(w, http.StatusOK, overview)
|
|
}
|
|
|
|
func (a *API) caExpiry() string {
|
|
ca, err := a.Store.ca()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return expiryString(ca.cert.NotAfter)
|
|
}
|
|
|
|
// dashboard serves the built Svelte admin page and its assets; data comes
|
|
// from /api/admin/overview. Without a built frontend (only the .gitkeep
|
|
// placeholder), it answers 503 with the build command.
|
|
func (a *API) dashboard(w http.ResponseWriter, r *http.Request) {
|
|
dist := dashboard.FS()
|
|
if r.URL.Path == "/" || r.URL.Path == "/admin" {
|
|
page, err := fs.ReadFile(dist, "index.html")
|
|
if err != nil {
|
|
http.Error(w, "dashboard not built: run `bun install && bun run build` in web/", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("X-Robots-Tag", "noindex")
|
|
w.Write(page)
|
|
return
|
|
}
|
|
http.FileServer(http.FS(dist)).ServeHTTP(w, r)
|
|
}
|