Start the Go rewrite: jsjson, awg-ctrl port, differential bench

- internal/jsjson: JSON.stringify-compatible encoder and order-preserving decoder,
  tested against fixtures produced by Node itself
- internal/ctrl: awg-ctrl in Go (params, vpn:// keys, users, peers, sync, 3.1
  upgrade, CAKE shaping, metrics, monitor job, Express-compatible HTTP layer)
- internal/iauth, internal/logx: internal Ed25519 tokens and logging
- tools/diffbench: Docker bench with stubbed awg/ip/tc/nft that runs one scenario
  against the TS and Go implementations for comparison
This commit is contained in:
2026-10-14 13:27:24 +00:00
parent 53d5a08bbd
commit 59e076c1de
28 changed files with 4960 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
package ctrl
import (
"fmt"
"io"
"mime"
"net/http"
"regexp"
"strings"
"unicode"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Минимальный роутер с поведением Express 4, на которое опираются клиенты:
// - тело JSON разбирается ДО авторизации и маршрутизации (битый JSON без токена → 400, не 401);
// - ошибки разбора и неизвестные пути отвечают HTML, как finalhandler Express;
// - пути без учёта регистра и с необязательным «/» в конце; :param декодируется.
// Первое совпавшее объявление выигрывает — порядок ниже важен («reissue» до «:name», «sync» до «:pub»).
type rctx struct {
w http.ResponseWriter
r *http.Request
s *Server
params map[string]string
body any // jsjson.Object, []any или nil (= {})
}
type result struct {
status int
v any
}
type route struct {
method string
segs []string
mw []func(*rctx) error
lock bool
h func(*rctx) (result, error)
}
const (
bodyLimitSmall = 1024
bodyLimitBig = 512 * 1024
)
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.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", text)
}
// readBody — express.json({limit}): только application/json, объект или массив.
func readBody(r *http.Request, limit int64) (any, int, string) {
mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mt != "application/json" || r.ContentLength == 0 && r.Body == http.NoBody {
return nil, 0, ""
}
if r.ContentLength > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
if err != nil {
return nil, 400, "BadRequestError: request aborted"
}
if int64(len(raw)) > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
trimmed := strings.TrimLeftFunc(string(raw), unicode.IsSpace)
if trimmed == "" {
return nil, 0, ""
}
if trimmed[0] != '{' && trimmed[0] != '[' {
return nil, 400, "SyntaxError: Unexpected token in JSON"
}
v, err := jsjson.Decode(raw)
if err != nil {
return nil, 400, "SyntaxError: " + err.Error()
}
return v, 0, ""
}
func (c *rctx) obj() jsjson.Object {
o, _ := c.body.(jsjson.Object)
return o
}
func match(rt route, method string, segs []string) map[string]string {
if rt.method != method || len(rt.segs) != len(segs) {
return nil
}
params := map[string]string{}
for i, p := range rt.segs {
if strings.HasPrefix(p, ":") {
v, ok := unescape(segs[i])
if !ok {
return nil
}
params[p[1:]] = v
} else if !strings.EqualFold(p, segs[i]) {
return nil
}
}
return params
}
func unescape(s string) (string, bool) {
if !strings.Contains(s, "%") {
return s, true
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+2 < len(s)+0 && isHex(s[i+1]) && isHex(s[i+2]) {
b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))
i += 2
} else if s[i] == '%' {
return "", false
} else {
b.WriteByte(s[i])
}
}
return b.String(), true
}
func isHex(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
func unhex(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
}
return c - 'A' + 10
}
func (s *Server) handler() http.Handler {
routes := s.routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Тело разбирается первым и для всех путей; лимит выбирает точный путь.
limit := int64(bodyLimitSmall)
p := strings.TrimSuffix(r.URL.EscapedPath(), "/")
if strings.EqualFold(p, "/api/peers/sync") || strings.EqualFold(p, "/api/audit/clean") {
limit = bodyLimitBig
}
body, status, msg := readBody(r, limit)
if status != 0 {
writeHTMLError(w, status, msg)
return
}
segs := strings.Split(strings.Trim(r.URL.EscapedPath(), "/"), "/")
for _, rt := range routes {
params := match(rt, r.Method, segs)
if params == nil {
continue
}
c := &rctx{w: w, r: r, s: s, params: params, body: body}
s.serve(c, rt)
return
}
writeHTMLError(w, 404, "Cannot "+r.Method+" "+r.URL.Path)
})
}
func (s *Server) serve(c *rctx, rt route) {
defer func() {
if p := recover(); p != nil {
s.log.Error("handler panic", "error", fmt.Sprint(p))
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
}()
for _, mw := range rt.mw {
if err := mw(c); err != nil {
s.fail(c, err)
return
}
}
if rt.lock {
s.mu.Lock()
defer s.mu.Unlock()
}
res, err := rt.h(c)
if err != nil {
s.fail(c, err)
return
}
writeJSON(c.w, res.status, res.v)
}
func (s *Server) fail(c *rctx, err error) {
if he, ok := err.(*HTTPError); ok {
writeJSON(c.w, he.Status, jsjson.Object{{Key: "error", Val: he.Msg}})
return
}
s.log.Error("handler error", "error", err)
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
// ───────── middleware ─────────
func (s *Server) auth(c *rctx) error {
h := c.r.Header.Get("Authorization")
tok := ""
if strings.HasPrefix(h, "Bearer ") {
tok = h[7:]
}
if tok == "" || !iauth.Verify(s.pub, tok, nowFn()) {
return httpErr(401, "Неверная авторизация")
}
return nil
}
var reName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`)
func validateName(c *rctx) error {
name, ok := c.params["name"]
if !ok {
name, _ = c.obj().Get2("name").(string)
}
if name == "" || !reName.MatchString(name) {
return httpErr(400, "Имя: буквы, цифры, _ и -, до 32 символов")
}
return nil
}