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

- internal/jsjson: JSON.stringify-compatible encoder and order-preserving decoder,
  tested against fixtures produced by Node itself
- internal/ctrl: awg-ctrl in Go (params, vpn:// keys, users, peers, sync, 3.1
  upgrade, CAKE shaping, metrics, monitor job, Express-compatible HTTP layer)
- internal/iauth, internal/logx: internal Ed25519 tokens and logging
- tools/diffbench: Docker bench with stubbed awg/ip/tc/nft that runs one scenario
  against the TS and Go implementations for comparison
This commit is contained in:
2026-10-14 13:27:24 +00:00
parent 53d5a08bbd
commit 59e076c1de
28 changed files with 4960 additions and 0 deletions
+31
View File
@@ -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 <ctrl|ui|agent|start|stop|restart|status|…>")
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)
}
}
+17
View File
@@ -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
)
+22
View File
@@ -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=
+455
View File
@@ -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/<pid>/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)
}
+233
View File
@@ -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, &notnull, &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("Подсеть заполнена")
}
+118
View File
@@ -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
+236
View File
@@ -0,0 +1,236 @@
package ctrl
import (
"fmt"
"io"
"mime"
"net/http"
"regexp"
"strings"
"unicode"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Минимальный роутер с поведением Express 4, на которое опираются клиенты:
// - тело JSON разбирается ДО авторизации и маршрутизации (битый JSON без токена → 400, не 401);
// - ошибки разбора и неизвестные пути отвечают HTML, как finalhandler Express;
// - пути без учёта регистра и с необязательным «/» в конце; :param декодируется.
// Первое совпавшее объявление выигрывает — порядок ниже важен («reissue» до «:name», «sync» до «:pub»).
type rctx struct {
w http.ResponseWriter
r *http.Request
s *Server
params map[string]string
body any // jsjson.Object, []any или nil (= {})
}
type result struct {
status int
v any
}
type route struct {
method string
segs []string
mw []func(*rctx) error
lock bool
h func(*rctx) (result, error)
}
const (
bodyLimitSmall = 1024
bodyLimitBig = 512 * 1024
)
func writeJSON(w http.ResponseWriter, status int, v any) {
b, err := jsjson.Marshal(v)
if err != nil {
status, b = 500, []byte(`{"error":"Internal server error"}`)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(b)
}
func writeHTMLError(w http.ResponseWriter, status int, text string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>%s</pre>\n</body>\n</html>\n", text)
}
// readBody — express.json({limit}): только application/json, объект или массив.
func readBody(r *http.Request, limit int64) (any, int, string) {
mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mt != "application/json" || r.ContentLength == 0 && r.Body == http.NoBody {
return nil, 0, ""
}
if r.ContentLength > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
if err != nil {
return nil, 400, "BadRequestError: request aborted"
}
if int64(len(raw)) > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
trimmed := strings.TrimLeftFunc(string(raw), unicode.IsSpace)
if trimmed == "" {
return nil, 0, ""
}
if trimmed[0] != '{' && trimmed[0] != '[' {
return nil, 400, "SyntaxError: Unexpected token in JSON"
}
v, err := jsjson.Decode(raw)
if err != nil {
return nil, 400, "SyntaxError: " + err.Error()
}
return v, 0, ""
}
func (c *rctx) obj() jsjson.Object {
o, _ := c.body.(jsjson.Object)
return o
}
func match(rt route, method string, segs []string) map[string]string {
if rt.method != method || len(rt.segs) != len(segs) {
return nil
}
params := map[string]string{}
for i, p := range rt.segs {
if strings.HasPrefix(p, ":") {
v, ok := unescape(segs[i])
if !ok {
return nil
}
params[p[1:]] = v
} else if !strings.EqualFold(p, segs[i]) {
return nil
}
}
return params
}
func unescape(s string) (string, bool) {
if !strings.Contains(s, "%") {
return s, true
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+2 < len(s)+0 && isHex(s[i+1]) && isHex(s[i+2]) {
b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))
i += 2
} else if s[i] == '%' {
return "", false
} else {
b.WriteByte(s[i])
}
}
return b.String(), true
}
func isHex(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
func unhex(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
}
return c - 'A' + 10
}
func (s *Server) handler() http.Handler {
routes := s.routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Тело разбирается первым и для всех путей; лимит выбирает точный путь.
limit := int64(bodyLimitSmall)
p := strings.TrimSuffix(r.URL.EscapedPath(), "/")
if strings.EqualFold(p, "/api/peers/sync") || strings.EqualFold(p, "/api/audit/clean") {
limit = bodyLimitBig
}
body, status, msg := readBody(r, limit)
if status != 0 {
writeHTMLError(w, status, msg)
return
}
segs := strings.Split(strings.Trim(r.URL.EscapedPath(), "/"), "/")
for _, rt := range routes {
params := match(rt, r.Method, segs)
if params == nil {
continue
}
c := &rctx{w: w, r: r, s: s, params: params, body: body}
s.serve(c, rt)
return
}
writeHTMLError(w, 404, "Cannot "+r.Method+" "+r.URL.Path)
})
}
func (s *Server) serve(c *rctx, rt route) {
defer func() {
if p := recover(); p != nil {
s.log.Error("handler panic", "error", fmt.Sprint(p))
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
}()
for _, mw := range rt.mw {
if err := mw(c); err != nil {
s.fail(c, err)
return
}
}
if rt.lock {
s.mu.Lock()
defer s.mu.Unlock()
}
res, err := rt.h(c)
if err != nil {
s.fail(c, err)
return
}
writeJSON(c.w, res.status, res.v)
}
func (s *Server) fail(c *rctx, err error) {
if he, ok := err.(*HTTPError); ok {
writeJSON(c.w, he.Status, jsjson.Object{{Key: "error", Val: he.Msg}})
return
}
s.log.Error("handler error", "error", err)
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
// ───────── middleware ─────────
func (s *Server) auth(c *rctx) error {
h := c.r.Header.Get("Authorization")
tok := ""
if strings.HasPrefix(h, "Bearer ") {
tok = h[7:]
}
if tok == "" || !iauth.Verify(s.pub, tok, nowFn()) {
return httpErr(401, "Неверная авторизация")
}
return nil
}
var reName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`)
func validateName(c *rctx) error {
name, ok := c.params["name"]
if !ok {
name, _ = c.obj().Get2("name").(string)
}
if name == "" || !reName.MatchString(name) {
return httpErr(400, "Имя: буквы, цифры, _ и -, до 32 символов")
}
return nil
}
+183
View File
@@ -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
}
+104
View File
@@ -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
}
+605
View File
@@ -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: панель присылает полный список ключей, которые ей нужны на этом сервере,
// — всё остальное из её области («<panel>/…») снимается. Удаляем только по такому явному
// списку, никогда по времени или неактивности: панель не на связи — ничего не происходит.
// Пиры без области (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
+152
View File
@@ -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: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
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
}
+392
View File
@@ -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)
}),
}
}
+186
View File
@@ -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
}
+605
View File
@@ -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,
}
}
+218
View File
@@ -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
}
+122
View File
@@ -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())
}
+86
View File
@@ -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
}
}
+393
View File
@@ -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
}
+84
View File
@@ -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)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package logx — простой логгер: человекочитаемые строки в консоль и JSON-строки в
// файл (как winston в Node-версии: console «<ts> 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) }
+274
View File
@@ -0,0 +1,274 @@
{
"strings": [
{
"in": "",
"out": "\"\""
},
{
"in": "plain",
"out": "\"plain\""
},
{
"in": "кириллица",
"out": "\"кириллица\""
},
{
"in": "emoji 😀",
"out": "\"emoji 😀\""
},
{
"in": "<r 2><b 0x85>",
"out": "\"<r 2><b 0x85>\""
},
{
"in": "a&b<c>d",
"out": "\"a&b<c>d\""
},
{
"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 & <co>
",
"awg": {
"Jc": "6",
"I1": "<r 2>"
}
}
]
},
"compact": "{\"rekey\":false,\"endpoints\":[\"1.2.3.4:443\"],\"servers\":[{\"id\":0,\"name\":\"VPN & <co>
\",\"awg\":{\"Jc\":\"6\",\"I1\":\"<r 2>\"}}]}",
"indent2": "{\n \"rekey\": false,\n \"endpoints\": [\n \"1.2.3.4:443\"\n ],\n \"servers\": [\n {\n \"id\": 0,\n \"name\": \"VPN & <co>
\",\n \"awg\": {\n \"Jc\": \"6\",\n \"I1\": \"<r 2>\"\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": "{}"
}
]
}
+11
View File
@@ -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
+20
View File
@@ -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/, "<ts>")).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`);
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# run.sh <ts|go> <имя-прогона>; окружение: 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"
+112
View File
@@ -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}`);
+53
View File
@@ -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" <<CONF
[Interface]
Address = 10.9.0.1/16
MTU = 1376
PostUp = awg set awg1 private-key /etc/amnezia/server_private.key; iptables -A FORWARD -i awg1 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PreDown = iptables -D FORWARD -i awg1 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
ListenPort = 47619
PrivateKey = ${PRIV}
Jc = 6
Jmin = 10
Jmax = 50
S1 = 90
S2 = 45
S3 = 37
S4 = ${S4:-14}
H1 = 1224800044-2116730834
H2 = 2122053282-2133204808
H3 = 2133604274-2140756116
H4 = 2143656228-2147444225
CONF
if [ "${WITH_I1:-0}" = "1" ]; then
echo 'I1 = <r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>' >> "$D/awg1.conf"
fi
if [ "$GEN" = "3.1" ]; then
cat >> "$D/awg1.conf" <<CONF
HeaderProtectionKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
ContentPaddingAddition = 10-100
RekeyAfterTime = 100-120
RekeyTimeout = 3-7
RejectAfterTime = 150-180
KeepaliveTimeout = 5-15
MaxHandshakeAttempts = 15-20
CONF
fi
chmod 600 "$D/awg1.conf"
echo '{"up":false,"n":0,"peers":{}}' > /var/lib/stub/state.json
: > /var/lib/stub/log.jsonl
awg-quick up awg1
: > /var/lib/stub/log.jsonl
+143
View File
@@ -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("<unreadable>"); }
}
}
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: <BROADCAST,MULTICAST,UP> 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: <BROADCAST,NOARP,UP> 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);
}
+23
View File
@@ -0,0 +1,23 @@
// Эталон для internal/jsjson: вывод JSON.stringify самого Node на каверзных значениях.
// Запуск: node tools/golden/jsjson.mjs > testdata/golden/jsjson.json
const strings = [
"", "plain", "кириллица", "emoji 😀", "<r 2><b 0x85>", "a&b<c>d", "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 & <co>
", awg: { Jc: "6", I1: "<r 2>" } }] },
{ 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));