Port awg-ui to Go: awg-control ui
internal/ui reimplements the awg-ui backend with the same HTTP surface; the React frontend is unchanged and served from awg-ui/dist. - web.go: Express-like router (express.json semantics, mounts, :params, HTML 400/404/413 pages, SPA fallback) - server.go: JWT login with rate limit, API keys and their server sets, awg-ctrl client and proxy, /api/v1/users, delete hooks - nodes.go: WSS hub for agents (pin, challenge-response, join strings), RPC with timeouts, health, /ui/nodes - sub.go, audit.go, sub_admin.go: sen:// master keys and devices, per-server reconcile with tombstones and /api/peers/sync, signed /sub/v1 listener, audit with two-pass confirmation and cleaning, panel and /api/v1/masterkeys routers - metrics.go, monitor.go: /metrics exposition with tokens, /ui/monitor - sen.go: link codec and protocol signatures, tested byte for byte against fixtures produced by awg-ui/sen.ts (tools/golden/sen.ts) Differential bench (tools/diffbench/local-ui.sh, scenario-ui.ts, compare-ui.mjs): 158 steps over every route group, including signed device requests and a Go agent joining as a node, plus the resulting ui.db and awg-ctrl state. TS and Go are identical after masking random values, across repeated runs and with the race detector on.
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
package ui
|
||||
|
||||
// Минимальный роутер с поведением Express 4, на которое опирается фронтенд и внешние клиенты:
|
||||
// - тело разбирает express.json() до маршрутизации и для всех путей: без тела или не JSON —
|
||||
// пустой объект {}, битый JSON / не объект и не массив — HTML 400, больше лимита — HTML 413;
|
||||
// - пути без учёта регистра и с необязательным «/» в конце; :param декодируется;
|
||||
// - use(prefix) совпадает по границе сегмента; первое подходящее объявление выигрывает;
|
||||
// - неизвестный путь — HTML 404 «Cannot METHOD /path», как finalhandler.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/maeneko/forgetting/internal/jsjson"
|
||||
)
|
||||
|
||||
type Ctx struct {
|
||||
W http.ResponseWriter
|
||||
R *http.Request
|
||||
Params map[string]string
|
||||
Body any // jsjson.Object, []any; без тела — пустой Object
|
||||
RawBody []byte // как пришло (для подписи /sub/v1)
|
||||
Path string // путь внутри смонтированного use(prefix), как req.path в под-роутере
|
||||
|
||||
// Внешний API: ключ запроса, его серверы и выбранный сервер.
|
||||
apiKey *apiKeyRow
|
||||
servers []int
|
||||
serverID int
|
||||
device *deviceRow
|
||||
}
|
||||
|
||||
// handler возвращает false, если ответ уже отправлен и цепочка дальше не идёт.
|
||||
type handler func(c *Ctx) bool
|
||||
|
||||
type route struct {
|
||||
method string // "" — любой (use)
|
||||
prefix bool // use(): совпадение по префиксу
|
||||
segs []string
|
||||
hs []handler
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
routes []route
|
||||
// notFound — ответ, если ничего не совпало (у /sub/v1 он подписанный).
|
||||
notFound func(c *Ctx)
|
||||
// badBody — ответ на неразобранное тело (у /sub/v1 подписанный 400).
|
||||
badBody func(w http.ResponseWriter, r *http.Request, status int, msg string)
|
||||
bodyLimit int64
|
||||
}
|
||||
|
||||
func newRouter() *Router { return &Router{bodyLimit: 100 * 1024} }
|
||||
|
||||
func splitPath(p string) []string {
|
||||
p = strings.Trim(p, "/")
|
||||
if p == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(p, "/")
|
||||
}
|
||||
|
||||
func (rt *Router) add(method string, prefix bool, pattern string, hs ...handler) {
|
||||
rt.routes = append(rt.routes, route{method: method, prefix: prefix, segs: splitPath(pattern), hs: hs})
|
||||
}
|
||||
|
||||
func (rt *Router) Get(p string, hs ...handler) { rt.add("GET", false, p, hs...) }
|
||||
func (rt *Router) Post(p string, hs ...handler) { rt.add("POST", false, p, hs...) }
|
||||
func (rt *Router) Put(p string, hs ...handler) { rt.add("PUT", false, p, hs...) }
|
||||
func (rt *Router) Patch(p string, hs ...handler) { rt.add("PATCH", false, p, hs...) }
|
||||
func (rt *Router) Delete(p string, hs ...handler) { rt.add("DELETE", false, p, hs...) }
|
||||
func (rt *Router) Use(p string, hs ...handler) { rt.add("", true, p, hs...) }
|
||||
|
||||
// Mount — app.use(prefix, mw..., router): маршруты под-роутера с префиксом и общими mw.
|
||||
func (rt *Router) Mount(prefix string, sub *Router, mw ...handler) {
|
||||
pre := splitPath(prefix)
|
||||
for _, r := range sub.routes {
|
||||
segs := append(append([]string{}, pre...), r.segs...)
|
||||
rt.routes = append(rt.routes, route{method: r.method, prefix: r.prefix, segs: segs, hs: append(append([]handler{}, mw...), r.hs...)})
|
||||
}
|
||||
}
|
||||
|
||||
// errDecode — %-последовательность в параметре не декодируется (Express: 400 URIError).
|
||||
type errDecode struct{}
|
||||
|
||||
func match(r route, method string, segs []string) (map[string]string, int, error) {
|
||||
if r.method != "" && r.method != method && !(r.method == "GET" && method == "HEAD") {
|
||||
return nil, 0, nil
|
||||
}
|
||||
if len(segs) < len(r.segs) || !r.prefix && len(segs) != len(r.segs) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
params := map[string]string{}
|
||||
for i, p := range r.segs {
|
||||
if strings.HasPrefix(p, ":") {
|
||||
v, err := url.PathUnescape(segs[i])
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("URIError: Failed to decode param '%s'", segs[i])
|
||||
}
|
||||
params[p[1:]] = v
|
||||
} else if !strings.EqualFold(p, segs[i]) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
}
|
||||
return params, len(r.segs), nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
b, err := jsjson.Marshal(v)
|
||||
if err != nil {
|
||||
status, b = 500, []byte(`{"error":"Internal server error"}`)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func writeHTMLError(w http.ResponseWriter, status int, text string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>%s</pre>\n</body>\n</html>\n", html.EscapeString(text))
|
||||
}
|
||||
|
||||
// readBody — express.json({limit}): только application/json, объект или массив.
|
||||
func readBody(r *http.Request, limit int64) (body any, raw []byte, status int, msg string) {
|
||||
empty := jsjson.Object{}
|
||||
mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if mt != "application/json" || r.Body == nil || r.Body == http.NoBody {
|
||||
return empty, nil, 0, ""
|
||||
}
|
||||
if r.ContentLength > limit {
|
||||
return nil, nil, 413, "PayloadTooLargeError: request entity too large"
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, nil, 400, "BadRequestError: request aborted"
|
||||
}
|
||||
if int64(len(raw)) > limit {
|
||||
return nil, nil, 413, "PayloadTooLargeError: request entity too large"
|
||||
}
|
||||
trimmed := strings.TrimLeftFunc(string(raw), unicode.IsSpace)
|
||||
if trimmed == "" {
|
||||
return empty, raw, 0, ""
|
||||
}
|
||||
if trimmed[0] != '{' && trimmed[0] != '[' {
|
||||
return nil, nil, 400, "SyntaxError: Unexpected token in JSON"
|
||||
}
|
||||
v, err := jsjson.Decode(raw)
|
||||
if err != nil {
|
||||
return nil, nil, 400, "SyntaxError: " + err.Error()
|
||||
}
|
||||
return v, raw, 0, ""
|
||||
}
|
||||
|
||||
func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
body, raw, status, msg := readBody(r, rt.bodyLimit)
|
||||
if status != 0 {
|
||||
if rt.badBody != nil {
|
||||
rt.badBody(w, r, status, msg)
|
||||
} else {
|
||||
writeHTMLError(w, status, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
segs := splitPath(r.URL.EscapedPath())
|
||||
c := &Ctx{W: w, R: r, Body: body, RawBody: raw}
|
||||
for _, route := range rt.routes {
|
||||
params, n, err := match(route, r.Method, segs)
|
||||
if err != nil {
|
||||
writeHTMLError(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if params == nil {
|
||||
continue
|
||||
}
|
||||
c.Params, c.Path = params, "/"+strings.Join(segs[n:], "/")
|
||||
for _, h := range route.hs {
|
||||
if !h(c) {
|
||||
return // ответ отправлен
|
||||
}
|
||||
}
|
||||
// Все обработчики вызвали next() — ищем дальше, как Express.
|
||||
}
|
||||
if rt.notFound != nil {
|
||||
rt.notFound(c)
|
||||
return
|
||||
}
|
||||
writeHTMLError(w, 404, "Cannot "+r.Method+" "+r.URL.EscapedPath())
|
||||
}
|
||||
|
||||
// ── Ответы ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (c *Ctx) JSON(status int, v any) bool {
|
||||
writeJSON(c.W, status, v)
|
||||
return false
|
||||
}
|
||||
|
||||
func errObj(msg string) jsjson.Object { return jsjson.Object{{Key: "error", Val: msg}} }
|
||||
|
||||
func (c *Ctx) Error(status int, msg string) bool { return c.JSON(status, errObj(msg)) }
|
||||
|
||||
func (c *Ctx) Obj() jsjson.Object {
|
||||
o, _ := c.Body.(jsjson.Object)
|
||||
return o
|
||||
}
|
||||
|
||||
// remoteIP — req.socket.remoteAddress.
|
||||
func (c *Ctx) remoteIP() string {
|
||||
host, _, err := net.SplitHostPort(c.R.RemoteAddr)
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// hostname — req.hostname в Express (trust proxy выключен): Host без порта, у IPv6 — в скобках.
|
||||
func (c *Ctx) hostname() string {
|
||||
h := c.R.Host
|
||||
if strings.HasPrefix(h, "[") {
|
||||
if i := strings.Index(h, "]"); i >= 0 {
|
||||
return h[:i+1]
|
||||
}
|
||||
return h
|
||||
}
|
||||
if i := strings.Index(h, ":"); i >= 0 {
|
||||
return h[:i]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ── Значения из разобранного JSON (как обращение к полям в JS) ──────────────
|
||||
|
||||
// field — v.a.b.c; чего нет — nil (undefined).
|
||||
func field(v any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
o, ok := v.(jsjson.Object)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
v = o.Get2(k)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func has(o jsjson.Object, k string) bool {
|
||||
_, ok := o.Get(k)
|
||||
return ok
|
||||
}
|
||||
|
||||
func str(v any) (string, bool) {
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func num(v any) (float64, bool) {
|
||||
f, ok := v.(float64)
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// isInt — Number.isInteger.
|
||||
func isInt(v any) bool {
|
||||
f, ok := v.(float64)
|
||||
return ok && f == float64(int64(f)) && f >= -9007199254740991*1024 && f <= 9007199254740991*1024
|
||||
}
|
||||
|
||||
// isSafeInt — Number.isSafeInteger.
|
||||
func isSafeInt(f float64) bool {
|
||||
return f == float64(int64(f)) && f >= -9007199254740991 && f <= 9007199254740991
|
||||
}
|
||||
|
||||
// numOr — `x ?? def` для числового поля.
|
||||
func numOr(v any, def float64) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
if v == nil {
|
||||
return def
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func strOr(v any, def string) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func truthy(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return x
|
||||
case float64:
|
||||
return x != 0 && x == x
|
||||
case string:
|
||||
return x != ""
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func arr(v any) []any {
|
||||
a, _ := v.([]any)
|
||||
return a
|
||||
}
|
||||
Reference in New Issue
Block a user