From 59e076c1de78223ba757da08e61ea6045d293e83 Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Wed, 14 Oct 2026 15:27:05 +0300 Subject: [PATCH] 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 --- cmd/awg-control/main.go | 31 ++ go.mod | 17 + go.sum | 22 ++ internal/ctrl/awg.go | 455 ++++++++++++++++++++++ internal/ctrl/db.go | 233 ++++++++++++ internal/ctrl/helpers.go | 118 ++++++ internal/ctrl/http.go | 236 ++++++++++++ internal/ctrl/metrics.go | 183 +++++++++ internal/ctrl/monitorjob.go | 104 +++++ internal/ctrl/ops.go | 605 ++++++++++++++++++++++++++++++ internal/ctrl/params.go | 152 ++++++++ internal/ctrl/routes.go | 392 +++++++++++++++++++ internal/ctrl/server.go | 186 +++++++++ internal/ctrl/shaping.go | 605 ++++++++++++++++++++++++++++++ internal/ctrl/vpnkey.go | 218 +++++++++++ internal/iauth/iauth.go | 122 ++++++ internal/jsjson/decode.go | 86 +++++ internal/jsjson/jsjson.go | 393 +++++++++++++++++++ internal/jsjson/jsjson_test.go | 84 +++++ internal/logx/logx.go | 60 +++ testdata/golden/jsjson.json | 274 ++++++++++++++ tools/diffbench/Dockerfile | 11 + tools/diffbench/collect.mjs | 20 + tools/diffbench/run.sh | 22 ++ tools/diffbench/scenario-ctrl.mjs | 112 ++++++ tools/diffbench/setup.sh | 53 +++ tools/diffbench/stubs/stub.mjs | 143 +++++++ tools/golden/jsjson.mjs | 23 ++ 28 files changed, 4960 insertions(+) create mode 100644 cmd/awg-control/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/ctrl/awg.go create mode 100644 internal/ctrl/db.go create mode 100644 internal/ctrl/helpers.go create mode 100644 internal/ctrl/http.go create mode 100644 internal/ctrl/metrics.go create mode 100644 internal/ctrl/monitorjob.go create mode 100644 internal/ctrl/ops.go create mode 100644 internal/ctrl/params.go create mode 100644 internal/ctrl/routes.go create mode 100644 internal/ctrl/server.go create mode 100644 internal/ctrl/shaping.go create mode 100644 internal/ctrl/vpnkey.go create mode 100644 internal/iauth/iauth.go create mode 100644 internal/jsjson/decode.go create mode 100644 internal/jsjson/jsjson.go create mode 100644 internal/jsjson/jsjson_test.go create mode 100644 internal/logx/logx.go create mode 100644 testdata/golden/jsjson.json create mode 100644 tools/diffbench/Dockerfile create mode 100644 tools/diffbench/collect.mjs create mode 100755 tools/diffbench/run.sh create mode 100644 tools/diffbench/scenario-ctrl.mjs create mode 100755 tools/diffbench/setup.sh create mode 100644 tools/diffbench/stubs/stub.mjs create mode 100644 tools/golden/jsjson.mjs diff --git a/cmd/awg-control/main.go b/cmd/awg-control/main.go new file mode 100644 index 0000000..b6f7473 --- /dev/null +++ b/cmd/awg-control/main.go @@ -0,0 +1,31 @@ +// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent и команды оператора. +package main + +import ( + "fmt" + "os" + + "github.com/maeneko/forgetting/internal/ctrl" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: awg-control ") + os.Exit(1) + } + switch os.Args[1] { + case "ctrl": + s, err := ctrl.New() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := s.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "awg-control: неизвестная команда %q\n", os.Args[1]) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d903c9c --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/maeneko/forgetting + +go 1.26.0 + +require ( + github.com/coder/websocket v1.8.15 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.48.0 // indirect + modernc.org/libc v1.77.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.60.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..67835cb --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs= +modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw= +modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0= diff --git a/internal/ctrl/awg.go b/internal/ctrl/awg.go new file mode 100644 index 0000000..6d80be2 --- /dev/null +++ b/internal/ctrl/awg.go @@ -0,0 +1,455 @@ +package ctrl + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unicode" +) + +// ───────── запуск команд ───────── + +type cmdResult struct { + Stdout, Stderr string + Code int // -1: не запустилась +} + +func (r cmdResult) ok() bool { return r.Code == 0 } + +func runCmdIn(stdin string, name string, args ...string) cmdResult { + cmd := exec.Command(name, args...) + var so, se bytes.Buffer + cmd.Stdout, cmd.Stderr = &so, &se + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + err := cmd.Run() + res := cmdResult{Stdout: so.String(), Stderr: se.String()} + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + res.Code = ee.ExitCode() + } else { + res.Code = -1 + } + } + return res +} + +func runCmd(name string, args ...string) cmdResult { return runCmdIn("", name, args...) } + +// runShell — аналог execSync(cmd).trim(): через sh, ошибка при ненулевом коде. +func runShell(cmd string) (string, error) { + r := runCmdIn("", "/bin/sh", "-c", cmd) + if !r.ok() { + return "", fmt.Errorf("Command failed: %s\n%s", cmd, r.Stderr) + } + return strings.TrimSpace(r.Stdout), nil +} + +func runCtx(timeout time.Duration, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + var so, se bytes.Buffer + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout, cmd.Stderr = &so, &se + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(se.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), msg) + } + return so.String(), nil +} + +// jsNumber — Number(s): пустая строка = 0, нечисло = NaN. +func jsNumber(s string) float64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return math.NaN() + } + return f +} + +// jsonStamp — new Date().toISOString().replace(/[:.]/g, "-"). +func jsonStamp(t time.Time) string { + t = t.UTC() + return t.Format("2006-01-02T15-04-05") + fmt.Sprintf("-%03dZ", t.Nanosecond()/1e6) +} + +// ───────── ключи ───────── + +func (s *Server) generateKeys() (Keys, error) { + priv, err := runShell("umask 077 && awg genkey") + if err != nil { + return Keys{}, err + } + r := runCmdIn(priv, "awg", "pubkey") + if !r.ok() { + return Keys{}, errors.New("awg pubkey завершился с ошибкой") + } + psk, err := runShell("awg genpsk") + if err != nil { + return Keys{}, err + } + return Keys{Private: priv, Public: strings.TrimSpace(r.Stdout), PSK: psk}, nil +} + +func (s *Server) genPSK() (string, error) { return runShell("awg genpsk") } + +func (s *Server) getServerPublicKey() (string, error) { + b, err := os.ReadFile(filepath.Join(s.confDir, "server_public.key")) + if err != nil { + return "", errors.New("server_public.key не найден") + } + return strings.TrimSpace(string(b)), nil +} + +// ───────── интерфейс ───────── + +type peerStat struct { + Online bool + LastHandshake float64 + Rx, Tx float64 +} + +// getPeersData: `awg show awg1 dump`, колонки [0] pub, [4] handshake, [5] rx, [6] tx. +func (s *Server) getPeersData() map[string]peerStat { + out, err := runShell("awg show " + s.iface + " dump") + if err != nil { + s.log.Warn("getPeersData failed", "error", err) + return map[string]peerStat{} + } + res := map[string]peerStat{} + now := float64(time.Now().Unix()) + lines := strings.Split(out, "\n") + for i := 1; i < len(lines); i++ { + parts := strings.Split(lines[i], "\t") + pub := parts[0] + if pub == "" { + continue + } + get := func(n int) float64 { + if n < len(parts) { + return jsNumber(parts[n]) + } + return math.NaN() // Number(undefined) + } + hs := get(4) + res[pub] = peerStat{ + Online: hs > 0 && (now-hs) < 180, + LastHandshake: hs, Rx: get(5), Tx: get(6), + } + } + return res +} + +type ifStatus struct { + Up bool `json:"up"` + Peers int `json:"peers"` + PublicKey *string `json:"publicKey"` +} + +var ( + rePeerLine = regexp.MustCompile(`(?m)^peer:`) + rePubKey = regexp.MustCompile(`public key:\s*(.+)`) +) + +func (s *Server) interfaceStatus() ifStatus { + r := runCmd("awg", "show", s.iface) + if !r.ok() { + return ifStatus{} + } + st := ifStatus{Up: true, Peers: len(rePeerLine.FindAllString(r.Stdout, -1))} + if m := rePubKey.FindStringSubmatch(r.Stdout); m != nil { + v := strings.TrimSpace(m[1]) + st.PublicKey = &v + } + return st +} + +func (s *Server) ensureInterfaceUp() error { + if !s.interfaceStatus().Up { + return fmt.Errorf("Interface %s is not up. Run: awg-quick up %s", s.iface, s.iface) + } + return nil +} + +// readVersions: версия загруженного модуля (важнее версии на диске: интерфейс обслуживает +// именно загруженный) и awg-tools. Вызывается на старте и при restartAwg — только тогда +// модуль может смениться; из /health не вызывается (он без авторизации). +func readVersions() (module, tools string) { + if b, err := os.ReadFile("/sys/module/amneziawg/version"); err == nil { + module = strings.TrimSpace(string(b)) + } else if v, err := runShell("modinfo -F version amneziawg"); err == nil { + module = v + } + if v, err := runShell("awg --version"); err == nil { + for _, t := range strings.Fields(v) { + if regexp.MustCompile(`^v?\d`).MatchString(t) { + tools = t + break + } + } + } + return +} + +// ───────── awg1.conf ───────── + +func (s *Server) confPath() string { return filepath.Join(s.confDir, s.iface+".conf") } + +func trimEndJS(s string) string { return strings.TrimRightFunc(s, unicode.IsSpace) } + +// rebuildConf пересобирает [Peer]-секции из БД; [Interface] сохраняется как есть. +// Пиры устройств идут после users; без записей в peers вывод байт-в-байт прежний. +func (s *Server) rebuildConf() error { + users, err := s.db.Query("SELECT name, ip, pub_key, psk_key FROM users") + if err != nil { + return err + } + type pr struct{ label, ip, pub, psk string } + var all []pr + for users.Next() { + var n, ip, pub, psk string + if err := users.Scan(&n, &ip, &pub, &psk); err != nil { + users.Close() + return err + } + all = append(all, pr{n, ip, pub, psk}) + } + users.Close() + devices, err := s.peersAll() + if err != nil { + return err + } + if _, err := os.Stat(s.confPath()); err != nil { + return nil + } + raw, err := os.ReadFile(s.confPath()) + if err != nil { + return err + } + for _, d := range devices { + label := d.Owner + if label == "" { + label = "device" + } + all = append(all, pr{label, d.IP, d.PubKey, d.PskKey}) + } + iface := trimEndJS(pPeerSplit.Split(string(raw), 2)[0]) + ka := s.cfg().Keepalive + blocks := make([]string, 0, len(all)) + for _, u := range all { + blocks = append(blocks, "\n# "+u.label+"\n[Peer]\nPublicKey = "+u.pub+"\nPresharedKey = "+u.psk+ + "\nAllowedIPs = "+u.ip+"/32\nPersistentKeepalive = "+ka) + } + if err := os.WriteFile(s.confPath(), []byte(iface+"\n"+strings.Join(blocks, "\n")+"\n"), 0o600); err != nil { + return err + } + s.log.Info("conf rebuilt", "peers", len(all)) + return nil +} + +var reAwgQuickOnly = regexp.MustCompile(`(?i)^\s*(Address|DNS|MTU|Table|PreUp|PostUp|PreDown|PostDown|SaveConfig)\s*=`) +var rePrivateKeyLine = regexp.MustCompile(`^\s*PrivateKey\s*=`) +var reInterfaceLine = regexp.MustCompile(`^\s*\[Interface\]`) + +// buildSyncConf готовит «stripped»-конфиг для `awg syncconf`: без awg-quick-ключей, но +// с [Interface] и PrivateKey. +// +// 🛑 КРИТИЧНО: [Interface] с PrivateKey ОБЯЗАН попасть в этот конфиг. Когда syncconf +// получал только [Peer]-блоки, AmneziaWG обнулял приватный ключ интерфейса и параметры +// обфускации — сервер поднимался с public-key=(none), и ВСЕ клиенты отваливались. +// PrivateKey берём из server_private.key — та же идентичность, что в server_public.key +// (его зашивают в vpn:// ключи) и в PostUp. +func (s *Server) buildSyncConf() (string, error) { + privRaw, err := os.ReadFile(s.serverPrivKeyFile) + if err != nil { + return "", err + } + priv := strings.TrimSpace(string(privRaw)) + raw, err := os.ReadFile(s.confPath()) + if err != nil { + return "", err + } + var out []string + replaced := false + for _, line := range strings.Split(string(raw), "\n") { + if reAwgQuickOnly.MatchString(line) { + continue + } + if rePrivateKeyLine.MatchString(line) { + out = append(out, "PrivateKey = "+priv) + replaced = true + continue + } + out = append(out, line) + } + if !replaced { + for i, l := range out { + if reInterfaceLine.MatchString(l) { + out = append(out[:i+1], append([]string{"PrivateKey = " + priv}, out[i+1:]...)...) + break + } + } + } + return strings.Join(out, "\n"), nil +} + +func (s *Server) syncPeers() error { + if err := s.rebuildConf(); err != nil { + return err + } + cfg, err := s.buildSyncConf() + if err != nil { + return err + } + f, err := os.CreateTemp("", "awg_sync_*.conf") // 0600, O_EXCL, случайное имя + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err := f.WriteString(cfg); err != nil { + f.Close() + return err + } + f.Close() + if r := runCmd("awg", "syncconf", s.iface, f.Name()); !r.ok() { + s.log.Warn("syncPeers syncconf failed", "stderr", r.Stderr) + } + var n int + s.db.QueryRow("SELECT COUNT(*) AS n FROM users").Scan(&n) + s.log.Info("peers synced", "count", n) + return nil +} + +// reloadAwgParams перечитывает параметры после любой правки конфига: иначе ключи +// vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1. +func (s *Server) reloadAwgParams() string { + p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) }) + s.cfgMu.Lock() + s.c.Params = p + s.c.Gen = p.Gen() + s.c.Keepalive = keepaliveByGen[s.c.Gen] + g := s.c.Gen + s.cfgMu.Unlock() + s.log.Info("awg params loaded from conf", "Jc", p.Jc, "H1", p.H1, "gen", g) + return g +} + +func (s *Server) restartAwg() error { + s.log.Info("AWG restart: down") + if r := runCmd("awg-quick", "down", s.iface); !r.ok() { + s.log.Warn("awg-quick down failed", "stderr", r.Stderr) + } + s.log.Info("AWG restart: up") + if r := runCmd("awg-quick", "up", s.iface); !r.ok() { + return fmt.Errorf("awg-quick up failed: %s", r.Stderr) + } + // Конфиг мог измениться, пока интерфейс лежал (переход на 3.1, ручная правка) — + // перечитываем ДО syncPeers: keepalive из CONFIG уходит в [Peer]-блоки. + gen := s.reloadAwgParams() + if err := s.syncPeers(); err != nil { + return err + } + go s.shaping.Apply() // tc-правила жили на интерфейсе, который мы только что пересоздали + m, t := readVersions() + s.cfgMu.Lock() + s.c.AwgModule, s.c.AwgTools = m, t + s.cfgMu.Unlock() + s.log.Info("AWG restart: done", "module", m, "tools", t, "gen", gen) + return nil +} + +func (s *Server) startInterface() (ifStatus, error) { + st := s.interfaceStatus() + if st.Up { + s.log.Info("AWG already up", "peers", st.Peers) + return st, nil + } + if r := runCmd("awg-quick", "up", s.iface); !r.ok() { + return ifStatus{}, fmt.Errorf("awg-quick up failed: %s", r.Stderr) + } + if err := s.syncPeers(); err != nil { + return ifStatus{}, err + } + go s.shaping.Apply() + return s.interfaceStatus(), nil +} + +// setPeer: PSK уходит во временный файл, а не в аргументы — иначе он виден в /proc//cmdline. +func (s *Server) setPeer(pub, psk, ip string) cmdResult { + f, err := os.CreateTemp("", "awg_psk_*.tmp") + if err != nil { + return cmdResult{Stderr: err.Error(), Code: -1} + } + defer os.Remove(f.Name()) + f.WriteString(psk) + f.Close() + return runCmd("awg", "set", s.iface, "peer", pub, + "preshared-key", f.Name(), + "allowed-ips", ip+"/32", + "persistent-keepalive", s.cfg().Keepalive) +} + +// kernelPeers — ключи пиров, стоящих в ядре (nil — не удалось спросить). +func (s *Server) kernelPeers() map[string]bool { + r := runCmd("awg", "show", s.iface, "peers") + if !r.ok() { + return nil + } + set := map[string]bool{} + for _, l := range strings.Split(r.Stdout, "\n") { + if l = strings.TrimSpace(l); l != "" { + set[l] = true + } + } + return set +} + +// dropFromKernel снимает пира с интерфейса и убеждается, что его там нет. Порядок во +// всех вызывающих: сначала ядро, потом база — при отказе строка остаётся, и повторная +// попытка (надгробие панели, следующий sync) найдёт пира. +func (s *Server) dropFromKernel(pub string) bool { + r := runCmd("awg", "set", s.iface, "peer", pub, "remove") + if r.ok() { + return true + } + if left := s.kernelPeers(); left != nil && !left[pub] { + return true // ответ странный, но пира уже нет + } + s.log.Error("peer remove from kernel failed", "pub", pub, "stderr", r.Stderr) + return false +} + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return fmt.Sprintf("%x", b) +} + +func b64Std(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) } +func stdB64(b []byte) string { return base64.StdEncoding.EncodeToString(b) } + +// lenientB64URL повторяет Buffer.from(s, "base64url") в Node: терпит паддинг, «+», «/». +func lenientB64URL(s string) ([]byte, error) { + s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "=")) + return base64.RawURLEncoding.DecodeString(s) +} diff --git a/internal/ctrl/db.go b/internal/ctrl/db.go new file mode 100644 index 0000000..064364a --- /dev/null +++ b/internal/ctrl/db.go @@ -0,0 +1,233 @@ +package ctrl + +import ( + "database/sql" + "fmt" + "net/url" + + _ "modernc.org/sqlite" +) + +// Схема users.db — та же, что у Node-версии: существующие базы открываются как есть. +// PRAGMA не задаём (журнал по умолчанию, без WAL); ждём блокировку 5 с, как better-sqlite3. + +type userRow struct { + Name string `json:"name"` + IP string `json:"ip"` + PubKey string `json:"pub_key"` + VpnKey string `json:"vpn_key"` + PskKey string `json:"psk_key"` + KeyGen string `json:"key_gen"` + VpnKeyPrev string `json:"vpn_key_prev"` +} + +type peerRow struct { + PubKey string + IP string + PskKey string + Owner string + CreatedAt int64 +} + +func openDB(path string) (*sql.DB, error) { + dsn := "file:" + (&url.URL{Path: path}).EscapedPath() + "?_pragma=busy_timeout(5000)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + // Один соединение: SQLite всё равно пишет по одному, а так нет «database is locked» между + // горутинами и Tx не конкурирует с запросами вне транзакции. + db.SetMaxOpenConns(1) + if err := initSchema(db); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +func initSchema(db *sql.DB) error { + for _, q := range []string{ + `CREATE TABLE IF NOT EXISTS users ( + name TEXT PRIMARY KEY, + ip TEXT NOT NULL UNIQUE, + pub_key TEXT NOT NULL, + vpn_key TEXT NOT NULL, + psk_key TEXT NOT NULL DEFAULT '' + )`, + "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)", + } { + if _, err := db.Exec(q); err != nil { + return err + } + } + // ALTER TABLE идемпотентным не бывает — смотрим фактический список колонок. + cols := map[string]bool{} + rows, err := db.Query("PRAGMA table_info(users)") + if err != nil { + return err + } + for rows.Next() { + var cid int + var name, typ string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + rows.Close() + return err + } + cols[name] = true + } + rows.Close() + if !cols["key_gen"] { + if _, err := db.Exec("ALTER TABLE users ADD COLUMN key_gen TEXT NOT NULL DEFAULT '2'"); err != nil { + return err + } + } + if !cols["vpn_key_prev"] { + if _, err := db.Exec("ALTER TABLE users ADD COLUMN vpn_key_prev TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + } + for _, q := range []string{ + `CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, + // Пиры устройств sen://: приватного ключа здесь нет и быть не может. + `CREATE TABLE IF NOT EXISTS peers ( + pub_key TEXT PRIMARY KEY, + ip TEXT NOT NULL UNIQUE, + psk_key TEXT NOT NULL, + owner TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL + )`, + } { + if _, err := db.Exec(q); err != nil { + return err + } + } + return nil +} + +type queryer interface { + Query(query string, args ...any) (*sql.Rows, error) + QueryRow(query string, args ...any) *sql.Row + Exec(query string, args ...any) (sql.Result, error) +} + +func (s *Server) getCfg(key, fallback string) string { + var v string + if err := s.db.QueryRow("SELECT value FROM config WHERE key = ?", key).Scan(&v); err != nil { + return fallback + } + return v +} + +func (s *Server) setCfg(key, value string) { + if _, err := s.db.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, value); err != nil { + s.log.Error("setCfg failed", "key", key, "error", err) + } +} + +const userCols = "name, ip, pub_key, vpn_key, psk_key, key_gen, vpn_key_prev" + +func scanUsers(rows *sql.Rows) ([]userRow, error) { + defer rows.Close() + out := []userRow{} + for rows.Next() { + var u userRow + if err := rows.Scan(&u.Name, &u.IP, &u.PubKey, &u.VpnKey, &u.PskKey, &u.KeyGen, &u.VpnKeyPrev); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (s *Server) usersAll() ([]userRow, error) { + rows, err := s.db.Query("SELECT " + userCols + " FROM users") + if err != nil { + return nil, err + } + return scanUsers(rows) +} + +func (s *Server) userGet(name string) (*userRow, error) { + rows, err := s.db.Query("SELECT "+userCols+" FROM users WHERE name = ?", name) + if err != nil { + return nil, err + } + us, err := scanUsers(rows) + if err != nil || len(us) == 0 { + return nil, err + } + return &us[0], nil +} + +func scanPeers(rows *sql.Rows) ([]peerRow, error) { + defer rows.Close() + out := []peerRow{} + for rows.Next() { + var p peerRow + if err := rows.Scan(&p.PubKey, &p.IP, &p.PskKey, &p.Owner, &p.CreatedAt); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Server) peersAll() ([]peerRow, error) { + rows, err := s.db.Query("SELECT pub_key, ip, psk_key, owner, created_at FROM peers ORDER BY created_at, rowid") + if err != nil { + return nil, err + } + return scanPeers(rows) +} + +func (s *Server) peerGet(pub string) (*peerRow, error) { + rows, err := s.db.Query("SELECT pub_key, ip, psk_key, owner, created_at FROM peers WHERE pub_key = ?", pub) + if err != nil { + return nil, err + } + ps, err := scanPeers(rows) + if err != nil || len(ps) == 0 { + return nil, err + } + return &ps[0], nil +} + +func (s *Server) userPubTaken(q queryer, pub string) (bool, error) { + var n int + if err := q.QueryRow("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?", pub).Scan(&n); err != nil { + return false, err + } + return n > 0, nil +} + +// nextIp: первый свободный 10.9.c.d (c = 0..255, d = 2..254) среди users и peers. +func (s *Server) nextIp(q queryer, subnet string) (string, error) { + rows, err := q.Query("SELECT ip FROM users UNION SELECT ip FROM peers") + if err != nil { + return "", err + } + used := map[string]bool{} + for rows.Next() { + var ip string + if err := rows.Scan(&ip); err != nil { + rows.Close() + return "", err + } + used[ip] = true + } + rows.Close() + for c := 0; c <= 255; c++ { + for d := 2; d <= 254; d++ { + ip := fmt.Sprintf("%s.%d.%d", subnet, c, d) + if !used[ip] { + return ip, nil + } + } + } + return "", fmt.Errorf("Подсеть заполнена") +} diff --git a/internal/ctrl/helpers.go b/internal/ctrl/helpers.go new file mode 100644 index 0000000..4e45229 --- /dev/null +++ b/internal/ctrl/helpers.go @@ -0,0 +1,118 @@ +package ctrl + +import ( + "math" + "regexp" + "strconv" + "strings" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +var reOwner = regexp.MustCompile(`^[\w/.-]{0,64}$`) + +// checkPubKeyAny — checkPubKey для значения из JSON (не строка → тот же 400). +func checkPubKeyAny(v any) (string, error) { + k, ok := v.(string) + if !ok { + return "", httpErr(400, "Неверный публичный ключ") + } + return checkPubKey(k) +} + +func checkPubKeys(arr []any) ([]string, error) { + out := make([]string, 0, len(arr)) + for _, v := range arr { + k, err := checkPubKeyAny(v) + if err != nil { + return nil, err + } + out = append(out, k) + } + return out, nil +} + +// jsToNumber — Number(v) для значений из JSON. +func jsToNumber(v any) float64 { + switch x := v.(type) { + case nil: + return 0 + case float64: + return x + case bool: + if x { + return 1 + } + return 0 + case string: + return jsNumber(x) + } + return math.NaN() +} + +func (s *Server) knownPubKeys() (map[string]bool, error) { + known := map[string]bool{} + rows, err := s.db.Query("SELECT pub_key FROM users") + if err != nil { + return nil, err + } + for rows.Next() { + var k string + rows.Scan(&k) + known[k] = true + } + rows.Close() + peers, err := s.peersAll() + if err != nil { + return nil, err + } + for _, p := range peers { + known[p.PubKey] = true + } + return known, nil +} + +func (s *Server) isKnownPub(k string) (bool, error) { + if p, err := s.peerGet(k); err != nil || p != nil { + return p != nil, err + } + var n int + if err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE pub_key = ?", k).Scan(&n); err != nil { + return false, err + } + return n > 0, nil +} + +// profileAwg — параметры для /api/profile в порядке CONFIG.awgParams (порядок объявления в +// DEFAULT_AWG_PARAMS), без пустых значений; числа — строками. +func profileAwg(p Params) jsjson.Object { + o := jsjson.Object{} + add := func(k, v string) { + if v != "" { + o = append(o, jsjson.KV{Key: k, Val: v}) + } + } + itoa := strconv.Itoa + add("Jc", itoa(p.Jc)) + add("Jmin", itoa(p.Jmin)) + add("Jmax", itoa(p.Jmax)) + add("S1", itoa(p.S1)) + add("S2", itoa(p.S2)) + add("S3", itoa(p.S3)) + add("S4", itoa(p.S4)) + add("H1", p.H1) + add("H2", p.H2) + add("H3", p.H3) + add("H4", p.H4) + add("I1", p.I1) + add("I2", p.I2) + add("I3", p.I3) + add("I4", p.I4) + add("I5", p.I5) + for _, k := range AWG3Keys { + add(k, p.A3[k]) + } + return o +} + +var _ = strings.TrimSpace diff --git a/internal/ctrl/http.go b/internal/ctrl/http.go new file mode 100644 index 0000000..ce5ba1c --- /dev/null +++ b/internal/ctrl/http.go @@ -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, "\n\n\n\nError\n\n\n
%s
\n\n\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 +} diff --git a/internal/ctrl/metrics.go b/internal/ctrl/metrics.go new file mode 100644 index 0000000..b9ac5f5 --- /dev/null +++ b/internal/ctrl/metrics.go @@ -0,0 +1,183 @@ +package ctrl + +import ( + "os" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде +// агент пропускает только /api/*, и один формат на ядре и нодах проще сверять. Кэш на 5 с — +// несколько скрейперов не должны гонять `awg show` чаще. +const metricsTTL = 5 * time.Second + +type metricsCache struct { + mu sync.Mutex + at time.Time + data any +} + +type metricsPeer struct { + Kind string `json:"kind"` + Name string `json:"name"` + IP string `json:"ip"` + Online bool `json:"online"` + Rx float64 `json:"rx"` + Tx float64 `json:"tx"` + Priority string `json:"priority"` +} + +type metricsHost struct { + CPU map[string]float64 `json:"cpu"` + Cores int `json:"cores"` + Load []float64 `json:"load"` + Mem struct { + Total float64 `json:"total"` + Available float64 `json:"available"` + } `json:"mem"` +} + +type linkStats struct { + RxBytes float64 `json:"rx_bytes"` + TxBytes float64 `json:"tx_bytes"` + RxPackets float64 `json:"rx_packets"` + TxPackets float64 `json:"tx_packets"` + RxDropped float64 `json:"rx_dropped"` + TxDropped float64 `json:"tx_dropped"` + RxErrors float64 `json:"rx_errors"` + TxErrors float64 `json:"tx_errors"` +} + +type metricsBody struct { + Up bool `json:"up"` + Gen string `json:"gen"` + Peers struct { + Total int `json:"total"` + Online int `json:"online"` + } `json:"peers"` + RxBytes float64 `json:"rx_bytes"` + TxBytes float64 `json:"tx_bytes"` + Link linkStats `json:"link"` + Shaping ShapingStatus `json:"shaping"` + Host *metricsHost `json:"host"` + List []metricsPeer `json:"list"` +} + +func (s *Server) linkCounter(name string) float64 { + b, err := os.ReadFile("/sys/class/net/" + s.iface + "/statistics/" + name) + if err != nil { + return 0 + } + return orZero(jsNumber(string(b))) +} + +var cpuModes = []string{"user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal"} + +// readHost: нагрузка хоста из /proc. Счётчики CPU кумулятивные (секунды по режимам, как у +// node_exporter): долю считает Prometheus через rate(). Память — MemAvailable, а не MemFree: +// кэш ядра свободен. +func readHost() *metricsHost { + stat, err := os.ReadFile("/proc/stat") + if err != nil { + return nil + } + first := strings.Fields(strings.SplitN(string(stat), "\n", 2)[0]) + if len(first) < 2 { + return nil + } + h := &metricsHost{CPU: map[string]float64{}, Cores: runtime.NumCPU()} + for i, m := range cpuModes { + v := 0.0 + if i+1 < len(first) { + v = orZero(jsNumber(first[i+1])) + } + h.CPU[m] = v / 100 // USER_HZ = 100 на всех обычных ядрах + } + mem, err := os.ReadFile("/proc/meminfo") + if err != nil { + return nil + } + kb := func(k string) float64 { + m := regexp.MustCompile(`(?m)^` + k + `:\s+(\d+) kB`).FindStringSubmatch(string(mem)) + if m == nil { + return 0 + } + f, _ := strconv.ParseFloat(m[1], 64) + return f * 1024 + } + h.Mem.Total, h.Mem.Available = kb("MemTotal"), kb("MemAvailable") + if la, err := os.ReadFile("/proc/loadavg"); err == nil { + f := strings.Fields(string(la)) + for i := 0; i < 3 && i < len(f); i++ { + h.Load = append(h.Load, orZero(jsNumber(f[i]))) + } + } + return h +} + +func (s *Server) collectMetrics() (*metricsBody, error) { + st := s.interfaceStatus() + stats := map[string]peerStat{} + if st.Up { + stats = s.getPeersData() + } + rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''") + if err != nil { + return nil, err + } + b := &metricsBody{Up: st.Up, Gen: s.cfg().Gen, List: []metricsPeer{}} + for rows.Next() { + var n, ip, pub string + if err := rows.Scan(&n, &ip, &pub); err != nil { + rows.Close() + return nil, err + } + ps := stats[pub] + b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip)}) + } + rows.Close() + devs, err := s.peersAll() + if err != nil { + return nil, err + } + for _, p := range devs { + ps := stats[p.PubKey] + b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP)}) + } + b.Peers.Total = st.Peers + for _, ps := range stats { + if ps.Online { + b.Peers.Online++ + } + b.RxBytes += orZero(ps.Rx) + b.TxBytes += orZero(ps.Tx) + } + b.Link = linkStats{ + s.linkCounter("rx_bytes"), s.linkCounter("tx_bytes"), s.linkCounter("rx_packets"), s.linkCounter("tx_packets"), + s.linkCounter("rx_dropped"), s.linkCounter("tx_dropped"), s.linkCounter("rx_errors"), s.linkCounter("tx_errors"), + } + b.Shaping = s.shaping.Status() + b.Host = readHost() + return b, nil +} + +// metricsCached отдаёт кэш на metricsTTL; ошибку не кэшируем. +func (s *Server) metricsCached() (any, error) { + m := &s.metrics + m.mu.Lock() + defer m.mu.Unlock() + if m.data != nil && time.Since(m.at) <= metricsTTL { + return m.data, nil + } + d, err := s.collectMetrics() + if err != nil { + m.data = nil + return nil, err + } + m.at, m.data = time.Now(), d + return d, nil +} diff --git a/internal/ctrl/monitorjob.go b/internal/ctrl/monitorjob.go new file mode 100644 index 0000000..c72ca48 --- /dev/null +++ b/internal/ctrl/monitorjob.go @@ -0,0 +1,104 @@ +package ctrl + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" +) + +// Установка и удаление встроенного мониторинга (Prometheus) по кнопке в панели. Действие +// одно из двух и без параметров: awg-ctrl запускает ту же команду, что оператор набрал бы +// руками (`awg-ctrl monitor install|remove`, root), так что код один. Панель сама пакетов +// не ставит и не удаляет — она только просит об этом здесь. + +type monitorJob struct { + mu sync.Mutex + active bool + action string + exit *int +} + +var reANSI = regexp.MustCompile("\x1b\\[[0-9;]*[A-Za-z]") + +func (s *Server) logTail() string { + b, err := os.ReadFile(s.cliLog) + if err != nil { + return "" + } + t := string(b) + if len(t) > 4000 { // последние 4000 символов (в JS — UTF-16 единиц; для лога разницы нет) + t = t[len(t)-4000:] + } + return strings.ReplaceAll(reANSI.ReplaceAllString(t, ""), "\r", "\n") +} + +// startMonitorJob запускает CLI в фоне; возвращает 409, если уже идёт. +func (s *Server) startMonitorJob(action string) error { + j := &s.monitor + j.mu.Lock() + if j.active { + j.mu.Unlock() + return httpErr(409, "Операция уже идёт") + } + if _, err := os.Stat(s.cliPath); err != nil { + j.mu.Unlock() + return httpErr(501, "CLI не найден ("+s.cliPath+"): выполните на сервере awg-ctrl monitor "+action) + } + os.MkdirAll(filepath.Dir(s.cliLog), 0o755) + f, err := os.OpenFile(s.cliLog, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + j.mu.Unlock() + return err + } + cmd := exec.Command(s.cliPath, "monitor", action) + cmd.Stdout, cmd.Stderr = f, f + j.active, j.action, j.exit = true, action, nil + if err := cmd.Start(); err != nil { + s.log.Error("monitor: не запустился", "action", action, "error", err.Error()) + code := 127 + j.active, j.exit = false, &code + f.Close() + j.mu.Unlock() + return nil + } + j.mu.Unlock() + go func() { + err := cmd.Wait() + code := 0 + if err != nil { + code = 1 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } + } + f.Close() + s.log.Info("monitor: завершён", "action", action, "code", code) + j.mu.Lock() + j.active, j.exit = false, &code + j.mu.Unlock() + }() + return nil +} + +type jobView struct { + Action *string `json:"action"` + Running bool `json:"running"` + Exit *int `json:"exit"` + Log string `json:"log"` +} + +func (s *Server) monitorView() jobView { + j := &s.monitor + j.mu.Lock() + defer j.mu.Unlock() + v := jobView{Running: j.active, Exit: j.exit} + if j.action != "" { + a := j.action + v.Action = &a + v.Log = s.logTail() + } + return v +} diff --git a/internal/ctrl/ops.go b/internal/ctrl/ops.go new file mode 100644 index 0000000..72b55f0 --- /dev/null +++ b/internal/ctrl/ops.go @@ -0,0 +1,605 @@ +package ctrl + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +// HTTPError — ошибка с кодом ответа; любая другая ошибка превращается в 500. +type HTTPError struct { + Status int + Msg string +} + +func (e *HTTPError) Error() string { return e.Msg } + +func httpErr(status int, msg string) error { return &HTTPError{status, msg} } + +// ───────── пользователи (vpn://) ───────── + +func (s *Server) addUser(name string) (*userRow, error) { + keys, err := s.generateKeys() + if err != nil { + return nil, err + } + serverPub, err := s.getServerPublicKey() + if err != nil { + return nil, err + } + cfg := s.cfg() + + var ip string + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + ip, err = s.nextIp(tx, cfg.Subnet) + if err == nil { + _, err = tx.Exec("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)", + name, ip, keys.Public, "", keys.PSK, cfg.Gen) + } + if err != nil { + tx.Rollback() + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + + vpnKey, err := EncodeVpnKey(s.keyEnv(), keys, ip, serverPub) + if err != nil { + return nil, err + } + if _, err := s.db.Exec("UPDATE users SET vpn_key = ? WHERE name = ?", vpnKey, name); err != nil { + return nil, err + } + + if r := s.setPeer(keys.Public, keys.PSK, ip); !r.ok() { + s.db.Exec("DELETE FROM users WHERE name = ?", name) + return nil, fmt.Errorf("awg set failed: %s", r.Stderr) + } + if err := s.rebuildConf(); err != nil { + return nil, err + } + s.log.Info("user created", "name", name, "ip", ip, "gen", cfg.Gen) + return &userRow{Name: name, IP: ip, PubKey: keys.Public, VpnKey: vpnKey, PskKey: keys.PSK, KeyGen: cfg.Gen}, nil +} + +func (s *Server) removeUser(name string) error { + u, err := s.userGet(name) + if err != nil { + return err + } + if u == nil { + return errors.New("Пользователь не найден") // в Node — обычный Error → 500 + } + if !s.dropFromKernel(u.PubKey) { + return errors.New("Не удалось снять пира с интерфейса") + } + if _, err := s.db.Exec("DELETE FROM users WHERE name = ?", name); err != nil { + return err + } + if err := s.rebuildConf(); err != nil { + return err + } + for _, ext := range []string{".conf", ".key"} { + f := filepath.Join(s.confDir, "clients", name+ext) + if _, err := os.Stat(f); err == nil { + os.Remove(f) + } + } + s.log.Info("user removed", "name", name) + return nil +} + +type reissueResult struct { + Total int `json:"total"` + Reissued int `json:"reissued"` + Regenerated []string `json:"regenerated"` + Backup string `json:"backup"` +} + +// reissueAll перевыпускает все vpn:// ключи на текущих параметрах интерфейса — после смены +// поколения старые перестают работать. IP, pub_key и psk_key сохраняются: на проводе ничего +// не меняется, клиенту достаточно заново импортировать ключ. +func (s *Server) reissueAll() (*reissueResult, error) { + users, err := s.usersAll() + if err != nil { + return nil, err + } + serverPub, err := s.getServerPublicKey() + if err != nil { + return nil, err + } + backup := s.dbPath + ".bak-" + jsonStamp(time.Now()) + if _, err := s.db.Exec("VACUUM INTO ?", backup); err != nil { + return nil, err + } + s.log.Info("reissue: db backed up", "backup", backup, "users", len(users)) + + res := &reissueResult{Total: len(users), Regenerated: []string{}, Backup: backup} + cfg := s.cfg() + for _, u := range users { + var keys Keys + if priv := clientPrivKeyFrom(u.VpnKey); priv != "" { + keys = Keys{Private: priv, Public: u.PubKey, PSK: u.PskKey} + } else { + // Блоб не разобрался — личность пира восстановить неоткуда, выдаём новую. + s.log.Warn("reissue: vpn_key не декодируется, генерируем новую пару", "name", u.Name) + keys, err = s.generateKeys() + if err != nil { + return nil, err + } + runCmd("awg", "set", s.iface, "peer", u.PubKey, "remove") + if r := s.setPeer(keys.Public, keys.PSK, u.IP); !r.ok() { + s.log.Error("reissue: awg set failed", "name", u.Name, "stderr", r.Stderr) + continue + } + res.Regenerated = append(res.Regenerated, u.Name) + } + vk, err := EncodeVpnKey(s.keyEnv(), keys, u.IP, serverPub) + if err != nil { + return nil, err + } + if _, err := s.db.Exec("UPDATE users SET vpn_key_prev = vpn_key, vpn_key = ?, pub_key = ?, psk_key = ?, key_gen = ? WHERE name = ?", + vk, keys.Public, keys.PSK, cfg.Gen, u.Name); err != nil { + return nil, err + } + res.Reissued++ + } + if err := s.syncPeers(); err != nil { + return nil, err + } + s.log.Info("reissue: done", "total", len(users), "reissued", res.Reissued, "regenerated", len(res.Regenerated)) + return res, nil +} + +// ───────── пиры устройств (sen://) ───────── +// awg-ctrl про мастер-ключи ничего не знает: ему приходит «добавь пира с этим публичным +// ключом». Приватного ключа устройства здесь нет и быть не может. + +var rePub = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`) + +// checkPubKey: base64 от 32 байт (44 символа, последний «=»). +func checkPubKey(k string) (string, error) { + if !rePub.MatchString(k) { + return "", httpErr(400, "Неверный публичный ключ") + } + b, err := b64Std(k) + if err != nil || len(b) != 32 { + return "", httpErr(400, "Неверный публичный ключ") + } + return k, nil +} + +// pubKeyFromParam: в пути base64url — «/» и «+» обычного base64 ломают маршрут. +func pubKeyFromParam(p string) (string, error) { + b, err := lenientB64URL(p) + if err != nil { + return "", httpErr(400, "Неверный публичный ключ") + } + return checkPubKey(stdB64(b)) +} + +type ipPsk struct { + IP string `json:"ip"` + PSK string `json:"psk_key"` +} + +func (s *Server) assertPubKeyFree(q queryer, pub string) error { + var n int + if err := q.QueryRow("SELECT COUNT(*) FROM peers WHERE pub_key = ?", pub).Scan(&n); err != nil { + return err + } + taken, err := s.userPubTaken(q, pub) + if err != nil { + return err + } + if n > 0 || taken { + return httpErr(409, "Публичный ключ уже используется") + } + if sp, err := s.getServerPublicKey(); err != nil { + return err + } else if pub == sp { + return httpErr(409, "Публичный ключ уже используется") + } + return nil +} + +func (s *Server) addPeer(pub, owner string) (*ipPsk, error) { + if _, err := checkPubKey(pub); err != nil { + return nil, err + } + psk, err := s.genPSK() + if err != nil { + return nil, err + } + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + var ip string + err = s.assertPubKeyFree(tx, pub) + if err == nil { + ip, err = s.nextIp(tx, s.cfg().Subnet) + } + if err == nil { + _, err = tx.Exec("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES (?, ?, ?, ?, ?)", + pub, ip, psk, owner, time.Now().Unix()) + } + if err != nil { + tx.Rollback() + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + if r := s.setPeer(pub, psk, ip); !r.ok() { + s.db.Exec("DELETE FROM peers WHERE pub_key = ?", pub) + return nil, fmt.Errorf("awg set failed: %s", r.Stderr) + } + if err := s.rebuildConf(); err != nil { + return nil, err + } + s.log.Info("peer added", "owner", owner, "ip", ip) + return &ipPsk{ip, psk}, nil +} + +// replacePeer — смена ключа устройства: IP и PSK остаются, меняется только pub_key. +func (s *Server) replacePeer(oldPub, newPub string) (*ipPsk, error) { + if _, err := checkPubKey(newPub); err != nil { + return nil, err + } + old, err := s.peerGet(oldPub) + if err != nil { + return nil, err + } + if old == nil { + return nil, httpErr(404, "Пир не найден") + } + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + if err = s.assertPubKeyFree(tx, newPub); err == nil { + _, err = tx.Exec("UPDATE peers SET pub_key = ? WHERE pub_key = ?", newPub, oldPub) + } + if err != nil { + tx.Rollback() + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + if r := s.setPeer(newPub, old.PskKey, old.IP); !r.ok() { + s.db.Exec("UPDATE peers SET pub_key = ? WHERE pub_key = ?", oldPub, newPub) + return nil, fmt.Errorf("awg set failed: %s", r.Stderr) + } + // Старый ключ не снялся — пир остаётся «призраком» в ядре до аудита панели; смену ключа + // не откатываем: новый уже стоит, а клиент к этому моменту получил бы ошибку. + if !s.dropFromKernel(oldPub) { + s.log.Error("peer rekey: старый ключ остался в ядре", "owner", old.Owner) + } + if err := s.rebuildConf(); err != nil { + return nil, err + } + s.log.Info("peer rekeyed", "owner", old.Owner, "ip", old.IP) + return &ipPsk{old.IP, old.PskKey}, nil +} + +func (s *Server) rotatePeerPSK(pub string) (*ipPsk, error) { + p, err := s.peerGet(pub) + if err != nil { + return nil, err + } + if p == nil { + return nil, httpErr(404, "Пир не найден") + } + psk, err := s.genPSK() + if err != nil { + return nil, err + } + if r := s.setPeer(pub, psk, p.IP); !r.ok() { + return nil, fmt.Errorf("awg set failed: %s", r.Stderr) + } + if _, err := s.db.Exec("UPDATE peers SET psk_key = ? WHERE pub_key = ?", psk, pub); err != nil { + return nil, err + } + if err := s.rebuildConf(); err != nil { + return nil, err + } + s.log.Info("peer psk rotated", "owner", p.Owner) + return &ipPsk{p.IP, psk}, nil +} + +func (s *Server) removePeer(pub string) error { + p, err := s.peerGet(pub) + if err != nil { + return err + } + if p == nil { + return httpErr(404, "Пир не найден") + } + if !s.dropFromKernel(pub) { + return errors.New("Не удалось снять пира с интерфейса") + } + if _, err := s.db.Exec("DELETE FROM peers WHERE pub_key = ?", pub); err != nil { + return err + } + if err := s.rebuildConf(); err != nil { + return err + } + s.log.Info("peer removed", "owner", p.Owner) + return nil +} + +var ( + rePanelID = regexp.MustCompile(`^[a-z0-9]{8,16}$`) + reLegacyOwner = regexp.MustCompile(`^m\d+/d\d+$`) +) + +const syncMax = 10_000 + +type legacyPeer struct { + PubKey string `json:"pub_key"` + Owner string `json:"owner"` + IP string `json:"ip"` +} +type syncResult struct { + Present []string `json:"present"` + Removed int `json:"removed"` + Legacy []legacyPeer `json:"legacy"` +} + +// syncOwnedPeers: панель присылает полный список ключей, которые ей нужны на этом сервере, +// — всё остальное из её области («/…») снимается. Удаляем только по такому явному +// списку, никогда по времени или неактивности: панель не на связи — ничего не происходит. +// Пиры без области (owner «m1/d2», выданы до областей) панель забирает себе, только если их +// ключ есть в её списке; остальные не трогаем — чьи они, уже не разобрать. +func (s *Server) syncOwnedPeers(panel string, keep []string) (*syncResult, error) { + want := map[string]bool{} + for _, k := range keep { + want[k] = true + } + prefix := panel + "/" + peers, err := s.peersAll() + if err != nil { + return nil, err + } + var removed []peerRow + claimed := 0 + for _, p := range peers { + switch { + case strings.HasPrefix(p.Owner, prefix): + // Ядро первым: не снялся — строка остаётся, следующий sync попробует снова. + if !want[p.PubKey] && s.dropFromKernel(p.PubKey) { + if _, err := s.db.Exec("DELETE FROM peers WHERE pub_key = ?", p.PubKey); err != nil { + return nil, err + } + removed = append(removed, p) + } + case reLegacyOwner.MatchString(p.Owner) && want[p.PubKey]: + if _, err := s.db.Exec("UPDATE peers SET owner = ? WHERE pub_key = ?", prefix+p.Owner, p.PubKey); err != nil { + return nil, err + } + claimed++ + } + } + if len(removed) > 0 || claimed > 0 { + if err := s.rebuildConf(); err != nil { + return nil, err + } + } + if len(removed) > 0 { + owners := make([]string, len(removed)) + for i, p := range removed { + owners[i] = p.Owner + } + s.log.Info("peers synced out", "panel", panel, "removed", owners) + } + all, err := s.peersAll() + if err != nil { + return nil, err + } + res := &syncResult{Present: []string{}, Removed: len(removed), Legacy: []legacyPeer{}} + for _, p := range all { + if strings.HasPrefix(p.Owner, prefix) { + res.Present = append(res.Present, p.PubKey) + } + if reLegacyOwner.MatchString(p.Owner) { + res.Legacy = append(res.Legacy, legacyPeer{p.PubKey, p.Owner, p.IP}) + } + } + return res, nil +} + +// ───────── переход 2.0 → 3.1 ───────── + +// Header protection использует библиотечный chacha-API, которого нет до ядра 5.5 — там +// модуль 3.x просто не соберётся (upstream issue #210). +func kernelRelease() string { + r := runCmd("uname", "-r") + return strings.TrimSpace(r.Stdout) +} + +func kernelSupportsHeaderProtection(release string) bool { + parts := strings.Split(release, ".") + if len(parts) < 2 { + return false + } + maj, e1 := strconv.Atoi(leadingDigits(parts[0])) + min, e2 := strconv.Atoi(leadingDigits(parts[1])) + if e1 != nil || e2 != nil { + return false + } + return maj > 5 || (maj == 5 && min >= 5) +} + +func leadingDigits(s string) string { + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + return s[:i] +} + +// checkGen31Support решает ЗАГРУЖЕННЫЙ модуль, а не тот, что лежит на диске после apt. +func (s *Server) checkGen31Support() string { + module, _ := readVersions() + if !strings.HasPrefix(module, "3.") { + m := module + if m == "" { + m = "неизвестной версии" + } + return fmt.Sprintf("В ядре загружен модуль amneziawg %s — для 3.1 нужна 3.x. ", m) + + "Обнови пакет (apt) и перезагрузи сервер, затем повтори переход." + } + if rel := kernelRelease(); !kernelSupportsHeaderProtection(rel) { + return fmt.Sprintf("Ядро %s старше 5.5 — header protection в нём не работает (upstream issue #210).", rel) + } + return "" +} + +var reAwg3KeyLine = regexp.MustCompile(`^\s*(` + strings.Join(AWG3Keys, "|") + `)\s*=`) + +// confWithGen31 готовит текст awg1.conf для 3.1: выкидывает старые 3.x-строки (конфиг могли +// править руками), поднимает S1–S4 до допустимых при header protection и дописывает 3.x-блок +// в конец [Interface]. [Peer]-блоки не трогаются — личности пиров переход не меняет. +func confWithGen31(conf, headerKey string) (string, []string) { + var head, rest string + if loc := pPeerSplit.FindStringIndex(conf); loc != nil { + head, rest = conf[:loc[0]], conf[loc[0]:] + } else { + head = conf + } + var lines []string + for _, l := range strings.Split(head, "\n") { + if !reAwg3KeyLine.MatchString(l) { + lines = append(lines, l) + } + } + // Пустые строки и «# имя» в хвосте [Interface] относятся уже к первому [Peer] (их пишет + // rebuildConf) — отделяем, чтобы 3.x-строки встали внутрь [Interface]. + var tail []string + for len(lines) > 0 { + t := strings.TrimSpace(lines[len(lines)-1]) + if t != "" && !strings.HasPrefix(t, "#") { + break + } + tail = append([]string{lines[len(lines)-1]}, tail...) + lines = lines[:len(lines)-1] + } + if tt := strings.TrimSpace(strings.Join(tail, "\n")); tt != "" { + rest = tt + "\n" + rest + } + iface := strings.Join(lines, "\n") + + // S ниже 12 (или вовсе отсутствующий) ядро с header protection не примет. Правка безопасна + // ровно потому, что следом идёт перевыпуск ключей: обе стороны получают новые значения + // одновременно. (Регулярка с \s*, как в Node-версии: захватывает и переводы строк.) + def := DefaultParams() + defaults := map[string]int{"S1": def.S1, "S2": def.S2, "S3": def.S3, "S4": def.S4} + bumped := []string{} + for _, k := range []string{"S1", "S2", "S3", "S4"} { + re := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*(\d+)\s*$`) + m := re.FindStringSubmatch(iface) + cur := -1 + if m != nil { + cur, _ = strconv.Atoi(m[1]) + } + if m != nil && cur >= minSForHeaderProtection { + continue + } + val := defaults[k] + if minSForHeaderProtection > val { + val = minSForHeaderProtection + } + if m != nil { + // как iface.replace(re, …) без флага g: заменяется только первое совпадение + loc := re.FindStringIndex(iface) + iface = iface[:loc[0]] + fmt.Sprintf("%s = %d", k, val) + iface[loc[1]:] + } else { + iface = trimEndJS(iface) + fmt.Sprintf("\n%s = %d", k, val) + } + was := "нет" + if m != nil { + was = strconv.Itoa(cur) + } + bumped = append(bumped, fmt.Sprintf("%s: %s → %d", k, was, val)) + } + + a3 := []string{"HeaderProtectionKey = " + headerKey} + for _, kv := range AWG31Defaults { + a3 = append(a3, kv[0]+" = "+kv[1]) + } + out := trimEndJS(iface) + "\n" + strings.Join(a3, "\n") + "\n" + if rest != "" { + out += "\n" + rest + } + return out, bumped +} + +type upgradeResult struct { + Gen string `json:"gen"` + Backup string `json:"backup"` + BumpedS []string `json:"bumpedS"` + Reissue *reissueResult `json:"reissue"` +} + +// upgradeToGen31: единственная настоящая проверка, что ядро приняло параметры, — подъём +// интерфейса, поэтому шаги такие: бэкап конфига → запись 3.1 → restartAwg() → перевыпуск +// ключей. Если ядро отказало, возвращаем прежний конфиг и поднимаем интерфейс обратно: +// операция либо применяется целиком, либо не оставляет следов. +func (s *Server) upgradeToGen31() (*upgradeResult, error) { + before, err := os.ReadFile(s.confPath()) + if err != nil { + return nil, err + } + backup := s.confPath() + ".bak-" + jsonStamp(time.Now()) + if err := os.WriteFile(backup, before, 0o600); err != nil { + return nil, err + } + hk, err := s.genPSK() + if err != nil { + return nil, err + } + conf, bumped := confWithGen31(string(before), hk) + if err := os.WriteFile(s.confPath(), []byte(conf), 0o600); err != nil { + return nil, err + } + s.log.Info("upgrade: conf rewritten for 3.1", "backup", backup, "bumpedS", bumped) + + rerr := s.restartAwg() + if rerr == nil && s.cfg().Gen != "3.1" { + rerr = errors.New("интерфейс поднялся, но конфиг всё ещё 2.0") + } + if rerr != nil { + s.log.Error("upgrade: kernel rejected 3.1, rolling back", "error", rerr) + os.WriteFile(s.confPath(), before, 0o600) + if e2 := s.restartAwg(); e2 != nil { + s.log.Error("upgrade: rollback restart failed", "error", e2) + } + return nil, fmt.Errorf("Ядро не приняло конфиг 3.1 — вернули прежний (копия: %s). "+ + "Причина видна в dmesg после `echo \"module amneziawg +p\" > /sys/kernel/debug/dynamic_debug/control`.", backup) + } + + // Ключи, выданные на 2.0-параметрах, после смены поколения не подключатся, поэтому + // перевыпускаем сразу — сервер не должен оставаться в состоянии «3.1 поднят, у всех + // нерабочие ключи». Личности пиров сохраняются. + re, err := s.reissueAll() + if err != nil { + return nil, err + } + s.log.Info("upgrade: done", "gen", s.cfg().Gen, "reissued", re.Reissued, "bumpedS", bumped) + return &upgradeResult{Gen: s.cfg().Gen, Backup: backup, BumpedS: bumped, Reissue: re}, nil +} + +var _ = jsjson.Undefined diff --git a/internal/ctrl/params.go b/internal/ctrl/params.go new file mode 100644 index 0000000..cb9d967 --- /dev/null +++ b/internal/ctrl/params.go @@ -0,0 +1,152 @@ +package ctrl + +import ( + "os" + "regexp" + "strconv" + "strings" +) + +// AWG3Keys — ключи AmneziaWG 3.0/3.1. Порядок — порядок строк в клиентском .conf +// (client/server_scripts/awg/template.conf в amnezia-client). Пустое значение = +// ключ не задан: он не попадает ни в conf, ни в vpn://, и 2.0 остаётся побайтно прежним. +var AWG3Keys = []string{ + "HeaderProtectionKey", + "ContentPaddingAddition", + "RekeyAfterTime", + "RekeyTimeout", + "RejectAfterTime", + "KeepaliveTimeout", + "MaxHandshakeAttempts", + "RandomTrailers", + "DisableCookies", +} + +// Params — параметры обфускации из [Interface] awg1.conf. Числовые — числа (в +// vpn:// они уходят строками), остальные — строки. +type Params struct { + Jc, Jmin, Jmax int + S1, S2, S3, S4 int + H1, H2, H3, H4 string + I1, I2, I3, I4, I5 string + A3 map[string]string // AWG3Keys → значение ("" = не задано) +} + +// DefaultParams — запасные значения на случай, когда awg1.conf не найден. +func DefaultParams() Params { + return Params{ + Jc: 6, Jmin: 10, Jmax: 50, + S1: 90, S2: 45, S3: 37, S4: 14, + H1: "1224800044-2116730834", + H2: "2122053282-2133204808", + H3: "2133604274-2140756116", + H4: "2143656228-2147444225", + I1: "", + A3: map[string]string{}, + } +} + +func (p Params) a3(k string) string { return p.A3[k] } + +// Gen выводится из параметров, отдельного флага нет: conf — единственный источник +// правды. Header protection — то, что ломает совместимость с 2.0. +func (p Params) Gen() string { + if p.a3("HeaderProtectionKey") != "" { + return "3.1" + } + return "2" +} + +var keepaliveByGen = map[string]string{"2": "25", "3.1": "25-35"} + +// AWG31Defaults — значения 3.1-параметров для перехода 2.0 → 3.1 (как у install.sh), +// в порядке записи. HeaderProtectionKey генерируется на месте. +var AWG31Defaults = [][2]string{ + {"ContentPaddingAddition", "10-100"}, + {"RekeyAfterTime", "100-120"}, + {"RekeyTimeout", "3-7"}, + {"RejectAfterTime", "150-180"}, + {"KeepaliveTimeout", "5-15"}, + {"MaxHandshakeAttempts", "15-20"}, +} + +// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12. +const minSForHeaderProtection = 12 + +var ( + pPeerSplit = regexp.MustCompile(`(?m)^\[Peer\]`) + numKeys = []string{"Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"} + strKeys = append([]string{"H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"}, AWG3Keys...) +) + +// ReadParams читает параметры из [Interface] awg1.conf (всё до первого [Peer]). +// +// Повторяет регулярки Node-версии: `^\s*K\s*=\s*(\d+)` с флагом m. `\s*` в JS +// захватывает и перевод строки, так что строка «I2 = » без значения «съела» бы +// следующую — install.sh таких не пишет, но поведение сохранено намеренно. +func ReadParams(confFile string, warn func(string)) Params { + p := DefaultParams() + raw, err := os.ReadFile(confFile) + if err != nil { + if warn != nil { + warn("awg1.conf не найден — параметры обфускации по умолчанию") + } + return p + } + iface := pPeerSplit.Split(string(raw), 2)[0] + + for _, k := range numKeys { + m := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*(\d+)`).FindStringSubmatch(iface) + if m == nil { + continue + } + n, _ := strconv.Atoi(m[1]) + switch k { + case "Jc": + p.Jc = n + case "Jmin": + p.Jmin = n + case "Jmax": + p.Jmax = n + case "S1": + p.S1 = n + case "S2": + p.S2 = n + case "S3": + p.S3 = n + case "S4": + p.S4 = n + } + } + for _, k := range strKeys { + // В JS `.` не совпадает с \n, \r, U+2028, U+2029; в Go-режиме (?m) без (?s) — только с \n. + m := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*([^\n\r\x{2028}\x{2029}]*)$`).FindStringSubmatch(iface) + if m == nil { + continue + } + v := strings.TrimSpace(m[1]) + switch k { + case "H1": + p.H1 = v + case "H2": + p.H2 = v + case "H3": + p.H3 = v + case "H4": + p.H4 = v + case "I1": + p.I1 = v + case "I2": + p.I2 = v + case "I3": + p.I3 = v + case "I4": + p.I4 = v + case "I5": + p.I5 = v + default: + p.A3[k] = v + } + } + return p +} diff --git a/internal/ctrl/routes.go b/internal/ctrl/routes.go new file mode 100644 index 0000000..7eac6a4 --- /dev/null +++ b/internal/ctrl/routes.go @@ -0,0 +1,392 @@ +package ctrl + +import ( + "math" + "strconv" + "strings" + "time" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +func nowFn() time.Time { return time.Now() } + +type ob = jsjson.Object +type kv = jsjson.KV + +func ok(v any) (result, error) { return result{200, v}, nil } +func created(v any) (result, error) { return result{201, v}, nil } +func (s *Server) routes() []route { + a := []func(*rctx) error{s.auth} + an := []func(*rctx) error{s.auth, validateName} + R := func(method, path string, lock bool, mw []func(*rctx) error, h func(*rctx) (result, error)) route { + segs := strings.Split(strings.Trim(path, "/"), "/") + return route{method: method, segs: segs, mw: mw, lock: lock, h: h} + } + + return []route{ + R("GET", "/health", false, nil, func(c *rctx) (result, error) { + st := s.interfaceStatus() + cfg := s.cfg() + status, word := 200, "ok" + awgSt := "ok" + if !st.Up { + status, word, awgSt = 503, "degraded", "down" + } + return result{status, ob{{"status", word}, {"server", cfg.ServerName}, {"ip", cfg.ServerIP}, {"gen", cfg.Gen}, + {"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil + }), + + R("POST", "/api/users", true, an, func(c *rctx) (result, error) { + name, _ := c.obj().Get2("name").(string) + if u, err := s.userGet(name); err != nil { + return result{}, err + } else if u != nil { + return result{}, httpErr(409, "Пользователь уже существует") + } + u, err := s.addUser(name) + if err != nil { + return result{}, err + } + return created(u) + }), + + R("GET", "/api/users", true, a, func(c *rctx) (result, error) { + rows, err := s.db.Query("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''") + if err != nil { + return result{}, err + } + stats := s.getPeersData() + list := []any{} + for rows.Next() { + var n, ip, pub, vk, kg string + if err := rows.Scan(&n, &ip, &pub, &vk, &kg); err != nil { + rows.Close() + return result{}, err + } + ps := stats[pub] + list = append(list, ob{{"name", n}, {"ip", ip}, {"pub_key", pub}, {"vpn_key", vk}, {"key_gen", kg}, + {"online", ps.Online}, {"lastHandshake", ps.LastHandshake}}) + } + rows.Close() + return ok(ob{{"users", list}}) + }), + + R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) { + rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''") + if err != nil { + return result{}, err + } + stats := s.getPeersData() + list := []any{} + for rows.Next() { + var n, ip, pub, kg string + if err := rows.Scan(&n, &ip, &pub, &kg); err != nil { + rows.Close() + return result{}, err + } + ps := stats[pub] + list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online}, + {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)}}) + } + rows.Close() + return ok(ob{{"users", list}}) + }), + + // ⚠️ До «/api/users/:name», иначе тот примет «reissue» за имя пользователя. + R("POST", "/api/users/reissue", true, a, func(c *rctx) (result, error) { + r, err := s.reissueAll() + if err != nil { + return result{}, err + } + return ok(r) + }), + + R("POST", "/api/users/:name", true, an, func(c *rctx) (result, error) { + u, err := s.userGet(c.params["name"]) + if err != nil { + return result{}, err + } + if u == nil { + return result{}, httpErr(404, "Пользователь не найден") + } + return ok(u) + }), + + R("DELETE", "/api/users/:name", true, an, func(c *rctx) (result, error) { + if err := s.removeUser(c.params["name"]); err != nil { + return result{}, err + } + return ok(ob{{"success", true}, {"name", c.params["name"]}}) + }), + + R("POST", "/api/peers", true, a, func(c *rctx) (result, error) { + owner, isStr := c.obj().Get2("owner").(string) + if !isStr || !reOwner.MatchString(owner) { + return result{}, httpErr(400, "Неверный owner") + } + pub, err := checkPubKeyAny(c.obj().Get2("pub_key")) + if err != nil { + return result{}, err + } + r, err := s.addPeer(pub, owner) + if err != nil { + return result{}, err + } + return created(r) + }), + + R("GET", "/api/peers", true, a, func(c *rctx) (result, error) { + stats := s.getPeersData() + peers, err := s.peersAll() + if err != nil { + return result{}, err + } + list := []any{} + for _, p := range peers { + ps := stats[p.PubKey] + list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner}, + {"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, + {"priority", s.shaping.PriorityOf(p.IP)}}) + } + return ok(ob{{"peers", list}}) + }), + + // Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять + // их после сбоя `awg set … remove` некому. Только чтение — панель сама решает, что делать. + R("GET", "/api/audit", true, a, func(c *rctx) (result, error) { + r := runCmd("awg", "show", s.iface, "dump") + if !r.ok() { + return result{}, httpErr(503, "awg show не ответил") + } + known, err := s.knownPubKeys() + if err != nil { + return result{}, err + } + now := float64(time.Now().Unix()) + ghosts := []any{} + lines := strings.Split(r.Stdout, "\n") + for _, l := range lines[1:] { + f := strings.Split(l, "\t") + if f[0] == "" || known[f[0]] { + continue + } + num := func(i int) float64 { + if i < len(f) { + return orZero(jsNumber(f[i])) + } + return 0 + } + hs := num(4) + ghosts = append(ghosts, ob{{"pub_key", f[0]}, {"lastHandshake", hs}, {"online", hs > 0 && now-hs < 180}, {"rx", num(5)}, {"tx", num(6)}}) + } + return ok(ob{{"ghosts", ghosts}}) + }), + + // Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется: + // пир, который успел появиться в users/peers, не трогаем. Строк в базе у «призраков» + // нет — снимается только ядро. + R("POST", "/api/audit/clean", true, a, func(c *rctx) (result, error) { + arr, isArr := c.obj().Get2("pub_keys").([]any) + if !isArr || len(arr) > syncMax { + return result{}, httpErr(400, "Неверный список ключей") + } + keys, err := checkPubKeys(arr) + if err != nil { + return result{}, err + } + removed, skipped := []string{}, []string{} + for _, k := range keys { + known, err := s.isKnownPub(k) + if err != nil { + return result{}, err + } + skip := known + if !skip { + sp, err := s.getServerPublicKey() + if err != nil { + return result{}, err + } + kp := s.kernelPeers() + skip = k == sp || kp == nil || !kp[k] + } + if skip { + skipped = append(skipped, k) + } else if s.dropFromKernel(k) { + removed = append(removed, k) + } else { + skipped = append(skipped, k) + } + } + if len(removed) > 0 { + s.log.Warn("ghost peers removed from kernel", "count", len(removed), "removed", removed) + } + return ok(ob{{"removed", removed}, {"skipped", skipped}}) + }), + + // Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ. + R("PUT", "/api/peers/sync", true, a, func(c *rctx) (result, error) { + owner, isStr := c.obj().Get2("owner").(string) + if !isStr || !rePanelID.MatchString(owner) { + return result{}, httpErr(400, "Неверный owner") + } + arr, isArr := c.obj().Get2("keep").([]any) + if !isArr || len(arr) > syncMax { + return result{}, httpErr(400, "Неверный список ключей") + } + keep, err := checkPubKeys(arr) + if err != nil { + return result{}, err + } + r, err := s.syncOwnedPeers(owner, keep) + if err != nil { + return result{}, err + } + return ok(r) + }), + + R("PUT", "/api/peers/:pub", true, a, func(c *rctx) (result, error) { + oldPub, err := pubKeyFromParam(c.params["pub"]) + if err != nil { + return result{}, err + } + newPub, err := checkPubKeyAny(c.obj().Get2("pub_key")) + if err != nil { + return result{}, err + } + r, err := s.replacePeer(oldPub, newPub) + if err != nil { + return result{}, err + } + return ok(r) + }), + + R("POST", "/api/peers/:pub/psk", true, a, func(c *rctx) (result, error) { + pub, err := pubKeyFromParam(c.params["pub"]) + if err != nil { + return result{}, err + } + r, err := s.rotatePeerPSK(pub) + if err != nil { + return result{}, err + } + return ok(r) + }), + + R("DELETE", "/api/peers/:pub", true, a, func(c *rctx) (result, error) { + pub, err := pubKeyFromParam(c.params["pub"]) + if err != nil { + return result{}, err + } + if err := s.removePeer(pub); err != nil { + return result{}, err + } + return ok(ob{{"success", true}}) + }), + + // Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf; + // пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf. + R("GET", "/api/profile", true, a, func(c *rctx) (result, error) { + sp, err := s.getServerPublicKey() + if err != nil { + return result{}, err + } + cfg := s.cfg() + return ok(ob{{"name", cfg.ServerName}, {"endpoint", cfg.ServerIP + ":" + strconv.Itoa(cfg.ServerPort)}, + {"server_pub", sp}, {"gen", cfg.Gen}, {"dns", []string{cfg.DNS1, cfg.DNS2}}, + {"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}}) + }), + + R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) { + if err := s.restartAwg(); err != nil { + return result{}, err + } + return ok(ob{{"success", true}}) + }), + + // Перевод сервера с 2.0 на 3.1 — включая перевыпуск всех vpn:// ключей. Ошибку отдаём + // текстом: панель показывает её оператору, а причины отказа (старый модуль, старое ядро, + // отказ ядра на подъёме) требуют разных действий. + R("POST", "/awg/upgrade", true, a, func(c *rctx) (result, error) { + if s.cfg().Gen == "3.1" { + return result{}, httpErr(409, "Сервер уже работает на AmneziaWG 3.1") + } + if blocker := s.checkGen31Support(); blocker != "" { + return result{}, httpErr(409, blocker) + } + r, err := s.upgradeToGen31() + if err != nil { + return result{}, httpErr(500, err.Error()) + } + return ok(r) + }), + + // Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не + // пропустит (и без PATCH: агент отвечает на него 400). + R("GET", "/api/shaping", false, a, func(c *rctx) (result, error) { return ok(s.shaping.Status()) }), + + R("PUT", "/api/shaping", false, a, func(c *rctx) (result, error) { + b := c.obj() + enabled, e1 := b.Get2("enabled").(bool) + auto, e2 := b.Get2("auto").(bool) + if !e1 || !e2 { + return result{}, httpErr(400, "enabled и auto должны быть true/false") + } + mbit := func(v any, what string) (int, error) { + n := jsToNumber(v) + if math.IsNaN(n) || n != math.Trunc(n) || n < 0 || n > 100_000 { + return 0, httpErr(400, what+": целое число Мбит/с от 1 до 100000") + } + return int(n), nil + } + down, err := mbit(b.Get2("downMbit"), "Загрузка") + if err != nil { + return result{}, err + } + up, err := mbit(b.Get2("upMbit"), "Отдача") + if err != nil { + return result{}, err + } + if enabled && (down < 1 || up < 1) { + return result{}, httpErr(400, "Для включения укажите ширину канала в обе стороны") + } + // tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500. + if err := s.shaping.Update(ShapingSettings{Enabled: enabled, DownMbit: down, UpMbit: up, Auto: auto}); err != nil { + return result{}, httpErr(500, err.Error()) + } + return ok(s.shaping.Status()) + }), + + R("GET", "/api/metrics", false, a, func(c *rctx) (result, error) { + d, err := s.metricsCached() + if err != nil { + return result{}, err + } + return ok(d) + }), + + R("POST", "/api/monitor/install", false, a, func(c *rctx) (result, error) { + if err := s.startMonitorJob("install"); err != nil { + return result{}, err + } + return result{202, ob{{"running", true}, {"action", "install"}}}, nil + }), + R("POST", "/api/monitor/remove", false, a, func(c *rctx) (result, error) { + if err := s.startMonitorJob("remove"); err != nil { + return result{}, err + } + return result{202, ob{{"running", true}, {"action", "remove"}}}, nil + }), + R("GET", "/api/monitor/job", false, a, func(c *rctx) (result, error) { return ok(s.monitorView()) }), + + R("GET", "/awg/status", true, a, func(c *rctx) (result, error) { return ok(s.interfaceStatus()) }), + + R("POST", "/awg/start", true, a, func(c *rctx) (result, error) { + st, err := s.startInterface() + if err != nil { + return result{}, err + } + return ok(st) + }), + } +} diff --git a/internal/ctrl/server.go b/internal/ctrl/server.go new file mode 100644 index 0000000..6e505ac --- /dev/null +++ b/internal/ctrl/server.go @@ -0,0 +1,186 @@ +// Package ctrl — awg-ctrl: привилегированный REST API, который управляет интерфейсом +// AmneziaWG (`awg`, `awg-quick`, `ip`, `tc`, `nft`) и базой пользователей users.db. +// Слушает только 127.0.0.1; снаружи до него добирается лишь awg-ui (и агент ноды, и CLI) +// с подписанным Ed25519-токеном. +package ctrl + +import ( + "crypto/ed25519" + "database/sql" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "sync" + "time" + + "github.com/maeneko/forgetting/internal/iauth" + "github.com/maeneko/forgetting/internal/logx" +) + +// Config — то, что в Node-версии лежало в CONFIG. Параметры и поколение меняются при +// restartAwg/переходе на 3.1 — читать через Server.cfg(). +type Config struct { + ServerIP string + ServerPort int + ServerName string + DNS1, DNS2 string + MTU int + Keepalive string + Subnet string + Params Params + Gen string + AwgModule string + AwgTools string +} + +type Server struct { + iface string + confDir string + dbPath string + serverPrivKeyFile string + port int + + db *sql.DB + log *logx.Logger + pub ed25519.PublicKey + + // mu делает последовательности «БД + awg + awg1.conf» неделимыми. В Node весь код между + // await атомарен (один поток); в Go без этого два запроса перемешали бы шаги. + mu sync.Mutex + cfgMu sync.RWMutex + c Config + + shaping *Shaping + metrics metricsCache + monitor monitorJob + cliPath string + cliLog string +} + +func (s *Server) cfg() Config { + s.cfgMu.RLock() + defer s.cfgMu.RUnlock() + return s.c +} + +func (s *Server) keyEnv() keyEnv { + c := s.cfg() + return keyEnv{P: c.Params, ServerIP: c.ServerIP, ServerPort: c.ServerPort, ServerName: c.ServerName, + DNS1: c.DNS1, DNS2: c.DNS2, MTU: c.MTU, Keepalive: c.Keepalive, Gen: c.Gen, Subnet: c.Subnet} +} + +func envOr(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +func getLocalIP() string { + r := runCmd("ip", "route", "show", "default") + m := regexp.MustCompile(`dev\s+(\S+)`).FindStringSubmatch(r.Stdout) + if m == nil { + return "" + } + a := runCmd("ip", "addr", "show", m[1]) + if m := regexp.MustCompile(`inet\s+([\d.]+)`).FindStringSubmatch(a.Stdout); m != nil { + return m[1] + } + return "" +} + +// New открывает базу и ключи, читает параметры из awg1.conf. Не слушает порт. +func New() (*Server, error) { + s := &Server{ + iface: "awg1", + confDir: envOr("AWG_CONF_DIR", "/etc/amnezia/amneziawg"), + serverPrivKeyFile: envOr("AWG_SERVER_PRIV_KEY_FILE", "/etc/amnezia/server_private.key"), + cliPath: envOr("AWG_CLI", "/usr/local/bin/awg-ctrl"), + } + s.dbPath = envOr("AWG_DB_PATH", filepath.Join(s.confDir, "users.db")) + port, _ := strconv.Atoi(os.Getenv("PORT")) + if port == 0 { + port = 3005 + } + s.port = port + cwd, _ := os.Getwd() + s.log = logx.New(os.Stdout, filepath.Join(cwd, "logs")) + s.cliLog = envOr("MONITOR_LOG", filepath.Join(filepath.Dir(cwd), "logs", "monitor-job.log")) + + pubFile := envOr("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key") + pub, err := iauth.LoadPublic(pubFile) + if err != nil { + s.log.Error("FATAL: публичный ключ внутренней авторизации не найден: " + pubFile) + return nil, fmt.Errorf("FATAL: публичный ключ внутренней авторизации не найден: %s", pubFile) + } + s.pub = pub + + db, err := openDB(s.dbPath) + if err != nil { + return nil, err + } + s.db = db + + // Адрес/порт/имя: БД, затем env, затем (для адреса) маршрут по умолчанию. Как в Node, + // запасной адрес вычисляется всегда, даже если в БД уже есть значение. + fallbackIP := "" + if v, ok := os.LookupEnv("SERVER_IP"); ok { + fallbackIP = v + } else { + fallbackIP = getLocalIP() + } + ip := s.getCfg("serverIp", fallbackIP) + portStr := s.getCfg("serverPort", envOr("SERVER_PORT", "51820")) + name := s.getCfg("serverName", envOr("SERVER_NAME", "VPN")) + if ip == "" { + return nil, fmt.Errorf("SERVER_IP не задан — передай через env при первом запуске") + } + s.setCfg("serverIp", ip) + s.setCfg("serverPort", portStr) + s.setCfg("serverName", name) + sp, _ := strconv.Atoi(portStr) + + params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) }) + gen := params.Gen() + module, tools := readVersions() + s.c = Config{ + ServerIP: ip, ServerPort: sp, ServerName: name, + DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: 1376, Subnet: "10.9", + Keepalive: keepaliveByGen[gen], Params: params, Gen: gen, + AwgModule: module, AwgTools: tools, + } + s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen) + s.shaping = newShaping(s) + return s, nil +} + +// Run слушает 127.0.0.1:PORT и выполняет стартовую последовательность Node-версии: +// listen → ensureInterfaceUp → syncPeers → шейпинг. Нет интерфейса — завершаемся с ошибкой: +// health-gate CLI опирается на это (без поднятого awg1 awg-ctrl не нужен). +func (s *Server) Run() error { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.port)) + if err != nil { + return err + } + srv := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 15 * time.Second} + errc := make(chan error, 1) + go func() { errc <- srv.Serve(ln) }() + c := s.cfg() + s.log.Info("Server started", "port", s.port, "host", "127.0.0.1", "serverName", c.ServerName, "serverIp", c.ServerIP) + + if err := s.ensureInterfaceUp(); err != nil { + return err + } + if err := s.syncPeers(); err != nil { + return err + } + // Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre), + // ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает. + go s.shaping.Apply() + s.shaping.Start() + return <-errc +} diff --git a/internal/ctrl/shaping.go b/internal/ctrl/shaping.go new file mode 100644 index 0000000..aba59e0 --- /dev/null +++ b/internal/ctrl/shaping.go @@ -0,0 +1,605 @@ +package ctrl + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057. +// +// Схема: +// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3 +// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort +// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу +// +// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и +// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому Apply зовётся на +// старте, после restartAwg()/startInterface() и из страховочной проверки в цикле. +// +// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по времени, +// ни по неактивности (правило проекта: удаление только по явному списку от панели). + +type ShapingSettings struct { + Enabled bool `json:"enabled"` + DownMbit int `json:"downMbit"` + UpMbit int `json:"upMbit"` + Auto bool `json:"auto"` +} + +type QdiscStats struct { + Packets float64 `json:"packets"` + Bytes float64 `json:"bytes"` + Drops float64 `json:"drops"` + Backlog float64 `json:"backlog"` +} + +type ShapingStatus struct { + Settings ShapingSettings `json:"settings"` + Active bool `json:"active"` + Congested bool `json:"congested"` + Heavy []string `json:"heavy"` + Devices int `json:"devices"` + RateDown float64 `json:"rateDown"` + RateUp float64 `json:"rateUp"` + Down *QdiscStats `json:"down,omitempty"` + Up *QdiscStats `json:"up,omitempty"` +} + +const ( + congestOn = 0.85 // доля ширины, выше которой канал считается перегруженным + congestOff = 0.70 // и ниже которой перегрузка снимается (гистерезис) + activeMbit = 0.1 // меньше — устройство не считается активным + heavyMult = 1.5 // тяжёлое: ≥ 1.5 честных долей… + heavyMinFrac = 0.05 // …и не меньше 5% ширины канала + checkEvery = 3 // страховочная проверка qdisc — раз в N тиков + ifbName = "ifb-awg" + nftTable = "awg_shape" +) + +// Окна и пороги; переопределение через env — только для тестов на стенде. +func envNum(key string, def float64) float64 { + if v := os.Getenv(key); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 { + return f + } + } + return def +} + +type tick struct{ dt, bytes float64 } + +type peerCounters struct { + IP string + Rx, Tx float64 +} + +// orderedSet — множество с порядком вставки (JS Set): порядок попадает в nft-скрипт. +type orderedSet struct { + keys []string + m map[string]bool +} + +func newOrderedSet() *orderedSet { return &orderedSet{m: map[string]bool{}} } +func (o *orderedSet) has(k string) bool { return o.m[k] } +func (o *orderedSet) add(k string) { + if !o.m[k] { + o.m[k] = true + o.keys = append(o.keys, k) + } +} +func (o *orderedSet) del(k string) { + if !o.m[k] { + return + } + delete(o.m, k) + for i, x := range o.keys { + if x == k { + o.keys = append(o.keys[:i], o.keys[i+1:]...) + break + } + } +} +func (o *orderedSet) clear() { o.keys, o.m = nil, map[string]bool{} } +func (o *orderedSet) list() []string { return append([]string(nil), o.keys...) } +func (o *orderedSet) size() int { return len(o.keys) } + +type Shaping struct { + s *Server + + opMu sync.Mutex // сериализует tc/nft: два параллельных Apply дали бы полусобранные qdisc + + mu sync.Mutex // состояние сэмплера + prev map[string][2]float64 + history map[string][]tick + histOrder []string + totals []tick + heavy *orderedSet + congested bool + lastRateDn float64 + lastRateUp float64 + lastTickAt time.Time + tickNo int + lastError string + windowTicks int + sampleS float64 + started bool +} + +func newShaping(s *Server) *Shaping { + sampleS := envNum("SHAPE_SAMPLE_S", 10) + windowS := envNum("SHAPE_WINDOW_S", 600) + return &Shaping{ + s: s, prev: map[string][2]float64{}, history: map[string][]tick{}, heavy: newOrderedSet(), + sampleS: sampleS, windowTicks: int(math.Max(3, math.Round(windowS/sampleS))), + } +} + +func (h *Shaping) Settings() ShapingSettings { + s := h.s + down, _ := strconv.Atoi(s.getCfg("shapeDownMbit", "0")) + up, _ := strconv.Atoi(s.getCfg("shapeUpMbit", "0")) + return ShapingSettings{ + Enabled: s.getCfg("shapeEnabled", "0") == "1", + DownMbit: down, UpMbit: up, + Auto: s.getCfg("shapeAuto", "1") == "1", + } +} + +func (h *Shaping) save(st ShapingSettings) { + b := func(v bool) string { + if v { + return "1" + } + return "0" + } + h.s.setCfg("shapeEnabled", b(st.Enabled)) + h.s.setCfg("shapeDownMbit", strconv.Itoa(st.DownMbit)) + h.s.setCfg("shapeUpMbit", strconv.Itoa(st.UpMbit)) + h.s.setCfg("shapeAuto", b(st.Auto)) +} + +func (h *Shaping) noteError(err error) { + h.mu.Lock() + defer h.mu.Unlock() + if m := err.Error(); m != h.lastError { // одинаковую ошибку в лог не спамим + h.s.log.Warn("shaping: " + m) + h.lastError = m + } +} + +func (h *Shaping) overhead() int { + if v := os.Getenv("SHAPE_OVERHEAD"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 { + return int(f) + } + } + return 60 + h.s.cfg().Params.S4 +} + +func shExec(name string, args ...string) (string, error) { + return runCtx(15*time.Second, name, args...) +} + +func shExecStdin(name string, args []string, input string) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + var se bytes.Buffer + cmd.Stderr = &se + cmd.Stdin = strings.NewReader(input) + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(se.String()) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("%s: %s", name, msg) + } + return nil +} + +func (h *Shaping) nftApply(heavy []string) error { + elements := "" + if len(heavy) > 0 { + elements = "\n elements = { " + strings.Join(heavy, ", ") + " }" + } + // add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил. + script := "\nadd table inet " + nftTable + "\ndelete table inet " + nftTable + "\ntable inet " + nftTable + " {\n" + + " set heavy {\n type ipv4_addr" + elements + "\n }\n" + + " chain postrouting {\n type filter hook postrouting priority -150; policy accept;\n" + + " oifname \"" + h.s.iface + "\" ip daddr @heavy ip dscp set cs1\n }\n}\n" + return shExecStdin("nft", []string{"-f", "-"}, script) +} + +func (h *Shaping) hasCake(dev string) bool { + out, err := shExec("tc", "qdisc", "show", "dev", dev, "root") + return err == nil && strings.Contains(out, "cake") +} + +func (h *Shaping) hasIngressRedirect() bool { + out, err := shExec("tc", "filter", "show", "dev", h.s.iface, "parent", "ffff:") + return err == nil && strings.Contains(out, "mirred") +} + +func (h *Shaping) applyNow() error { + st := h.Settings() + if !st.Enabled || st.DownMbit <= 0 || st.UpMbit <= 0 { + h.removeNow() + return nil + } + iface := h.s.iface + oh := strconv.Itoa(h.overhead()) + + // Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету. + if _, err := shExec("tc", "qdisc", "replace", "dev", iface, "root", "cake", + "bandwidth", fmt.Sprintf("%dmbit", st.DownMbit), "diffserv3", "dual-dsthost", "overhead", oh); err != nil { + return err + } + // Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там. + if _, err := shExec("ip", "link", "show", ifbName); err != nil { + if _, err := shExec("ip", "link", "add", ifbName, "type", "ifb"); err != nil { + return err + } + } + if _, err := shExec("ip", "link", "set", ifbName, "up"); err != nil { + return err + } + if _, err := shExec("tc", "qdisc", "replace", "dev", ifbName, "root", "cake", + "bandwidth", fmt.Sprintf("%dmbit", st.UpMbit), "besteffort", "dual-srchost", "overhead", oh); err != nil { + return err + } + if !h.hasIngressRedirect() { + shExec("tc", "qdisc", "del", "dev", iface, "ingress") // ошибки игнорируем + if _, err := shExec("tc", "qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"); err != nil { + return err + } + if _, err := shExec("tc", "filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1", + "matchall", "action", "mirred", "egress", "redirect", "dev", ifbName); err != nil { + return err + } + } + h.mu.Lock() + heavy := h.heavy.list() + h.mu.Unlock() + if err := h.nftApply(heavy); err != nil { + return err + } + h.mu.Lock() + h.lastError = "" + h.mu.Unlock() + h.s.log.Info("shaping applied", "down", st.DownMbit, "up", st.UpMbit, "overhead", oh, "heavy", len(heavy)) + return nil +} + +func (h *Shaping) removeNow() { + iface := h.s.iface + shExec("tc", "qdisc", "del", "dev", iface, "root") + shExec("tc", "qdisc", "del", "dev", iface, "ingress") + shExec("ip", "link", "del", ifbName) + shExec("nft", "delete", "table", "inet", nftTable) + h.mu.Lock() + h.heavy.clear() + h.history, h.histOrder, h.prev = map[string][]tick{}, nil, map[string][2]float64{} + h.totals = nil + h.congested = false + h.mu.Unlock() +} + +// Apply не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от +// ensureInterfaceUp). +func (h *Shaping) Apply() { + h.opMu.Lock() + err := h.applyNow() + h.opMu.Unlock() + if err != nil { + h.noteError(err) + } +} + +// Update сохраняет настройки и применяет их; ошибку применения отдаёт вызывающему — +// пользователю нужна честная причина, а не молчаливый warn. +func (h *Shaping) Update(st ShapingSettings) error { + h.save(st) + h.opMu.Lock() + err := h.applyNow() + h.opMu.Unlock() + if err == nil { + h.mu.Lock() + h.lastError = "" + h.mu.Unlock() + } + return err +} + +// ───────── сэмплер (RFC 6057) ───────── + +func (h *Shaping) pushTick(arr []tick, t tick) []tick { + arr = append(arr, t) + for len(arr) > h.windowTicks { + arr = arr[1:] + } + return arr +} + +func mbit(arr []tick) float64 { + var dt, by float64 + for _, t := range arr { + dt += t.dt + by += t.bytes + } + if dt <= 0 { + return 0 + } + return by * 8 / dt / 1e6 +} + +func (h *Shaping) readPeers() ([]peerCounters, error) { + out, err := runCtx(10*time.Second, "awg", "show", h.s.iface, "dump") + if err != nil { + return nil, err + } + var rows []peerCounters + lines := strings.Split(out, "\n") + if len(lines) > 0 { + lines = lines[1:] + } + for _, l := range lines { + f := strings.Split(l, "\t") + if len(f) < 4 || f[0] == "" || f[3] == "" { + continue + } + ip := strings.SplitN(strings.SplitN(f[3], ",", 2)[0], "/", 2)[0] + rx, tx := 0.0, 0.0 + if len(f) > 5 { + rx = orZero(jsNumber(f[5])) + } + if len(f) > 6 { + tx = orZero(jsNumber(f[6])) + } + rows = append(rows, peerCounters{ip, rx, tx}) + } + return rows, nil +} + +func orZero(f float64) float64 { + if math.IsNaN(f) { + return 0 + } + return f +} + +func (h *Shaping) tick() { + st := h.Settings() + if !st.Enabled { + return + } + now := time.Now() + h.mu.Lock() + dt := h.sampleS + if !h.lastTickAt.IsZero() { + dt = now.Sub(h.lastTickAt).Seconds() + } + h.lastTickAt = now + h.tickNo++ + tickNo := h.tickNo + h.mu.Unlock() + + // Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез. + if tickNo%checkEvery == 0 && !h.hasCake(h.s.iface) { + h.s.log.Warn("shaping: qdisc на интерфейсе пропал, применяю заново") + h.Apply() + } + + peers, err := h.readPeers() + if err != nil { + h.noteError(err) + return + } + + h.mu.Lock() + var totalDown, totalUp float64 + seen := map[string]bool{} + for _, p := range peers { + seen[p.IP] = true + before, had := h.prev[p.IP] + // Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира → + // отрицательная дельта = 0. + dTx, dRx := 0.0, 0.0 + if had && p.Tx >= before[1] { + dTx = p.Tx - before[1] + } + if had && p.Rx >= before[0] { + dRx = p.Rx - before[0] + } + h.prev[p.IP] = [2]float64{p.Rx, p.Tx} + totalDown += dTx + totalUp += dRx + if _, ok := h.history[p.IP]; !ok { + h.histOrder = append(h.histOrder, p.IP) + } + h.history[p.IP] = h.pushTick(h.history[p.IP], tick{dt, dTx}) + } + var kept []string + for _, ip := range h.histOrder { + if seen[ip] { + kept = append(kept, ip) + continue + } + delete(h.prev, ip) + delete(h.history, ip) + h.heavy.del(ip) + } + h.histOrder = kept + h.totals = h.pushTick(h.totals, tick{dt, totalDown}) + h.lastRateDn = totalDown * 8 / dt / 1e6 + h.lastRateUp = totalUp * 8 / dt / 1e6 + + if !st.Auto { + changed := h.heavy.size() > 0 || h.congested + h.heavy.clear() + h.congested = false + h.mu.Unlock() + if changed { + h.opMu.Lock() + err := h.nftApply(nil) + h.opMu.Unlock() + if err != nil { + h.noteError(err) + } + } + return + } + if len(h.totals) < h.windowTicks { // пока окно не набралось, решений не принимаем + h.mu.Unlock() + return + } + + avg := mbit(h.totals) + wasCongested := h.congested + if !h.congested && avg >= congestOn*float64(st.DownMbit) { + h.congested = true + } + if h.congested && avg < congestOff*float64(st.DownMbit) { + h.congested = false + } + + next := newOrderedSet() + if h.congested { + type rate struct { + ip string + r float64 + } + var rates []rate + for _, ip := range h.histOrder { + if hh := h.history[ip]; len(hh) >= h.windowTicks { + rates = append(rates, rate{ip, mbit(hh)}) + } + } + active := 0 + for _, r := range rates { + if r.r >= activeMbit { + active++ + } + } + share := float64(st.DownMbit) / math.Max(1, float64(active)) + for _, r := range rates { + wasHeavy := h.heavy.has(r.ip) + // Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе. + if r.r >= math.Max(heavyMult*share, heavyMinFrac*float64(st.DownMbit)) || (wasHeavy && r.r >= share) { + next.add(r.ip) + } + } + } + changed := next.size() != h.heavy.size() + if !changed { + for _, ip := range next.keys { + if !h.heavy.has(ip) { + changed = true + break + } + } + } + var apply []string + if changed || wasCongested != h.congested { + h.heavy = next + apply = next.list() + h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", apply) + } + h.mu.Unlock() + if changed || wasCongested != h.congested { + h.opMu.Lock() + err := h.nftApply(apply) + h.opMu.Unlock() + if err != nil { + h.noteError(err) + } + } +} + +func (h *Shaping) Start() { + h.mu.Lock() + if h.started { + h.mu.Unlock() + return + } + h.started = true + h.mu.Unlock() + go func() { + t := time.NewTicker(time.Duration(h.sampleS * float64(time.Second))) + defer t.Stop() + var busy sync.Mutex + for range t.C { + if !busy.TryLock() { // предыдущий тик ещё идёт — пропускаем, а не копим очередь + continue + } + go func() { + defer busy.Unlock() + h.tick() + }() + } + }() +} + +// ───────── состояние ───────── + +func (h *Shaping) qdiscStats(dev string) *QdiscStats { + out, err := shExec("tc", "-s", "-j", "qdisc", "show", "dev", dev, "root") + if err != nil { + return nil + } + var arr []map[string]any + if json.Unmarshal([]byte(out), &arr) != nil { + return nil + } + for _, q := range arr { + if q["kind"] == "cake" { + n := func(k string) float64 { f, _ := q[k].(float64); return f } + return &QdiscStats{n("packets"), n("bytes"), n("drops"), n("backlog")} + } + } + return nil +} + +func (h *Shaping) PriorityOf(ip string) string { + h.mu.Lock() + defer h.mu.Unlock() + if h.heavy.has(ip) { + return "background" + } + return "normal" +} + +func (h *Shaping) Status() ShapingStatus { + st := h.Settings() + var active bool + var down, up *QdiscStats + if st.Enabled { + active = h.hasCake(h.s.iface) + down, up = h.qdiscStats(h.s.iface), h.qdiscStats(ifbName) + } + h.mu.Lock() + defer h.mu.Unlock() + devices := 0 + for _, hh := range h.history { + if mbit(hh) >= activeMbit { + devices++ + } + } + heavy := h.heavy.list() + if heavy == nil { + heavy = []string{} + } + round1 := func(f float64) float64 { return math.Round(f*10) / 10 } + return ShapingStatus{ + Settings: st, Active: active, Congested: h.congested, Heavy: heavy, Devices: devices, + RateDown: round1(h.lastRateDn), RateUp: round1(h.lastRateUp), Down: down, Up: up, + } +} diff --git a/internal/ctrl/vpnkey.go b/internal/ctrl/vpnkey.go new file mode 100644 index 0000000..b411895 --- /dev/null +++ b/internal/ctrl/vpnkey.go @@ -0,0 +1,218 @@ +package ctrl + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "encoding/binary" + "io" + "strconv" + "strings" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +// Keys — пара ключей клиента и PSK. +type Keys struct{ Private, Public, PSK string } + +// Подмножество CONFIG, нужное для сборки ключа. +type keyEnv struct { + P Params + ServerIP string + ServerPort int + ServerName string + DNS1, DNS2 string + MTU int + Keepalive string + Gen string + Subnet string +} + +// buildClientConf — клиентский .conf. Порядок: PrivateKey → AWG params (Jc,S,H,I) → +// 3.x-ключи → Address → DNS. +// +// 🛑 Пустые I2–I5 выводятся как «I2 = » с ОДНИМ хвостовым пробелом (так в рабочих ключах +// Amnezia). Пробел дан отдельной константой, чтобы его не срезала ни IDE, ни инструмент +// правки. Не «чистить». +func buildClientConf(e keyEnv, k Keys, ip, serverPub string) string { + const sp = " " + var awg3 strings.Builder + for _, key := range AWG3Keys { + if v := e.P.a3(key); v != "" { + awg3.WriteString(key + " = " + v + "\n") + } + } + var b strings.Builder + b.WriteString("[Interface]\n") + b.WriteString("PrivateKey = " + k.Private + "\n") + b.WriteString("Jc = " + strconv.Itoa(e.P.Jc) + "\n") + b.WriteString("Jmin = " + strconv.Itoa(e.P.Jmin) + "\n") + b.WriteString("Jmax = " + strconv.Itoa(e.P.Jmax) + "\n") + b.WriteString("S1 = " + strconv.Itoa(e.P.S1) + "\n") + b.WriteString("S2 = " + strconv.Itoa(e.P.S2) + "\n") + b.WriteString("S3 = " + strconv.Itoa(e.P.S3) + "\n") + b.WriteString("S4 = " + strconv.Itoa(e.P.S4) + "\n") + b.WriteString("H1 = " + e.P.H1 + "\n") + b.WriteString("H2 = " + e.P.H2 + "\n") + b.WriteString("H3 = " + e.P.H3 + "\n") + b.WriteString("H4 = " + e.P.H4 + "\n") + b.WriteString("I1 = " + e.P.I1 + "\n") + b.WriteString("I2 =" + sp + "\n") + b.WriteString("I3 =" + sp + "\n") + b.WriteString("I4 =" + sp + "\n") + b.WriteString("I5 =" + sp + "\n") + b.WriteString(awg3.String()) + b.WriteString("Address = " + ip + "/32\n") + b.WriteString("DNS = " + e.DNS1 + ", " + e.DNS2 + "\n") + b.WriteString("\n[Peer]\n") + b.WriteString("PublicKey = " + serverPub + "\n") + b.WriteString("PresharedKey = " + k.PSK + "\n") + b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n") + b.WriteString("Endpoint = " + e.ServerIP + ":" + strconv.Itoa(e.ServerPort) + "\n") + b.WriteString("PersistentKeepalive = " + e.Keepalive + "\n") + return b.String() +} + +// awgObject собирает объект параметров в ASCII-алфавитном порядке, в каком его +// сериализует QJsonObject клиента AmneziaVPN. Незаданные 3.x-ключи выбрасываются. +func awgObject(p Params) jsjson.Object { + a := func(k string) any { + if p.a3(k) == "" { + return jsjson.Undefined + } + return p.a3(k) + } + return jsjson.Object{ + {"ContentPaddingAddition", a("ContentPaddingAddition")}, + {"DisableCookies", a("DisableCookies")}, + {"H1", p.H1}, {"H2", p.H2}, {"H3", p.H3}, {"H4", p.H4}, + {"HeaderProtectionKey", a("HeaderProtectionKey")}, + {"I1", p.I1}, {"I2", ""}, {"I3", ""}, {"I4", ""}, {"I5", ""}, + {"Jc", strconv.Itoa(p.Jc)}, + {"Jmax", strconv.Itoa(p.Jmax)}, + {"Jmin", strconv.Itoa(p.Jmin)}, + {"KeepaliveTimeout", a("KeepaliveTimeout")}, + {"MaxHandshakeAttempts", a("MaxHandshakeAttempts")}, + {"RandomTrailers", a("RandomTrailers")}, + {"RejectAfterTime", a("RejectAfterTime")}, + {"RekeyAfterTime", a("RekeyAfterTime")}, + {"RekeyTimeout", a("RekeyTimeout")}, + {"S1", strconv.Itoa(p.S1)}, {"S2", strconv.Itoa(p.S2)}, + {"S3", strconv.Itoa(p.S3)}, {"S4", strconv.Itoa(p.S4)}, + } +} + +// EncodeVpnKey собирает vpn://-ключ в формате AmneziaVPN: JSON → zlib → 4 байта длины +// (BE, несжатой) → base64url без «=». +// +// Содержимое после распаковки совпадает с Node-версией байт в байт (порядок ключей, +// типы: port — число внутри last_config и строка снаружи). Сам поток deflate у Go и +// у C-zlib разный, поэтому строка vpn:// не совпадает побайтно — решение владельца +// проекта: AmneziaVPN читает оба одинаково (qUncompress). +func EncodeVpnKey(e keyEnv, k Keys, ip, serverPub string) (string, error) { + clientConf := buildClientConf(e, k, ip, serverPub) + + lastConfig := append(awgObject(e.P), + jsjson.KV{"allowed_ips", []string{"0.0.0.0/0", "::/0"}}, + jsjson.KV{"clientId", k.Public}, + jsjson.KV{"client_ip", ip}, + jsjson.KV{"client_priv_key", k.Private}, + jsjson.KV{"client_pub_key", k.Public}, + jsjson.KV{"config", clientConf}, + jsjson.KV{"hostName", e.ServerIP}, + jsjson.KV{"mtu", strconv.Itoa(e.MTU)}, + jsjson.KV{"persistent_keep_alive", e.Keepalive}, + jsjson.KV{"port", e.ServerPort}, + jsjson.KV{"psk_key", k.PSK}, + jsjson.KV{"server_pub_key", serverPub}, + ) + lastConfigJSON, err := jsjson.MarshalIndent(lastConfig, " ") + if err != nil { + return "", err + } + + awg := append(awgObject(e.P), + jsjson.KV{"last_config", string(lastConfigJSON)}, + jsjson.KV{"port", strconv.Itoa(e.ServerPort)}, + jsjson.KV{"protocol_version", e.Gen}, + jsjson.KV{"subnet_address", e.Subnet + ".0.0"}, + jsjson.KV{"transport_proto", "udp"}, + ) + outer := jsjson.Object{ + {"containers", []any{jsjson.Object{{"container", "amnezia-awg2"}, {"awg", awg}}}}, + {"defaultContainer", "amnezia-awg2"}, + {"description", e.ServerName}, + {"dns1", e.DNS1}, + {"dns2", e.DNS2}, + {"hostName", e.ServerIP}, + {"nameOverriddenByUser", true}, + } + js, err := jsjson.Marshal(outer) + if err != nil { + return "", err + } + + var zb bytes.Buffer + zw, _ := zlib.NewWriterLevel(&zb, zlib.DefaultCompression) + if _, err := zw.Write(js); err != nil { + return "", err + } + if err := zw.Close(); err != nil { + return "", err + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(js))) + return "vpn://" + base64.RawURLEncoding.EncodeToString(append(hdr[:], zb.Bytes()...)), nil +} + +// DecodeVpnKey — обратная к EncodeVpnKey. Как Buffer.from(…, "base64url") в Node, +// терпим паддинг, «+» и «/». Любая ошибка → nil. +func DecodeVpnKey(key string) jsjson.Object { + s := strings.TrimPrefix(key, "vpn://") + s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "=")) + buf, err := base64.RawURLEncoding.DecodeString(s) + if err != nil || len(buf) <= 4 { + return nil + } + zr, err := zlib.NewReader(bytes.NewReader(buf[4:])) + if err != nil { + return nil + } + defer zr.Close() + raw, err := io.ReadAll(zr) + if err != nil { + return nil + } + v, err := jsjson.Decode(raw) + if err != nil { + return nil + } + o, _ := v.(jsjson.Object) + return o +} + +// clientPrivKeyFrom достаёт client_priv_key из last_config: приватный ключ клиента +// нигде больше не хранится, и только так перевыпуск сохраняет личность пира. +func clientPrivKeyFrom(vpnKey string) string { + o := DecodeVpnKey(vpnKey) + if o == nil { + return "" + } + containers, _ := o.Get2("containers").([]any) + if len(containers) == 0 { + return "" + } + c, _ := containers[0].(jsjson.Object) + awg, _ := c.Get2("awg").(jsjson.Object) + lc, ok := awg.Get2("last_config").(string) + if !ok { + return "" + } + v, err := jsjson.Decode([]byte(lc)) + if err != nil { + return "" + } + obj, _ := v.(jsjson.Object) + priv, _ := obj.Get2("client_priv_key").(string) + return priv +} diff --git a/internal/iauth/iauth.go b/internal/iauth/iauth.go new file mode 100644 index 0000000..918abf8 --- /dev/null +++ b/internal/iauth/iauth.go @@ -0,0 +1,122 @@ +// Package iauth — внутренние токены между awg-ui / awg-agent / CLI и awg-ctrl. +// +// Формат — «JWT» с EdDSA: header {"alg":"EdDSA","typ":"JWT"}, payload {iss,iat,exp}, +// подпись Ed25519 над ASCII "h.p". Ключи — PEM от openssl: PKCS#8 (закрытый) и SPKI +// (открытый). Проверка намеренно такая же нестрогая, как у Node-версии: только подпись +// и exp (alg/iss/лишние сегменты не смотрим) — ужесточение отдельным шагом (zero trust). +package iauth + +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "os" + "strconv" + "strings" + "time" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +func decodePEM(file string) ([]byte, error) { + raw, err := os.ReadFile(file) + if err != nil { + return nil, err + } + blk, _ := pem.Decode(raw) + if blk == nil { + return nil, errors.New("iauth: not a PEM file: " + file) + } + return blk.Bytes, nil +} + +// LoadPublic читает открытый ключ (SPKI PEM). +func LoadPublic(file string) (ed25519.PublicKey, error) { + der, err := decodePEM(file) + if err != nil { + return nil, err + } + k, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, err + } + pub, ok := k.(ed25519.PublicKey) + if !ok { + return nil, errors.New("iauth: not an Ed25519 public key") + } + return pub, nil +} + +// LoadPrivate читает закрытый ключ (PKCS#8 PEM). +func LoadPrivate(file string) (ed25519.PrivateKey, error) { + der, err := decodePEM(file) + if err != nil { + return nil, err + } + k, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + return nil, err + } + priv, ok := k.(ed25519.PrivateKey) + if !ok { + return nil, errors.New("iauth: not an Ed25519 private key") + } + return priv, nil +} + +var b64 = base64.RawURLEncoding + +// Mint выпускает токен на 60 с. +func Mint(priv ed25519.PrivateKey, iss string, now time.Time) string { + h := b64.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`)) + t := now.Unix() + p := b64.EncodeToString(jsjson.MustMarshal(jsjson.Object{{Key: "iss", Val: iss}, {Key: "iat", Val: t}, {Key: "exp", Val: t + 60}})) + sig := ed25519.Sign(priv, []byte(h+"."+p)) + return h + "." + p + "." + b64.EncodeToString(sig) +} + +// lenient повторяет Buffer.from(s, "base64url") в Node: терпит паддинг, «+», «/». +func lenient(s string) ([]byte, error) { + s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "=")) + return b64.DecodeString(s) +} + +// Verify — подпись верна и exp в будущем. +func Verify(pub ed25519.PublicKey, token string, now time.Time) bool { + parts := strings.Split(token, ".") + if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return false + } + sig, err := lenient(parts[2]) + if err != nil || !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) { + return false + } + raw, err := lenient(parts[1]) + if err != nil { + return false + } + v, err := jsjson.Decode(raw) + if err != nil { + return false + } + obj, ok := v.(jsjson.Object) + if !ok { + return false + } + var exp float64 + switch e := obj.Get2("exp").(type) { + case float64: + exp = e + case string: // JS сравнил бы числовую строку как число + f, err := strconv.ParseFloat(strings.TrimSpace(e), 64) + if err != nil { + return false + } + exp = f + default: + return false + } + return exp > float64(now.Unix()) +} diff --git a/internal/jsjson/decode.go b/internal/jsjson/decode.go new file mode 100644 index 0000000..9d6d7bc --- /dev/null +++ b/internal/jsjson/decode.go @@ -0,0 +1,86 @@ +package jsjson + +import ( + "bytes" + "encoding/json" + "errors" + "io" +) + +// Decode разбирает JSON с сохранением порядка ключей: объекты — Object, массивы — +// []any, числа — float64, как JSON.parse. Нужен, чтобы JSON.stringify(JSON.parse(x)) +// воспроизводился точно (например, сохранённый профиль сервера с ключами awg). +func Decode(data []byte) (any, error) { + d := json.NewDecoder(bytes.NewReader(data)) + d.UseNumber() + v, err := decodeValue(d) + if err != nil { + return nil, err + } + if _, err := d.Token(); err != io.EOF { + return nil, errors.New("jsjson: trailing data") + } + return v, nil +} + +func decodeValue(d *json.Decoder) (any, error) { + t, err := d.Token() + if err != nil { + return nil, err + } + switch x := t.(type) { + case json.Delim: + switch x { + case '{': + obj := Object{} + for d.More() { + kt, err := d.Token() + if err != nil { + return nil, err + } + key, ok := kt.(string) + if !ok { + return nil, errors.New("jsjson: object key is not a string") + } + val, err := decodeValue(d) + if err != nil { + return nil, err + } + // повторный ключ в JS перезаписывает значение, оставаясь на первом месте + replaced := false + for i := range obj { + if obj[i].Key == key { + obj[i].Val = val + replaced = true + break + } + } + if !replaced { + obj = append(obj, KV{key, val}) + } + } + if _, err := d.Token(); err != nil { + return nil, err + } + return obj, nil + case '[': + arr := []any{} + for d.More() { + val, err := decodeValue(d) + if err != nil { + return nil, err + } + arr = append(arr, val) + } + if _, err := d.Token(); err != nil { + return nil, err + } + return arr, nil + } + return nil, errors.New("jsjson: unexpected delimiter") + case json.Number: + return x.Float64() + default: + return x, nil // string, bool, nil + } +} diff --git a/internal/jsjson/jsjson.go b/internal/jsjson/jsjson.go new file mode 100644 index 0000000..d2c816d --- /dev/null +++ b/internal/jsjson/jsjson.go @@ -0,0 +1,393 @@ +// Package jsjson повторяет JSON.stringify из JavaScript байт в байт. +// +// Зачем: выход Node-версии зашит в форматы, которые проверяют клиенты и хэши — +// распакованное содержимое vpn://, rev конфига sen:// (sha256 от строки), +// подписанные тела ответов /sub/v1, /api/profile. encoding/json отличается +// (экранирует < > & и U+2028/2029, иначе пишет числа, добавляет перевод +// строки, сортирует ключи map), поэтому здесь свой сериализатор. +// +// Порядок ключей — порядок объявления: Object хранит пары как есть, у структур +// берутся поля по порядку (тег json задаёт имя, "-" — пропуск, omitempty — +// пропуск нулевого значения, как undefined в JS). +package jsjson + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "reflect" + "strconv" + "strings" + "unicode/utf8" +) + +// KV — пара ключ/значение упорядоченного объекта. +type KV struct { + Key string + Val any +} + +// Object — JS-объект с сохранённым порядком ключей. +type Object []KV + +// Get возвращает значение по ключу. +func (o Object) Get(key string) (any, bool) { + for _, kv := range o { + if kv.Key == key { + return kv.Val, true + } + } + return nil, false +} + +// Raw — уже готовый JSON, вставляется как есть (без проверки). +type Raw []byte + +// Marshal — аналог JSON.stringify(v). +func Marshal(v any) ([]byte, error) { + var b bytes.Buffer + if err := encode(&b, reflect.ValueOf(v), "", ""); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// MarshalIndent — аналог JSON.stringify(v, null, indent) (у нас indent = " "). +func MarshalIndent(v any, indent string) ([]byte, error) { + var b bytes.Buffer + if err := encode(&b, reflect.ValueOf(v), indent, ""); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// MustMarshal — для значений, которые заведомо сериализуемы. +func MustMarshal(v any) []byte { + b, err := Marshal(v) + if err != nil { + panic(err) + } + return b +} + +var ( + objectType = reflect.TypeOf(Object(nil)) + rawType = reflect.TypeOf(Raw(nil)) + jsonRaw = reflect.TypeOf(json.RawMessage(nil)) + jsonNumber = reflect.TypeOf(json.Number("")) +) + +func encode(b *bytes.Buffer, v reflect.Value, indent, cur string) error { + if !v.IsValid() { + b.WriteString("null") + return nil + } + switch v.Type() { + case objectType: + return encodeObject(b, v.Interface().(Object), indent, cur) + case rawType: + b.Write(v.Bytes()) + return nil + case jsonRaw: + raw := v.Bytes() + if len(raw) == 0 { + b.WriteString("null") + return nil + } + // JSON.stringify(JSON.parse(x)): нормализуем, не доверяя форме исходника. + val, err := Decode(raw) + if err != nil { + return err + } + return encode(b, reflect.ValueOf(val), indent, cur) + case jsonNumber: + f, err := strconv.ParseFloat(v.String(), 64) + if err != nil { + return err + } + b.WriteString(Number(f)) + return nil + } + switch v.Kind() { + case reflect.Interface, reflect.Pointer: + if v.IsNil() { + b.WriteString("null") + return nil + } + return encode(b, v.Elem(), indent, cur) + case reflect.Bool: + if v.Bool() { + b.WriteString("true") + } else { + b.WriteString("false") + } + case reflect.String: + writeString(b, v.String()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + b.WriteString(Number(float64(v.Int()))) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + b.WriteString(Number(float64(v.Uint()))) + case reflect.Float32, reflect.Float64: + b.WriteString(Number(v.Float())) + case reflect.Slice: + if v.IsNil() { + // nil-срез — это пустой массив, а не null: в JS неоткуда взяться null вместо [] + b.WriteString("[]") + return nil + } + fallthrough + case reflect.Array: + return encodeArray(b, v, indent, cur) + case reflect.Map: + // В JS порядок ключей — порядок вставки; у Go-map его нет. Разрешаем только + // map[string]..., сортируя ключи, — вызывающий отвечает за то, что это совпадает. + if v.Type().Key().Kind() != reflect.String { + return fmt.Errorf("jsjson: map key must be string, got %s", v.Type().Key()) + } + keys := v.MapKeys() + ks := make([]string, len(keys)) + for i, k := range keys { + ks[i] = k.String() + } + sortStrings(ks) + obj := make(Object, 0, len(ks)) + for _, k := range ks { + obj = append(obj, KV{k, v.MapIndex(reflect.ValueOf(k).Convert(v.Type().Key())).Interface()}) + } + return encodeObject(b, obj, indent, cur) + case reflect.Struct: + return encodeObject(b, structObject(v), indent, cur) + default: + return fmt.Errorf("jsjson: unsupported kind %s", v.Kind()) + } + return nil +} + +func encodeArray(b *bytes.Buffer, v reflect.Value, indent, cur string) error { + n := v.Len() + if n == 0 { + b.WriteString("[]") + return nil + } + b.WriteByte('[') + inner := cur + indent + for i := 0; i < n; i++ { + if i > 0 { + b.WriteByte(',') + } + if indent != "" { + b.WriteByte('\n') + b.WriteString(inner) + } + // undefined/функция в массиве — null; у нас таких нет, кроме nil + if err := encode(b, v.Index(i), indent, inner); err != nil { + return err + } + } + if indent != "" { + b.WriteByte('\n') + b.WriteString(cur) + } + b.WriteByte(']') + return nil +} + +// Omit — значение, которое JSON.stringify пропускает (undefined): ключ не выводится. +type omit struct{} + +// Undefined — маркер «ключа нет», как undefined в JS-объекте. +var Undefined = omit{} + +func encodeObject(b *bytes.Buffer, o Object, indent, cur string) error { + first := true + inner := cur + indent + b.WriteByte('{') + for _, kv := range o { + if _, skip := kv.Val.(omit); skip { + continue + } + if !first { + b.WriteByte(',') + } + first = false + if indent != "" { + b.WriteByte('\n') + b.WriteString(inner) + } + writeString(b, kv.Key) + b.WriteByte(':') + if indent != "" { + b.WriteByte(' ') + } + if err := encode(b, reflect.ValueOf(kv.Val), indent, inner); err != nil { + return err + } + } + if !first && indent != "" { + b.WriteByte('\n') + b.WriteString(cur) + } + b.WriteByte('}') + return nil +} + +func structObject(v reflect.Value) Object { + t := v.Type() + obj := make(Object, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + name := f.Name + omitEmpty := false + if tag, ok := f.Tag.Lookup("json"); ok { + if tag == "-" { + continue + } + parts := strings.Split(tag, ",") + if parts[0] != "" { + name = parts[0] + } + for _, p := range parts[1:] { + if p == "omitempty" { + omitEmpty = true + } + } + } + fv := v.Field(i) + if omitEmpty && isEmpty(fv) { + continue + } + obj = append(obj, KV{name, fv.Interface()}) + } + return obj +} + +func isEmpty(v reflect.Value) bool { + switch v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return v.Len() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Pointer: + return v.IsNil() + } + return false +} + +// writeString экранирует строку как JSON.stringify (ES2019): " \ и управляющие +// символы; \b \f \n \r \t коротко, прочие < 0x20 — \u00xx строчными. Не трогает +// / < > & и U+2028/2029. Неверный UTF-8 заменяется на U+FFFD (в JS его не бывает). +func writeString(b *bytes.Buffer, s string) { + b.WriteByte('"') + for i := 0; i < len(s); { + c := s[i] + if c < utf8.RuneSelf { + switch c { + case '"': + b.WriteString(`\"`) + case '\\': + b.WriteString(`\\`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + if c < 0x20 { + fmt.Fprintf(b, `\u%04x`, c) + } else { + b.WriteByte(c) + } + } + i++ + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + b.WriteString("�") + } else { + b.WriteString(s[i : i+size]) + } + i += size + } + b.WriteByte('"') +} + +// Number форматирует число как String(x) в JS (Number::toString), а NaN/±Infinity — +// как null, что делает JSON.stringify. +func Number(x float64) string { + if math.IsNaN(x) || math.IsInf(x, 0) { + return "null" + } + if x == 0 { + return "0" // и -0 + } + neg := x < 0 + if neg { + x = -x + } + // Кратчайшие цифры, однозначно восстанавливающие x: d.ddddde±n + e := strconv.FormatFloat(x, 'e', -1, 64) + mant, expStr, _ := strings.Cut(e, "e") + digits := strings.Replace(mant, ".", "", 1) + exp, _ := strconv.Atoi(expStr) + k := len(digits) // число значащих цифр + n := exp + 1 // позиция десятичной точки (ECMA-262 Number::toString) + var out string + switch { + case k <= n && n <= 21: + out = digits + strings.Repeat("0", n-k) + case 0 < n && n <= 21: + out = digits[:n] + "." + digits[n:] + case -6 < n && n <= 0: + out = "0." + strings.Repeat("0", -n) + digits + default: + sign := "+" + if n-1 < 0 { + sign = "-" + } + ee := n - 1 + if ee < 0 { + ee = -ee + } + if k == 1 { + out = digits + "e" + sign + strconv.Itoa(ee) + } else { + out = digits[:1] + "." + digits[1:] + "e" + sign + strconv.Itoa(ee) + } + } + if neg { + return "-" + out + } + return out +} + +func sortStrings(s []string) { + // вставками: ключей мало, а сортировка строк в JS-порядке — по UTF-16, что для + // наших ASCII-ключей совпадает с побайтовой + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +// Get2 — Get без признака наличия (nil, если ключа нет); для цепочек разбора. +func (o Object) Get2(key string) any { + v, _ := o.Get(key) + return v +} diff --git a/internal/jsjson/jsjson_test.go b/internal/jsjson/jsjson_test.go new file mode 100644 index 0000000..74420f4 --- /dev/null +++ b/internal/jsjson/jsjson_test.go @@ -0,0 +1,84 @@ +package jsjson + +import ( + "encoding/json" + "os" + "testing" +) + +type fixture struct { + Strings []struct{ In, Out string } `json:"strings"` + Numbers []struct { + In json.RawMessage `json:"in"` + Out string `json:"out"` + Str string `json:"str"` + } `json:"numbers"` + Objects []struct { + In json.RawMessage `json:"in"` + Compact string `json:"compact"` + Indent2 string `json:"indent2"` + } `json:"objects"` +} + +// Эталон — вывод JSON.stringify самого Node (tools/golden/jsjson.mjs). +func TestAgainstNode(t *testing.T) { + raw, err := os.ReadFile("../../testdata/golden/jsjson.json") + if err != nil { + t.Fatal(err) + } + var f fixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + for _, c := range f.Strings { + if got := string(MustMarshal(c.In)); got != c.Out { + t.Errorf("string %q: got %s want %s", c.In, got, c.Out) + } + } + for _, c := range f.Numbers { + v, err := Decode(c.In) + if err != nil { + t.Fatal(err) + } + if s, ok := v.(string); ok { // NaN / Infinity переданы строкой + _ = s + continue + } + x := v.(float64) + if got := Number(x); got != c.Str { + t.Errorf("String(%s): got %s want %s", c.In, got, c.Str) + } + if got := string(MustMarshal(x)); got != c.Out { + t.Errorf("JSON.stringify(%s): got %s want %s", c.In, got, c.Out) + } + } + for _, c := range f.Objects { + v, err := Decode(c.In) + if err != nil { + t.Fatal(err) + } + if got := string(MustMarshal(v)); got != c.Compact { + t.Errorf("compact:\n got %s\nwant %s", got, c.Compact) + } + got, _ := MarshalIndent(v, " ") + if string(got) != c.Indent2 { + t.Errorf("indent:\n got %s\nwant %s", got, c.Indent2) + } + } +} + +func TestStructOrderAndOmit(t *testing.T) { + type S struct { + B string `json:"b"` + A int `json:"a"` + Opt string `json:"opt,omitempty"` + Skip string `json:"-"` + } + if got := string(MustMarshal(S{B: "x", A: 1})); got != `{"b":"x","a":1}` { + t.Fatal(got) + } + o := Object{{"x", 1}, {"gone", Undefined}, {"y", []string(nil)}} + if got := string(MustMarshal(o)); got != `{"x":1,"y":[]}` { + t.Fatal(got) + } +} diff --git a/internal/logx/logx.go b/internal/logx/logx.go new file mode 100644 index 0000000..9aa34f8 --- /dev/null +++ b/internal/logx/logx.go @@ -0,0 +1,60 @@ +// Package logx — простой логгер: человекочитаемые строки в консоль и JSON-строки в +// файл (как winston в Node-версии: console « level: msg», файл {level,message,timestamp,…}). +package logx + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" +) + +type Logger struct { + mu sync.Mutex + out io.Writer + file *os.File +} + +// New пишет в stdout и (если dir не пуст) в dir/app.log. +func New(out io.Writer, dir string) *Logger { + l := &Logger{out: out} + if dir != "" { + if err := os.MkdirAll(dir, 0o755); err == nil { + if f, err := os.OpenFile(filepath.Join(dir, "app.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil { + l.file = f + } + } + } + return l +} + +func (l *Logger) log(level, msg string, kv []any) { + l.mu.Lock() + defer l.mu.Unlock() + ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + if l.out != nil { + fmt.Fprintf(l.out, "%s %s: %s\n", ts, level, msg) + } + if l.file != nil { + m := map[string]any{"level": level, "message": msg, "timestamp": ts} + for i := 0; i+1 < len(kv); i += 2 { + if k, ok := kv[i].(string); ok { + if e, isErr := kv[i+1].(error); isErr { + m[k] = e.Error() + } else { + m[k] = kv[i+1] + } + } + } + b, _ := json.Marshal(m) + l.file.Write(append(b, '\n')) + } +} + +func (l *Logger) Info(msg string, kv ...any) { l.log("info", msg, kv) } +func (l *Logger) Warn(msg string, kv ...any) { l.log("warn", msg, kv) } +func (l *Logger) Error(msg string, kv ...any) { l.log("error", msg, kv) } +func (l *Logger) Debug(msg string, kv ...any) { l.log("debug", msg, kv) } diff --git a/testdata/golden/jsjson.json b/testdata/golden/jsjson.json new file mode 100644 index 0000000..f85bbd6 --- /dev/null +++ b/testdata/golden/jsjson.json @@ -0,0 +1,274 @@ +{ + "strings": [ + { + "in": "", + "out": "\"\"" + }, + { + "in": "plain", + "out": "\"plain\"" + }, + { + "in": "кириллица", + "out": "\"кириллица\"" + }, + { + "in": "emoji 😀", + "out": "\"emoji 😀\"" + }, + { + "in": "", + "out": "\"\"" + }, + { + "in": "a&bd", + "out": "\"a&bd\"" + }, + { + "in": "slash/и\\обратный", + "out": "\"slash/и\\\\обратный\"" + }, + { + "in": "quote\"q", + "out": "\"quote\\\"q\"" + }, + { + "in": "line\nbreak", + "out": "\"line\\nbreak\"" + }, + { + "in": "tab\tcr\r", + "out": "\"tab\\tcr\\r\"" + }, + { + "in": "\b\f", + "out": "\"\\b\\f\"" + }, + { + "in": "\u0000\u0001\u001f", + "out": "\"\\u0000\\u0001\\u001f\"" + }, + { + "in": "

", + "out": "\"

\"" + }, + { + "in": "I2 = ", + "out": "\"I2 = \"" + }, + { + "in": " spaces ", + "out": "\" spaces \"" + }, + { + "in": "ü", + "out": "\"ü\"" + }, + { + "in": "日本語", + "out": "\"日本語\"" + } + ], + "numbers": [ + { + "in": 0, + "out": "0", + "str": "0" + }, + { + "in": 0, + "out": "0", + "str": "0" + }, + { + "in": 1, + "out": "1", + "str": "1" + }, + { + "in": -1, + "out": "-1", + "str": "-1" + }, + { + "in": 1376, + "out": "1376", + "str": "1376" + }, + { + "in": 47619, + "out": "47619", + "str": "47619" + }, + { + "in": 0.1, + "out": "0.1", + "str": "0.1" + }, + { + "in": 0.5, + "out": "0.5", + "str": "0.5" + }, + { + "in": 1.5, + "out": "1.5", + "str": "1.5" + }, + { + "in": 2.5, + "out": "2.5", + "str": "2.5" + }, + { + "in": 100.25, + "out": "100.25", + "str": "100.25" + }, + { + "in": 1e+21, + "out": "1e+21", + "str": "1e+21" + }, + { + "in": 100000000000000000000, + "out": "100000000000000000000", + "str": "100000000000000000000" + }, + { + "in": 123456789012345680000, + "out": "123456789012345680000", + "str": "123456789012345680000" + }, + { + "in": 1e-7, + "out": "1e-7", + "str": "1e-7" + }, + { + "in": 0.000001, + "out": "0.000001", + "str": "0.000001" + }, + { + "in": 0.000001234, + "out": "0.000001234", + "str": "0.000001234" + }, + { + "in": 1.7976931348623157e+308, + "out": "1.7976931348623157e+308", + "str": "1.7976931348623157e+308" + }, + { + "in": 5e-324, + "out": "5e-324", + "str": "5e-324" + }, + { + "in": 3.14159, + "out": "3.14159", + "str": "3.14159" + }, + { + "in": -273.15, + "out": "-273.15", + "str": "-273.15" + }, + { + "in": 9007199254740992, + "out": "9007199254740992", + "str": "9007199254740992" + }, + { + "in": 9007199254740994, + "out": "9007199254740994", + "str": "9007199254740994" + }, + { + "in": 0.30000000000000004, + "out": "0.30000000000000004", + "str": "0.30000000000000004" + }, + { + "in": 250.5, + "out": "250.5", + "str": "250.5" + }, + { + "in": "NaN", + "out": "null", + "str": "NaN" + }, + { + "in": "Infinity", + "out": "null", + "str": "Infinity" + }, + { + "in": "-Infinity", + "out": "null", + "str": "-Infinity" + } + ], + "objects": [ + { + "in": { + "a": 1, + "b": "x", + "c": [ + 1, + 2, + { + "d": null + } + ], + "e": true, + "f": false + }, + "compact": "{\"a\":1,\"b\":\"x\",\"c\":[1,2,{\"d\":null}],\"e\":true,\"f\":false}", + "indent2": "{\n \"a\": 1,\n \"b\": \"x\",\n \"c\": [\n 1,\n 2,\n {\n \"d\": null\n }\n ],\n \"e\": true,\n \"f\": false\n}" + }, + { + "in": { + "rekey": false, + "endpoints": [ + "1.2.3.4:443" + ], + "servers": [ + { + "id": 0, + "name": "VPN & 
", + "awg": { + "Jc": "6", + "I1": "" + } + } + ] + }, + "compact": "{\"rekey\":false,\"endpoints\":[\"1.2.3.4:443\"],\"servers\":[{\"id\":0,\"name\":\"VPN & 
\",\"awg\":{\"Jc\":\"6\",\"I1\":\"\"}}]}", + "indent2": "{\n \"rekey\": false,\n \"endpoints\": [\n \"1.2.3.4:443\"\n ],\n \"servers\": [\n {\n \"id\": 0,\n \"name\": \"VPN & 
\",\n \"awg\": {\n \"Jc\": \"6\",\n \"I1\": \"\"\n }\n }\n ]\n}" + }, + { + "in": { + "empty": {}, + "arr": [], + "nested": { + "x": [] + } + }, + "compact": "{\"empty\":{},\"arr\":[],\"nested\":{\"x\":[]}}", + "indent2": "{\n \"empty\": {},\n \"arr\": [],\n \"nested\": {\n \"x\": []\n }\n}" + }, + { + "in": [], + "compact": "[]", + "indent2": "[]" + }, + { + "in": {}, + "compact": "{}", + "indent2": "{}" + } + ] +} \ No newline at end of file diff --git a/tools/diffbench/Dockerfile b/tools/diffbench/Dockerfile new file mode 100644 index 0000000..d85656f --- /dev/null +++ b/tools/diffbench/Dockerfile @@ -0,0 +1,11 @@ +# Дифференциальный стенд: TS- и Go-версии awg-ctrl против одних и тех же заглушек. +FROM node:20-bookworm +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends openssl sqlite3 >/dev/null && rm -rf /var/lib/apt/lists/* +COPY tools/diffbench/stubs/stub.mjs /opt/stub/stub.mjs +RUN for c in awg awg-quick ip tc nft modinfo; do printf '#!/bin/sh\nexec node /opt/stub/stub.mjs %s "$@"\n' "$c" > /usr/local/bin/$c; chmod +x /usr/local/bin/$c; done +# TS-эталон: тот же код, что в репозитории, со своими node_modules под linux +COPY awg-ctrl/package.json /ts/awg-ctrl/package.json +RUN cd /ts/awg-ctrl && npm install --no-audit --no-fund --loglevel=error +COPY awg-ctrl/index.ts awg-ctrl/shaping.ts awg-ctrl/tsconfig.json /ts/awg-ctrl/ +COPY tools/diffbench/setup.sh tools/diffbench/run.sh tools/diffbench/scenario-ctrl.mjs tools/diffbench/collect.mjs /bench/ +WORKDIR /bench diff --git a/tools/diffbench/collect.mjs b/tools/diffbench/collect.mjs new file mode 100644 index 0000000..b12847a --- /dev/null +++ b/tools/diffbench/collect.mjs @@ -0,0 +1,20 @@ +// Состояние после сценария: conf, файлы каталога, строки баз, журнал и состояние заглушек. +import fs from "fs"; +import { execSync } from "child_process"; +const out = process.argv[2]; +const t = JSON.parse(fs.readFileSync(out, "utf8")); +const D = "/etc/amnezia/amneziawg"; +const q = sql => JSON.parse(execSync(`sqlite3 -json ${D}/users.db "${sql}"`).toString() || "[]"); +t.state = { + conf: fs.readFileSync(`${D}/awg1.conf`, "utf8"), + confMode: (fs.statSync(`${D}/awg1.conf`).mode & 0o777).toString(8), + files: fs.readdirSync(D).map(f => f.replace(/\d{4}-\d\d-\d\dT[\d-]+Z/, "")).sort(), + users: q("select * from users order by rowid"), + peers: q("select * from peers order by rowid"), + config: q("select * from config order by key"), + schema: execSync(`sqlite3 ${D}/users.db .schema`).toString(), + stubLog: fs.readFileSync("/var/lib/stub/log.jsonl", "utf8").split("\n").filter(Boolean).map(l => JSON.parse(l)), + stubState: JSON.parse(fs.readFileSync("/var/lib/stub/state.json", "utf8")), +}; +fs.writeFileSync(out, JSON.stringify(t, null, 1)); +console.log(`collect: done`); diff --git a/tools/diffbench/run.sh b/tools/diffbench/run.sh new file mode 100755 index 0000000..1894187 --- /dev/null +++ b/tools/diffbench/run.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# run.sh <имя-прогона>; окружение: GEN, WITH_I1, STUB_MODULE +set -e +IMPL="$1"; NAME="$2" +./setup.sh +export PORT=3005 SERVER_IP=203.0.113.7 SERVER_PORT=47619 SERVER_NAME=VPN \ + INTERNAL_AUTH_PUB_FILE=/etc/amnezia/amneziawg/internal_auth_public.key \ + SHAPE_SAMPLE_S=3600 AWG_CLI=/nonexistent/awg-ctrl +cd /ts/awg-ctrl +if [ "$IMPL" = "ts" ]; then + node node_modules/.bin/tsx index.ts > /run/bench/ctrl.log 2>&1 & +else + /go/awg-control ctrl > /run/bench/ctrl.log 2>&1 & +fi +PID=$! +i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $i -gt 100 ] && { cat /run/bench/ctrl.log; exit 1; }; sleep 0.1; done +sleep 1 # стартовый apply шейпинга асинхронный — даём ему отработать до сценария +cd /bench +node scenario-ctrl.mjs "/out/$NAME.json" +kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true +node collect.mjs "/out/$NAME.json" +cp /run/bench/ctrl.log "/out/$NAME.log" diff --git a/tools/diffbench/scenario-ctrl.mjs b/tools/diffbench/scenario-ctrl.mjs new file mode 100644 index 0000000..0e4dbfb --- /dev/null +++ b/tools/diffbench/scenario-ctrl.mjs @@ -0,0 +1,112 @@ +// Сценарий для awg-ctrl: одинаковая последовательность запросов к любой реализации. +// Пишет транскрипт (запрос → статус, тип, тело) в JSON; collect.mjs дописывает состояние. +import fs from "fs"; +import crypto from "crypto"; +import { execSync } from "child_process"; + +const BASE = "http://127.0.0.1:3005"; +const KEY = crypto.createPrivateKey(fs.readFileSync("/etc/amnezia/amneziawg/internal_auth_private.key")); +const out = process.argv[2]; + +function token(iss = "bench") { + const h = Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT" })).toString("base64url"); + const t = Math.floor(Date.now() / 1000); + const p = Buffer.from(JSON.stringify({ iss, iat: t, exp: t + 60 })).toString("base64url"); + return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), KEY).toString("base64url")}`; +} +const key = s => crypto.createHash("sha256").update(s).digest("base64"); +const url64 = k => Buffer.from(k, "base64").toString("base64url"); +const P1 = key("dev1"), P2 = key("dev2"), P3 = key("dev3"), GHOST = key("ghost"); + +const steps = []; +async function call(name, method, path, body, { auth = true, raw, headers = {} } = {}) { + const h = { ...headers }; + if (auth) h.authorization = `Bearer ${typeof auth === "string" ? auth : token()}`; + let data; + if (raw !== undefined) { h["content-type"] = "application/json"; data = raw; } + else if (body !== undefined) { h["content-type"] = "application/json"; data = JSON.stringify(body); } + const r = await fetch(BASE + path, { method, headers: h, body: data }); + const ctype = (r.headers.get("content-type") ?? "").split(";")[0]; + const text = await r.text(); + let parsed = text; + if (ctype === "application/json") { try { parsed = JSON.parse(text); } catch {} } + steps.push({ name, method, path, status: r.status, ctype, body: parsed, text: ctype === "application/json" ? text : undefined }); + return parsed; +} + +const gen = process.env.GEN ?? "2"; + +await call("health", "GET", "/health", undefined, { auth: false }); +await call("no token", "GET", "/api/users", undefined, { auth: false }); +await call("bad token", "GET", "/api/users", undefined, { auth: "a.b.c" }); +await call("expired token", "GET", "/api/users", undefined, { auth: (() => { + const h = Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT" })).toString("base64url"); + const p = Buffer.from(JSON.stringify({ iss: "x", iat: 1, exp: 2 })).toString("base64url"); + return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), KEY).toString("base64url")}`; +})() }); +await call("users empty", "GET", "/api/users"); +await call("add alice", "POST", "/api/users", { name: "alice" }); +await call("add bob", "POST", "/api/users", { name: "bob" }); +await call("add alice again", "POST", "/api/users", { name: "alice" }); +await call("add bad name", "POST", "/api/users", { name: "a b" }); +await call("add long name", "POST", "/api/users", { name: "x".repeat(33) }); +await call("add no body", "POST", "/api/users"); +await call("add too big", "POST", "/api/users", undefined, { raw: JSON.stringify({ name: "y", pad: "z".repeat(2000) }) }); +await call("add broken json", "POST", "/api/users", undefined, { raw: "{\"name\":" }); +await call("users", "GET", "/api/users"); +await call("stats", "GET", "/api/users/stats"); +await call("fetch alice", "POST", "/api/users/alice"); +await call("fetch nobody", "POST", "/api/users/nobody"); +await call("fetch bad name", "POST", "/api/users/a%20b"); +await call("add peer", "POST", "/api/peers", { pub_key: P1, owner: "abcdef123456/m1/d1" }); +await call("add peer dup", "POST", "/api/peers", { pub_key: P1, owner: "abcdef123456/m1/d1" }); +await call("add peer bad owner", "POST", "/api/peers", { pub_key: P2, owner: "bad owner!" }); +await call("add peer bad key", "POST", "/api/peers", { pub_key: "nope", owner: "abcdef123456/m1/d2" }); +await call("add peer legacy", "POST", "/api/peers", { pub_key: P3, owner: "m7/d9" }); +await call("peers", "GET", "/api/peers"); +await call("rekey peer", "PUT", `/api/peers/${url64(P1)}`, { pub_key: P2 }); +await call("rekey missing", "PUT", `/api/peers/${url64(key("missing"))}`, { pub_key: key("other") }); +await call("rekey to taken", "PUT", `/api/peers/${url64(P2)}`, { pub_key: P3 }); +await call("rotate psk", "POST", `/api/peers/${url64(P2)}/psk`); +await call("rotate psk missing", "POST", `/api/peers/${url64(key("missing"))}/psk`); +await call("profile", "GET", "/api/profile"); +await call("add peer 4", "POST", "/api/peers", { pub_key: key("dev4"), owner: "abcdef123456/m1/d4" }); +await call("sync bad owner", "PUT", "/api/peers/sync", { owner: "BAD", keep: [] }); +await call("sync bad keep", "PUT", "/api/peers/sync", { owner: "abcdef123456", keep: "x" }); +await call("sync keep P2 + legacy P3", "PUT", "/api/peers/sync", { owner: "abcdef123456", keep: [P2, P3] }); +await call("peers after sync", "GET", "/api/peers"); +execSync(`awg set awg1 peer ${GHOST} allowed-ips 10.9.9.9/32`); +await call("audit", "GET", "/api/audit"); +await call("audit clean bad", "POST", "/api/audit/clean", { pub_keys: "x" }); +await call("audit clean", "POST", "/api/audit/clean", { pub_keys: [GHOST, P2] }); +await call("delete peer", "DELETE", `/api/peers/${url64(P2)}`); +await call("delete peer missing", "DELETE", `/api/peers/${url64(P2)}`); +await call("delete bob", "DELETE", "/api/users/bob"); +await call("delete nobody", "DELETE", "/api/users/nobody"); +await call("shaping", "GET", "/api/shaping"); +await call("shaping bad", "PUT", "/api/shaping", { enabled: "yes", auto: true }); +await call("shaping bad mbit", "PUT", "/api/shaping", { enabled: true, auto: true, downMbit: 1.5, upMbit: 10 }); +await call("shaping missing width", "PUT", "/api/shaping", { enabled: true, auto: true, downMbit: 0, upMbit: 10 }); +await call("shaping on", "PUT", "/api/shaping", { enabled: true, auto: false, downMbit: 285, upMbit: 280 }); +await call("shaping get on", "GET", "/api/shaping"); +await call("stats priority", "GET", "/api/users/stats"); +await call("metrics", "GET", "/api/metrics"); +await call("shaping off", "PUT", "/api/shaping", { enabled: false, auto: true, downMbit: 0, upMbit: 0 }); +await call("monitor job", "GET", "/api/monitor/job"); +await call("monitor install (no cli)", "POST", "/api/monitor/install"); +await call("awg status", "GET", "/awg/status"); +await call("awg start (already up)", "POST", "/awg/start"); +await call("reissue", "POST", "/api/users/reissue"); +await call("users after reissue", "GET", "/api/users"); +await call("awg restart", "POST", "/awg/restart"); +await call("health after restart", "GET", "/health", undefined, { auth: false }); +await call("upgrade", "POST", "/awg/upgrade"); +await call("profile after upgrade", "GET", "/api/profile"); +await call("users after upgrade", "GET", "/api/users"); +await call("fetch alice after upgrade", "POST", "/api/users/alice"); +await call("health final", "GET", "/health", undefined, { auth: false }); +await call("unknown route", "GET", "/api/nope"); +await call("wrong method", "PATCH", "/api/users"); + +fs.writeFileSync(out, JSON.stringify({ gen, steps }, null, 1)); +console.log(`scenario: ${steps.length} steps → ${out}`); diff --git a/tools/diffbench/setup.sh b/tools/diffbench/setup.sh new file mode 100755 index 0000000..6306194 --- /dev/null +++ b/tools/diffbench/setup.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Чистое состояние «сервера»: awg1.conf нужного поколения, ключи, пустые базы, заглушки. +# GEN=2|3.1, WITH_I1=1 — добавить I1 в conf (install.sh его не пишет). +set -e +GEN="${GEN:-2}" +D=/etc/amnezia/amneziawg +rm -rf /etc/amnezia /var/lib/stub /run/bench +mkdir -p "$D" /var/lib/stub /run/bench +node -e 'const c=require("crypto");process.stdout.write(c.createHash("sha256").update("server").digest("base64"))' > /etc/amnezia/server_private.key +chmod 600 /etc/amnezia/server_private.key +node -e 'const c=require("crypto");const p=require("fs").readFileSync("/etc/amnezia/server_private.key","utf8").trim();process.stdout.write(c.createHash("sha256").update("pub:"+p).digest("base64")+"\n")' > "$D/server_public.key" +openssl genpkey -algorithm ed25519 -out "$D/internal_auth_private.key" 2>/dev/null +openssl pkey -in "$D/internal_auth_private.key" -pubout -out "$D/internal_auth_public.key" +PRIV=$(cat /etc/amnezia/server_private.key) +cat > "$D/awg1.conf" <' >> "$D/awg1.conf" +fi +if [ "$GEN" = "3.1" ]; then +cat >> "$D/awg1.conf" < /var/lib/stub/state.json +: > /var/lib/stub/log.jsonl +awg-quick up awg1 +: > /var/lib/stub/log.jsonl diff --git a/tools/diffbench/stubs/stub.mjs b/tools/diffbench/stubs/stub.mjs new file mode 100644 index 0000000..f092c43 --- /dev/null +++ b/tools/diffbench/stubs/stub.mjs @@ -0,0 +1,143 @@ +// Заглушки системных команд для дифференциального стенда: awg, awg-quick, ip, tc, nft, modinfo. +// Детерминированные ключи (счётчик), состояние интерфейса в JSON, журнал каждого вызова. +// Одинаково ведут себя для TS- и Go-версии — сравнивается то, что версии с ними делают. +import fs from "fs"; +import crypto from "crypto"; + +const DIR = "/var/lib/stub"; +const STATE = `${DIR}/state.json`; +const LOG = `${DIR}/log.jsonl`; +const [, , cmd, ...args] = process.argv; + +const load = () => { try { return JSON.parse(fs.readFileSync(STATE, "utf8")); } catch { return { up: false, n: 0, peers: {} }; } }; +const save = s => fs.writeFileSync(STATE, JSON.stringify(s, null, 1)); +const b64 = s => crypto.createHash("sha256").update(s).digest("base64"); +const pubOf = priv => b64("pub:" + priv.trim()); +const readStdin = () => { try { return fs.readFileSync(0, "utf8"); } catch { return ""; } }; + +let stdin = ""; +const needsStdin = (cmd === "awg" && args[0] === "pubkey") || (cmd === "nft" && args.includes("-")); +if (needsStdin) stdin = readStdin(); + +const entry = { cmd, args }; +if (stdin) entry.stdin = stdin; +// файлы, которые передают по пути (PSK, syncconf), пишем в журнал содержимым: имя у них со временем +const fileArgs = []; +for (let i = 0; i < args.length; i++) { + if (["preshared-key", "private-key"].includes(args[i]) || (cmd === "awg" && args[0] === "syncconf" && i === 2)) { + try { fileArgs.push(fs.readFileSync(args[i + (args[0] === "syncconf" ? 0 : 1)], "utf8")); } catch { fileArgs.push(""); } + } +} +if (fileArgs.length) entry.files = fileArgs; +fs.appendFileSync(LOG, JSON.stringify(entry) + "\n"); + +const out = s => process.stdout.write(s); +const st = load(); + +function parsePeers(text) { + const peers = {}; + for (const block of text.split(/^\[Peer\]/m).slice(1)) { + const g = k => block.match(new RegExp(`^\\s*${k}\\s*=\\s*(.*)$`, "m"))?.[1]?.trim(); + const pub = g("PublicKey"); + if (pub) peers[pub] = { psk: g("PresharedKey") ?? "", allowed: g("AllowedIPs") ?? "", ka: g("PersistentKeepalive") ?? "" }; + } + return peers; +} + +function serverPub() { + try { return pubOf(fs.readFileSync("/etc/amnezia/server_private.key", "utf8")); } catch { return "(none)"; } +} + +switch (cmd) { + case "awg": { + const [sub, iface, ...rest] = args; + if (sub === "--version") { out("awg-tools v1.0.20210914 - https://amnezia.org\n"); break; } + if (sub === "genkey") { st.n++; save(st); out(b64("priv" + st.n) + "\n"); break; } + if (sub === "genpsk") { st.n++; save(st); out(b64("psk" + st.n) + "\n"); break; } + if (sub === "pubkey") { out(pubOf(stdin) + "\n"); break; } + if (sub === "show") { + if (!st.up) { process.stderr.write(`Unable to access interface: No such device\n`); process.exit(1); } + const pubs = Object.keys(st.peers); + if (rest[0] === "dump") { + out(`(hidden)\t${serverPub()}\t47619\toff\n`); + for (const p of pubs) out(`${p}\t(hidden)\t(none)\t${st.peers[p].allowed}\t0\t0\t0\t${st.peers[p].ka || "off"}\n`); + } else if (rest[0] === "peers") { + for (const p of pubs) out(p + "\n"); + } else { + out(`interface: ${iface}\n public key: ${serverPub()}\n private key: (hidden)\n listening port: 47619\n`); + for (const p of pubs) out(`\npeer: ${p}\n preshared key: (hidden)\n allowed ips: ${st.peers[p].allowed}\n`); + } + break; + } + if (sub === "set") { + if (!st.up) { process.stderr.write("Unable to modify interface: No such device\n"); process.exit(1); } + const i = rest.indexOf("peer"); + if (i >= 0) { + const pub = rest[i + 1]; + if (rest.includes("remove")) { delete st.peers[pub]; } + else { + const p = st.peers[pub] ?? { psk: "", allowed: "", ka: "" }; + const k = rest.indexOf("allowed-ips"); if (k >= 0) p.allowed = rest[k + 1]; + const a = rest.indexOf("persistent-keepalive"); if (a >= 0) p.ka = rest[a + 1]; + const s = rest.indexOf("preshared-key"); if (s >= 0) { try { p.psk = fs.readFileSync(rest[s + 1], "utf8").trim(); } catch {} } + st.peers[pub] = p; + } + } + save(st); + break; + } + if (sub === "syncconf") { + if (!st.up) { process.stderr.write("Unable to modify interface: No such device\n"); process.exit(1); } + st.peers = parsePeers(fs.readFileSync(rest[0], "utf8")); + save(st); + break; + } + process.stderr.write(`stub awg: unsupported ${args.join(" ")}\n`); process.exit(1); + } + case "awg-quick": { + const [sub] = args; + if (sub === "up") { + if (st.up) { process.stderr.write("awg-quick: `awg1' already exists\n"); process.exit(1); } + if (process.env.STUB_UP_FAIL === "1") { process.stderr.write("RTNETLINK answers: Invalid argument\n"); process.exit(1); } + st.up = true; + try { st.peers = parsePeers(fs.readFileSync("/etc/amnezia/amneziawg/awg1.conf", "utf8")); } catch { st.peers = {}; } + } else if (sub === "down") { + if (!st.up) { process.stderr.write("awg-quick: `awg1' is not a WireGuard interface\n"); process.exit(1); } + st.up = false; st.peers = {}; + } + save(st); + break; + } + case "ip": { + const a = args.join(" "); + if (a === "route show default") out("default via 203.0.113.1 dev eth0 proto static\n"); + else if (a.startsWith("addr show")) out("2: eth0: mtu 1500\n inet 203.0.113.7/24 brd 203.0.113.255 scope global eth0\n"); + else if (a === "link show ifb-awg") { if (!st.ifb) process.exit(1); out("5: ifb-awg: mtu 1500\n"); } + else if (a.startsWith("link add ifb-awg")) { st.ifb = true; save(st); } + else if (a.startsWith("link del ifb-awg")) { if (!st.ifb) process.exit(1); st.ifb = false; save(st); } + break; + } + case "tc": { + const a = args.join(" "); + st.tc ??= {}; + if (a.startsWith("qdisc replace dev")) { st.tc[args[3] + ":root"] = a; save(st); } + else if (a.startsWith("qdisc add dev") && a.includes("ingress")) { st.tc[args[3] + ":ingress"] = a; save(st); } + else if (a.startsWith("filter add dev")) { st.tc[args[3] + ":filter"] = a; save(st); } + else if (a.startsWith("qdisc del dev")) { + const key = args[3] + ":" + (args[4] === "root" ? "root" : "ingress"); + if (!st.tc[key]) { process.stderr.write("Error: Cannot delete qdisc with handle of zero.\n"); process.exit(2); } + delete st.tc[key]; if (args[4] === "ingress") delete st.tc[args[3] + ":filter"]; save(st); + } + else if (a.startsWith("filter show dev")) { if (st.tc[args[3] + ":filter"]) out("filter parent ffff: protocol all pref 1 matchall\n\taction order 1: mirred (Egress Redirect to device ifb-awg) stolen\n"); } + else if (args[0] === "-s" && args[1] === "-j") { const dev = args[5]; out(st.tc[dev + ":root"] ? `[{"kind":"cake","handle":"8001:","root":true,"bytes":1000,"packets":10,"drops":2,"overlimits":0,"requeues":0,"backlog":0,"qlen":0}]\n` : "[]\n"); } + else if (a.startsWith("qdisc show dev")) { if (st.tc[args[3] + ":root"]) out(`qdisc cake 8001: root refcnt 2 bandwidth 285Mbit\n`); } + break; + } + case "nft": { + if (args[0] === "delete") { if (!st.nft) { process.stderr.write("Error: No such file or directory\n"); process.exit(1); } st.nft = false; save(st); } + else if (args.includes("-")) { st.nft = true; save(st); } + break; + } + case "modinfo": out((process.env.STUB_MODULE ?? "1.0.0") + "\n"); break; + default: process.stderr.write(`stub: unknown ${cmd}\n`); process.exit(127); +} diff --git a/tools/golden/jsjson.mjs b/tools/golden/jsjson.mjs new file mode 100644 index 0000000..0f00f55 --- /dev/null +++ b/tools/golden/jsjson.mjs @@ -0,0 +1,23 @@ +// Эталон для internal/jsjson: вывод JSON.stringify самого Node на каверзных значениях. +// Запуск: node tools/golden/jsjson.mjs > testdata/golden/jsjson.json +const strings = [ + "", "plain", "кириллица", "emoji 😀", "", "a&bd", "slash/и\\обратный", + "quote\"q", "line\nbreak", "tab\tcr\r", "\b\f", "\u0000\u0001\u001f\u007f", "

", + "I2 = ", " spaces ", "ü", "日本語", +]; +const numbers = [0, -0, 1, -1, 1376, 47619, 0.1, 0.5, 1.5, 2.5, 100.25, 1e21, 1e20, 123456789012345680000, + 1e-7, 1e-6, 0.000001234, 1.7976931348623157e308, 5e-324, 3.14159, -273.15, 2 ** 53, 2 ** 53 + 2, 0.1 + 0.2, 250.5, + NaN, Infinity, -Infinity]; +const objects = [ + { a: 1, b: "x", c: [1, 2, { d: null }], e: true, f: false }, + { rekey: false, endpoints: ["1.2.3.4:443"], servers: [{ id: 0, name: "VPN & 
", awg: { Jc: "6", I1: "" } }] }, + { empty: {}, arr: [], nested: { x: [] } }, + [], + {}, +]; +const out = { + strings: strings.map(s => ({ in: s, out: JSON.stringify(s) })), + numbers: numbers.map(n => ({ in: Number.isFinite(n) ? n : String(n), out: JSON.stringify(n), str: String(n) })), + objects: objects.map(o => ({ in: o, compact: JSON.stringify(o), indent2: JSON.stringify(o, null, 2) })), +}; +process.stdout.write(JSON.stringify(out, null, 1));