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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user