Add admin dashboard at / with bearer-token overview API

This commit is contained in:
2026-10-25 15:12:33 +08:00
parent 4acf226455
commit f5fcdd7bbb
9 changed files with 545 additions and 10 deletions
+3
View File
@@ -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 checked before auth (404 before 401), body JSON syntax before everything
- `internal/server/bridge.go` — WebSocket bridge sessions: attach, route - `internal/server/bridge.go` — WebSocket bridge sessions: attach, route
claiming (one owner per route), heartbeats, idle retirement, visitor pumps 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, - `internal/server/relay.go` — public TLS listener: parses the ClientHello,
terminates the domain itself, passes tunnel hostnames to bridges untouched terminates the domain itself, passes tunnel hostnames to bridges untouched
+12
View File
@@ -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 | | `-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) | | `-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 | | `-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) ## Deploy on Coolify (or any Docker host)
@@ -93,6 +104,7 @@ publicly trusted.
heartbeats, idle retirement, `open`/`end`/`reset`, 32 KiB data frames heartbeats, idle retirement, `open`/`end`/`reset`, 32 KiB data frames
- SNI routing on the TLS listener: the domain is terminated (API), tunnel - SNI routing on the TLS listener: the domain is terminated (API), tunnel
hostnames are passed through still encrypted 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 - Persistence: JSON store with the CA, server certificate, and tunnels
- Graceful shutdown: `drain` + close to every bridge - Graceful shutdown: `drain` + close to every bridge
+18 -1
View File
@@ -18,6 +18,10 @@ import (
"gitea.tzgyn.dev/TZGyn/tunnel/internal/server" "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 { func envOr(key, fallback string) string {
if value := os.Getenv(key); value != "" { if value := os.Getenv(key); value != "" {
return 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`) 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)") 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") 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() flag.Parse()
if *caFile == "" { if *caFile == "" {
@@ -70,7 +75,19 @@ func main() {
} }
hub := server.NewHub(store) 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 // Public TLS listener: SNI routing, with API terminations handed to the
// shared HTTP server. // shared HTTP server.
+161
View File
@@ -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)
}
+116
View File
@@ -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)
}
}
}
+9
View File
@@ -7,6 +7,7 @@ import (
"mime" "mime"
"net/http" "net/http"
"strings" "strings"
"time"
) )
// API is the HTTP control plane. Its answers match the recorded contract in // API is the HTTP control plane. Its answers match the recorded contract in
@@ -15,6 +16,10 @@ type API struct {
Store *Store Store *Store
Hub *Hub Hub *Hub
Domain string 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) { 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}`)) w.Write([]byte(`{"ok":true}`))
case path == "/openapi.json" && r.Method == http.MethodGet: case path == "/openapi.json" && r.Method == http.MethodGet:
writeJSON(w, http.StatusOK, json.RawMessage(openapiStub)) 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: case path == "/api/tunnel" && r.Method == http.MethodPost:
a.createTunnel(w, r) a.createTunnel(w, r)
default: default:
+40
View File
@@ -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 { func (h *Hub) register(b *Bridge) bool {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@@ -128,6 +163,9 @@ type Bridge struct {
maxConns int maxConns int
ws *websocket.Conn ws *websocket.Conn
clientVersion string
startedAt time.Time
outbox chan outbound outbox chan outbound
done chan struct{} done chan struct{}
closeOnce sync.Once closeOnce sync.Once
@@ -204,6 +242,8 @@ func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel
routes: routes, routes: routes,
maxConns: maxConnsPerBridge, maxConns: maxConnsPerBridge,
ws: ws, ws: ws,
clientVersion: attach.Client.Version,
startedAt: time.Now(),
outbox: make(chan outbound, 256), outbox: make(chan outbound, 256),
done: make(chan struct{}), done: make(chan struct{}),
conns: map[uint32]*visitorConn{}, conns: map[uint32]*visitorConn{},
+162
View File
@@ -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) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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>
+15
View File
@@ -7,6 +7,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"sync" "sync"
"time" "time"
) )
@@ -25,6 +26,7 @@ type storedTunnel struct {
ID string `json:"id"` ID string `json:"id"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
TokenHash string `json:"tokenHash"` TokenHash string `json:"tokenHash"`
CreatedAt string `json:"createdAt,omitempty"`
Certificate *storedCert `json:"certificate,omitempty"` Certificate *storedCert `json:"certificate,omitempty"`
} }
@@ -125,6 +127,7 @@ func (s *Store) createTunnel(domain string) (storedTunnel, string, error) {
t := storedTunnel{ t := storedTunnel{
ID: slug(), ID: slug(),
TokenHash: hashToken(token), TokenHash: hashToken(token),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
} }
t.Hostname = t.ID + "." + domain t.Hostname = t.ID + "." + domain
s.mu.Lock() s.mu.Lock()
@@ -141,6 +144,18 @@ func (s *Store) get(id string) (storedTunnel, bool) {
return t, ok 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 // 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. // renewal (and a resumed provision) can be answered without a new bind.
func (s *Store) bindCertificate(id string, cert storedCert) error { func (s *Store) bindCertificate(id string, cert storedCert) error {