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) }