Start the Go rewrite: jsjson, awg-ctrl port, differential bench
ci / check (push) Successful in 6m6s

- 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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 15:27:05 +03:00
co-authored by Claude Sonnet 5.5
parent e9df363dd2
commit 18106115f7
28 changed files with 4960 additions and 0 deletions
+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
}