Add admin dashboard at / with bearer-token overview API
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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` | `<data dir>/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://<domain>/` (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 <ADMIN_TOKEN>`) 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
|
||||
|
||||
|
||||
+18
-1
@@ -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=<v>".
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -204,6 +242,8 @@ func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel
|
||||
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{},
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>tunneld admin</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
background: #0d1117; color: #e6edf3; }
|
||||
header { display: flex; flex-wrap: wrap; gap: 12px; align-items: baseline;
|
||||
padding: 20px 24px; border-bottom: 1px solid #21262d; }
|
||||
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
||||
header .meta { color: #8b949e; font-size: 12px; }
|
||||
header .spacer { flex: 1; }
|
||||
main { padding: 24px; max-width: 1100px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px; margin-bottom: 24px; }
|
||||
.card { background: #161b22; border: 1px solid #21262d; border-radius: 8px;
|
||||
padding: 14px 16px; }
|
||||
.card .label { color: #8b949e; font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: .06em; }
|
||||
.card .value { font-size: 22px; margin-top: 4px; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em;
|
||||
color: #8b949e; margin: 24px 0 8px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #161b22;
|
||||
border: 1px solid #21262d; border-radius: 8px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #21262d; }
|
||||
th { color: #8b949e; font-weight: 500; font-size: 12px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.pill { display: inline-block; padding: 1px 8px; border-radius: 999px;
|
||||
font-size: 12px; border: 1px solid; }
|
||||
.online { color: #3fb950; border-color: #238636; background: #0f2417; }
|
||||
.offline { color: #8b949e; border-color: #30363d; background: #161b22; }
|
||||
.empty { color: #8b949e; padding: 12px; }
|
||||
form { display: flex; gap: 8px; align-items: center; }
|
||||
input { background: #0d1117; border: 1px solid #30363d; color: #e6edf3;
|
||||
border-radius: 6px; padding: 6px 10px; font: inherit; width: 340px; }
|
||||
button { background: #21262d; color: #e6edf3; border: 1px solid #30363d;
|
||||
border-radius: 6px; padding: 6px 14px; font: inherit; cursor: pointer; }
|
||||
button:hover { background: #30363d; }
|
||||
#error { color: #f85149; margin: 16px 0 0; white-space: pre-wrap; }
|
||||
.dim { color: #8b949e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>tunneld admin</h1>
|
||||
<span class="meta" id="server-meta"></span>
|
||||
<span class="spacer"></span>
|
||||
<form id="auth">
|
||||
<input type="password" id="token" placeholder="ADMIN_TOKEN" autocomplete="off">
|
||||
<button type="submit">Use token</button>
|
||||
<button type="button" id="forget">Forget</button>
|
||||
</form>
|
||||
</header>
|
||||
<main>
|
||||
<div id="error"></div>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="label">Tunnels</div><div class="value" id="t-tunnels">–</div></div>
|
||||
<div class="card"><div class="label">Online</div><div class="value" id="t-online">–</div></div>
|
||||
<div class="card"><div class="label">Bridges</div><div class="value" id="t-bridges">–</div></div>
|
||||
<div class="card"><div class="label">Visitors</div><div class="value" id="t-visitors">–</div></div>
|
||||
<div class="card"><div class="label">CA expires</div><div class="value" style="font-size:13px" id="t-ca">–</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Tunnels</h2>
|
||||
<table id="tunnels"><thead><tr>
|
||||
<th>Hostname</th><th>State</th><th>Routes</th><th>Visitors</th><th>Cert expiry</th><th>Created</th>
|
||||
</tr></thead><tbody></tbody></table>
|
||||
|
||||
<h2>Bridges</h2>
|
||||
<table id="bridges"><thead><tr>
|
||||
<th>Session</th><th>Tunnel</th><th>Client</th><th>Routes</th><th>Visitors</th><th>Connected</th>
|
||||
</tr></thead><tbody></tbody></table>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const KEY = "tunneld-admin-token";
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
|
||||
(c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
const fmt = (t) => t ? new Date(t).toLocaleString() : "–";
|
||||
|
||||
const tokenInput = $("token");
|
||||
tokenInput.value = localStorage.getItem(KEY) || "";
|
||||
|
||||
$("auth").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
localStorage.setItem(KEY, tokenInput.value.trim());
|
||||
refresh();
|
||||
});
|
||||
$("forget").addEventListener("click", () => {
|
||||
localStorage.removeItem(KEY);
|
||||
tokenInput.value = "";
|
||||
refresh();
|
||||
});
|
||||
|
||||
function row(cells) {
|
||||
return "<tr>" + cells.map((c) => "<td>" + c + "</td>").join("") + "</tr>";
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const s = data.server;
|
||||
$("server-meta").textContent =
|
||||
s.domain + " · v" + s.version + " · " + s.go + " · up " +
|
||||
Math.floor(s.uptime_seconds / 60) + "m";
|
||||
$("t-tunnels").textContent = data.totals.tunnels;
|
||||
$("t-online").textContent = data.totals.online;
|
||||
$("t-bridges").textContent = data.totals.bridges;
|
||||
$("t-visitors").textContent = data.totals.visitors;
|
||||
$("t-ca").textContent = fmt(data.ca.expires);
|
||||
|
||||
$("tunnels").querySelector("tbody").innerHTML = (data.tunnels || []).length
|
||||
? data.tunnels.map((t) => row([
|
||||
esc(t.hostname),
|
||||
'<span class="pill ' + (t.state === "online" ? "online" : "offline") + '">' + esc(t.state) + "</span>",
|
||||
esc((t.routes || []).join(", ")) || '<span class="dim">–</span>',
|
||||
t.visitors,
|
||||
fmt(t.certificate_expiry),
|
||||
fmt(t.created_at),
|
||||
])).join("")
|
||||
: '<tr><td colspan="6" class="empty">No tunnels yet.</td></tr>';
|
||||
|
||||
$("bridges").querySelector("tbody").innerHTML = (data.bridges || []).length
|
||||
? data.bridges.map((b) => row([
|
||||
esc(b.session),
|
||||
esc(b.tunnel),
|
||||
esc(b.client_version) || '<span class="dim">–</span>',
|
||||
esc((b.routes || []).join(", ")) || '<span class="dim">–</span>',
|
||||
b.visitors + " / " + b.max_conns,
|
||||
fmt(b.connected_at),
|
||||
])).join("")
|
||||
: '<tr><td colspan="6" class="empty">No bridges attached.</td></tr>';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const token = localStorage.getItem(KEY) || "";
|
||||
const err = $("error");
|
||||
if (!token) { err.textContent = "Enter the ADMIN_TOKEN to view the dashboard."; return; }
|
||||
try {
|
||||
const res = await fetch("/api/admin/overview", {
|
||||
headers: { "Authorization": "Bearer " + token },
|
||||
});
|
||||
if (res.status === 401) { err.textContent = "Invalid admin token."; return; }
|
||||
if (res.status === 503) { err.textContent = "Server has no ADMIN_TOKEN configured."; return; }
|
||||
if (!res.ok) { err.textContent = "Error " + res.status + " from the server."; return; }
|
||||
err.textContent = "";
|
||||
render(await res.json());
|
||||
} catch (e) {
|
||||
err.textContent = "Request failed: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user