From f5fcdd7bbbd3abaafda616d0747f276fa67a03a5 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 25 Oct 2026 15:12:33 +0800 Subject: [PATCH] Add admin dashboard at / with bearer-token overview API --- AGENTS.md | 3 + README.md | 12 +++ cmd/tunneld/main.go | 19 +++- internal/server/admin.go | 161 ++++++++++++++++++++++++++++++++ internal/server/admin_test.go | 116 +++++++++++++++++++++++ internal/server/api.go | 9 ++ internal/server/bridge.go | 58 ++++++++++-- internal/server/dashboard.html | 162 +++++++++++++++++++++++++++++++++ internal/server/store.go | 15 +++ 9 files changed, 545 insertions(+), 10 deletions(-) create mode 100644 internal/server/admin.go create mode 100644 internal/server/admin_test.go create mode 100644 internal/server/dashboard.html diff --git a/AGENTS.md b/AGENTS.md index 510c50c..b0564d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,9 @@ what we take vs. skip). Never vendor it into the build. checked before auth (404 before 401), body JSON syntax before everything - `internal/server/bridge.go` — WebSocket bridge sessions: attach, route claiming (one owner per route), heartbeats, idle retirement, visitor pumps +- `internal/server/admin.go` + `dashboard.html` — the admin dashboard served + at `/` (embedded HTML, no build step) and `GET /api/admin/overview`, both + behind the `ADMIN_TOKEN` bearer token (503 when unset, 401 when wrong) - `internal/server/relay.go` — public TLS listener: parses the ClientHello, terminates the domain itself, passes tunnel hostnames to bridges untouched diff --git a/README.md b/README.md index b7d596c..3735274 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,17 @@ Every flag also reads an environment variable (`tunneld -h`): | `-http-mode` | `HTTP_MODE` | `serve` | `serve` = plain-HTTP app (dev); `redirect` = 301 to the domain | | `-data` | `TUNNELD_DATA` | `$XDG_DATA_HOME/tunneld/data.json` | tunnel store (0600) | | `-ca-file` | `LOCAL_CA_FILE` | `/ca.pem` | where the local CA cert is written | +| `-admin-token` | `ADMIN_TOKEN` | *(empty)* | bearer token for the admin dashboard; empty disables the admin API | + +## Admin dashboard + +With `ADMIN_TOKEN` set, `https:///` (and `/admin`) serves a small +self-contained dashboard: server uptime and version, CA expiry, tunnels with +state/routes/cert expiry, and attached bridges with client version and live +connection counts. The page stores the token in `localStorage` and polls +`GET /api/admin/overview` (`Authorization: Bearer `) every 5 +seconds. Without `ADMIN_TOKEN` the endpoint answers 503 and the page is +inert; with a wrong token, 401. ## Deploy on Coolify (or any Docker host) @@ -93,6 +104,7 @@ publicly trusted. heartbeats, idle retirement, `open`/`end`/`reset`, 32 KiB data frames - SNI routing on the TLS listener: the domain is terminated (API), tunnel hostnames are passed through still encrypted +- Admin dashboard at `/` (bearer `ADMIN_TOKEN`): tunnels, bridges, CA expiry - Persistence: JSON store with the CA, server certificate, and tunnels - Graceful shutdown: `drain` + close to every bridge diff --git a/cmd/tunneld/main.go b/cmd/tunneld/main.go index f4d00fb..e72a877 100644 --- a/cmd/tunneld/main.go +++ b/cmd/tunneld/main.go @@ -18,6 +18,10 @@ import ( "gitea.tzgyn.dev/TZGyn/tunnel/internal/server" ) +// version is shown on the admin dashboard; override with +// -ldflags "-X main.version=". +var version = "dev" + func envOr(key, fallback string) string { if value := os.Getenv(key); value != "" { return value @@ -44,6 +48,7 @@ func main() { httpMode := flag.String("http-mode", envOr("HTTP_MODE", "serve"), `"serve" runs the whole app on plain HTTP, "redirect" 301s to the domain`) dataPath := flag.String("data", envOr("TUNNELD_DATA", dataDefault), "tunnel store file (created 0600)") caFile := flag.String("ca-file", envOr("LOCAL_CA_FILE", ""), "write the local CA certificate here for clients to trust") + adminToken := flag.String("admin-token", envOr("ADMIN_TOKEN", ""), "bearer token for the admin dashboard at / and /api/admin/overview; empty disables the admin API") flag.Parse() if *caFile == "" { @@ -70,7 +75,19 @@ func main() { } hub := server.NewHub(store) - api := &server.API{Store: store, Hub: hub, Domain: *domain} + api := &server.API{ + Store: store, + Hub: hub, + Domain: *domain, + AdminToken: *adminToken, + StartedAt: time.Now(), + Version: version, + } + if *adminToken == "" { + log.Printf("admin dashboard: disabled (set ADMIN_TOKEN to enable it at /)") + } else { + log.Printf("admin dashboard: enabled at /") + } // Public TLS listener: SNI routing, with API terminations handed to the // shared HTTP server. diff --git a/internal/server/admin.go b/internal/server/admin.go new file mode 100644 index 0000000..8b847c7 --- /dev/null +++ b/internal/server/admin.go @@ -0,0 +1,161 @@ +package server + +import ( + "crypto/subtle" + _ "embed" + "net/http" + "runtime" + "sort" + "strings" + "time" +) + +//go:embed dashboard.html +var dashboardHTML []byte + +// ---- admin API: GET /api/admin/overview, bearer ADMIN_TOKEN. + +type adminServer struct { + Domain string `json:"domain"` + Version string `json:"version"` + Go string `json:"go"` + 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"` + 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"` + Tunnels []adminTunnel `json:"tunnels"` + Bridges []adminBridge `json:"bridges"` +} + +// 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(), + 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 + } + overview.Tunnels = append(overview.Tunnels, entry) + } + + 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 admin page; data comes from /api/admin/overview. +func (a *API) dashboard(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Robots-Tag", "noindex") + w.Write(dashboardHTML) +} diff --git a/internal/server/admin_test.go b/internal/server/admin_test.go new file mode 100644 index 0000000..5240fa6 --- /dev/null +++ b/internal/server/admin_test.go @@ -0,0 +1,116 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestAdminOverview(t *testing.T) { + store, err := OpenStore("") + if err != nil { + t.Fatal(err) + } + api := &API{ + Store: store, Hub: NewHub(store), Domain: "opentunnel.test", + AdminToken: "admin-secret", StartedAt: time.Now().Add(-90 * time.Second), Version: "test", + } + srv := httptest.NewServer(api) + defer srv.Close() + + if _, _, err := store.createTunnel("opentunnel.test"); err != nil { + t.Fatal(err) + } + + // no token configured → 503 + noToken := &API{Store: store, Hub: NewHub(store), Domain: "opentunnel.test", StartedAt: time.Now()} + noTokenSrv := httptest.NewServer(noToken) + defer noTokenSrv.Close() + resp, err := http.Get(noTokenSrv.URL + "/api/admin/overview") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 503 { + t.Errorf("unconfigured admin token: got %d, want 503", resp.StatusCode) + } + resp.Body.Close() + + // missing and wrong token → 401 + for _, header := range []string{"", "Bearer nope"} { + req, _ := http.NewRequest("GET", srv.URL+"/api/admin/overview", nil) + if header != "" { + req.Header.Set("Authorization", header) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 401 { + t.Errorf("auth %q: got %d, want 401", header, resp.StatusCode) + } + resp.Body.Close() + } + + // correct token → 200 with the tunnel and server info + req, _ := http.NewRequest("GET", srv.URL+"/api/admin/overview", nil) + req.Header.Set("Authorization", "Bearer admin-secret") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("admin overview: got %d, want 200", resp.StatusCode) + } + var overview adminOverview + if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil { + t.Fatal(err) + } + if overview.Server.Domain != "opentunnel.test" || overview.Server.Version != "test" { + t.Errorf("server info: %+v", overview.Server) + } + if overview.Server.UptimeSeconds < 60 { + t.Errorf("uptime: %d", overview.Server.UptimeSeconds) + } + if overview.Totals.Tunnels != 1 || overview.Totals.Online != 0 { + t.Errorf("totals: %+v", overview.Totals) + } + if len(overview.Tunnels) != 1 || !strings.HasSuffix(overview.Tunnels[0].Hostname, ".opentunnel.test") { + t.Errorf("tunnels: %+v", overview.Tunnels) + } + if overview.Tunnels[0].State != "offline" { + t.Errorf("tunnel state: %q", overview.Tunnels[0].State) + } + if overview.CA.Expires == "" { + t.Error("CA expiry missing") + } +} + +func TestDashboardPage(t *testing.T) { + store, err := OpenStore("") + if err != nil { + t.Fatal(err) + } + api := &API{Store: store, Hub: NewHub(store), Domain: "opentunnel.test", StartedAt: time.Now()} + srv := httptest.NewServer(api) + defer srv.Close() + + for _, path := range []string{"/", "/admin"} { + resp, err := http.Get(srv.URL + path) + if err != nil { + t.Fatal(err) + } + body := make([]byte, 512) + n, _ := resp.Body.Read(body) + resp.Body.Close() + if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Type"), "text/html") { + t.Errorf("%s: got %d %q", path, resp.StatusCode, resp.Header.Get("Content-Type")) + } + if !strings.Contains(string(body[:n]), "tunneld admin") { + t.Errorf("%s: unexpected body", path) + } + } +} diff --git a/internal/server/api.go b/internal/server/api.go index 80af642..7f87ed2 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -7,6 +7,7 @@ import ( "mime" "net/http" "strings" + "time" ) // API is the HTTP control plane. Its answers match the recorded contract in @@ -15,6 +16,10 @@ type API struct { Store *Store Hub *Hub Domain string + + AdminToken string // bearer token for /api/admin/*; empty disables it + StartedAt time.Time // for the uptime shown on the dashboard + Version string } func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -27,6 +32,10 @@ func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"ok":true}`)) case path == "/openapi.json" && r.Method == http.MethodGet: writeJSON(w, http.StatusOK, json.RawMessage(openapiStub)) + case (path == "/" || path == "/admin") && r.Method == http.MethodGet: + a.dashboard(w, r) + case path == "/api/admin/overview" && r.Method == http.MethodGet: + a.adminOverviewHandler(w, r) case path == "/api/tunnel" && r.Method == http.MethodPost: a.createTunnel(w, r) default: diff --git a/internal/server/bridge.go b/internal/server/bridge.go index bcbd893..eac0125 100644 --- a/internal/server/bridge.go +++ b/internal/server/bridge.go @@ -82,6 +82,41 @@ func (h *Hub) DrainAll() { } } +// BridgeInfo is a snapshot of one attached bridge for the admin API. +type BridgeInfo struct { + Session string + TunnelID string + ClientVersion string + Routes []string + Visitors int + MaxConns int + ConnectedAt time.Time +} + +// BridgeInfos snapshots every attached bridge. Field reads are safe without +// the bridge lock because they are immutable after construction; only the +// visitor count needs the lock. +func (h *Hub) BridgeInfos() []BridgeInfo { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]BridgeInfo, 0, len(h.bridges)) + for b := range h.bridges { + b.mu.Lock() + visitors := len(b.conns) + b.mu.Unlock() + out = append(out, BridgeInfo{ + Session: b.session, + TunnelID: b.tunnelID, + ClientVersion: b.clientVersion, + Routes: b.routes, + Visitors: visitors, + MaxConns: b.maxConns, + ConnectedAt: b.startedAt, + }) + } + return out +} + func (h *Hub) register(b *Bridge) bool { h.mu.Lock() defer h.mu.Unlock() @@ -128,6 +163,9 @@ type Bridge struct { maxConns int ws *websocket.Conn + clientVersion string + startedAt time.Time + outbox chan outbound done chan struct{} closeOnce sync.Once @@ -198,15 +236,17 @@ func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel } b := &Bridge{ - hub: h, - tunnelID: tunnel.ID, - session: sessionID(), - routes: routes, - maxConns: maxConnsPerBridge, - ws: ws, - outbox: make(chan outbound, 256), - done: make(chan struct{}), - conns: map[uint32]*visitorConn{}, + hub: h, + tunnelID: tunnel.ID, + session: sessionID(), + routes: routes, + maxConns: maxConnsPerBridge, + ws: ws, + clientVersion: attach.Client.Version, + startedAt: time.Now(), + outbox: make(chan outbound, 256), + done: make(chan struct{}), + conns: map[uint32]*visitorConn{}, } if attach.Client.MaxConns > 0 && attach.Client.MaxConns < b.maxConns { b.maxConns = attach.Client.MaxConns diff --git a/internal/server/dashboard.html b/internal/server/dashboard.html new file mode 100644 index 0000000..70cc409 --- /dev/null +++ b/internal/server/dashboard.html @@ -0,0 +1,162 @@ + + + + + + +tunneld admin + + + +
+

