Tunnels now get a per-tunnel certificate from the configured ACME CA (Let's Encrypt by default) with DNS-01 through Porkbun, keeping the client's CSR and key. Issuance is asynchronous (issuing/ready/failed); the account persists in the store, renewals run in a 30-day window with retries, and a failed renewal keeps the old certificate serving. Switching from the local CA reissues existing tunnels automatically, and the admin dashboard shows the issuer and certificate state. Verified against Pebble and Let's Encrypt staging.
181 lines
5.2 KiB
Go
181 lines
5.2 KiB
Go
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"`
|
|
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(),
|
|
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
|
|
}
|
|
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 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)
|
|
}
|