Add admin dashboard at / with bearer-token overview API
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user