tunneld admin

+ + +
+ + + +
+
+
+
+
+
Tunnels
–
+
Online
–
+
Bridges
–
+
Visitors
–
+
CA expires
–
+
+ +

Tunnels

+ + +
HostnameStateRoutesVisitorsCert expiryCreated
+ +

Bridges

+ + +
SessionTunnelClientRoutesVisitorsConnected
+
+ + + diff --git a/internal/server/store.go b/internal/server/store.go index fde1429..586aa1e 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "sync" "time" ) @@ -25,6 +26,7 @@ type storedTunnel struct { ID string `json:"id"` Hostname string `json:"hostname"` TokenHash string `json:"tokenHash"` + CreatedAt string `json:"createdAt,omitempty"` Certificate *storedCert `json:"certificate,omitempty"` } @@ -125,6 +127,7 @@ func (s *Store) createTunnel(domain string) (storedTunnel, string, error) { t := storedTunnel{ ID: slug(), TokenHash: hashToken(token), + CreatedAt: time.Now().UTC().Format(time.RFC3339), } t.Hostname = t.ID + "." + domain s.mu.Lock() @@ -141,6 +144,18 @@ func (s *Store) get(id string) (storedTunnel, bool) { return t, ok } +// Tunnels returns every tunnel, sorted by hostname (for the admin dashboard). +func (s *Store) Tunnels() []storedTunnel { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]storedTunnel, 0, len(s.data.Tunnels)) + for _, t := range s.data.Tunnels { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { return out[i].Hostname < out[j].Hostname }) + return out +} + // bindCSR records the issued certificate for the tunnel. The CSR is kept so a // renewal (and a resumed provision) can be answered without a new bind. func (s *Store) bindCertificate(id string, cert storedCert) error {