diff --git a/cmd/awg-control/main.go b/cmd/awg-control/main.go index bfe273d..401eebf 100644 --- a/cmd/awg-control/main.go +++ b/cmd/awg-control/main.go @@ -10,6 +10,7 @@ import ( "github.com/maeneko/forgetting/internal/agent" "github.com/maeneko/forgetting/internal/ctrl" + "github.com/maeneko/forgetting/internal/ui" ) func main() { @@ -28,6 +29,16 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + case "ui": + a, err := ui.New() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := a.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } case "agent": c, err := agent.ConfigFromEnv() if err != nil { diff --git a/internal/ui/audit.go b/internal/ui/audit.go new file mode 100644 index 0000000..3c68efd --- /dev/null +++ b/internal/ui/audit.go @@ -0,0 +1,512 @@ +package ui + +// Аудит серверов (порт части awg-ui/sub.ts). Периодически core обходит все серверы и ищет +// «мёртвых» пиров: живы на сервере, хотя панель про них не знает. Сообщает и — если включена +// очистка или оператор нажал кнопку — снимает подтверждённых: мёртвых два прохода подряд. +// ghosts — есть в ядре (awg show), но неизвестны awg-ctrl; +// orphans — есть у awg-ctrl в области этой панели, но панели не нужны (sync их не снял); +// key — мастер-ключ пира, если он отозван или удалён. +// Пиры чужой области в отчёт не попадают — считаются в foreign. + +import ( + "regexp" + "strconv" + "strings" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +type auditPeer struct { + PubKey string + Owner any // только у orphans + Key any // только у orphans + Online any + LastHandshake any + SeenBefore *bool + orphan bool +} + +func (p *auditPeer) json() jsjson.Object { + o := jsjson.Object{{Key: "pub_key", Val: p.PubKey}} + if p.orphan { + o = append(o, jsjson.KV{Key: "owner", Val: p.Owner}) + } + o = append(o, jsjson.KV{Key: "online", Val: p.Online}, jsjson.KV{Key: "lastHandshake", Val: p.LastHandshake}) + if p.orphan { + o = append(o, jsjson.KV{Key: "key", Val: p.Key}) + } + if p.SeenBefore != nil { + o = append(o, jsjson.KV{Key: "seen_before", Val: *p.SeenBefore}) + } + return o +} + +type auditEntry struct { + ServerID int + Status string // ok | offline | error + GhostsChecked bool // false — awg-ctrl старше /api/audit, «призраков» не видно + Ghosts []*auditPeer + Orphans []*auditPeer + Foreign int + Blocked *int // очистка отказалась снимать столько пиров разом (предохранитель) +} + +func peersJSON(ps []*auditPeer) []any { + out := []any{} + for _, p := range ps { + out = append(out, p.json()) + } + return out +} + +func (e *auditEntry) json() jsjson.Object { + o := jsjson.Object{{Key: "server_id", Val: e.ServerID}, {Key: "status", Val: e.Status}, {Key: "ghosts_checked", Val: e.GhostsChecked}, + {Key: "ghosts", Val: peersJSON(e.Ghosts)}, {Key: "orphans", Val: peersJSON(e.Orphans)}, {Key: "foreign", Val: e.Foreign}} + if e.Blocked != nil { + o = append(o, jsjson.KV{Key: "blocked", Val: *e.Blocked}) + } + return o +} + +type auditReport struct { + At int64 + Servers []*auditEntry +} + +func (r *auditReport) json() any { + if r == nil { + return nil + } + list := []any{} + for _, e := range r.Servers { + list = append(list, e.json()) + } + return jsjson.Object{{Key: "at", Val: r.At}, {Key: "servers", Val: list}} +} + +type cleanedPeer struct { + At int64 + ServerID int + PubKey string + Kind string // ghost | orphan + Key any // у ghost нет (undefined) + HasKey bool + Auto bool +} + +func (c cleanedPeer) json() jsjson.Object { + o := jsjson.Object{{Key: "at", Val: c.At}, {Key: "server_id", Val: c.ServerID}, {Key: "pub_key", Val: c.PubKey}, {Key: "kind", Val: c.Kind}} + if c.HasKey { + o = append(o, jsjson.KV{Key: "key", Val: c.Key}) + } + return append(o, jsjson.KV{Key: "auto", Val: c.Auto}) +} + +// auditSettings — clean: снимать «мёртвых» сами; every_h — период; max — предохранитель. +type auditSettings struct { + Clean bool + EveryH int + Max int +} + +func (s auditSettings) json() jsjson.Object { + return jsjson.Object{{Key: "clean", Val: s.Clean}, {Key: "every_h", Val: s.EveryH}, {Key: "max", Val: s.Max}} +} + +// ── sub_meta: JSON-значения ───────────────────────────────────────────────── + +func (s *Sub) metaGet(key string) any { + var raw string + if s.a.db.QueryRow("SELECT value FROM sub_meta WHERE key = ?", key).Scan(&raw) != nil { + return nil + } + v, err := jsjson.Decode([]byte(raw)) + if err != nil { + return nil + } + return v +} + +func (s *Sub) metaSet(key string, v any) { + s.a.db.Exec("INSERT INTO sub_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, string(jsjson.MustMarshal(v))) +} + +func parsePeer(v any, orphan bool) *auditPeer { + o, _ := v.(jsjson.Object) + p := &auditPeer{PubKey: strOr(o.Get2("pub_key"), ""), Online: o.Get2("online"), LastHandshake: o.Get2("lastHandshake"), orphan: orphan} + if orphan { + p.Owner, p.Key = o.Get2("owner"), o.Get2("key") + } + if b, ok := o.Get2("seen_before").(bool); ok { + p.SeenBefore = &b + } + return p +} + +func parseReport(v any) *auditReport { + o, ok := v.(jsjson.Object) + if !ok { + return nil + } + r := &auditReport{At: int64(numOr(o.Get2("at"), 0))} + for _, x := range arr(o.Get2("servers")) { + e := &auditEntry{ServerID: int(numOr(field(x, "server_id"), 0)), Status: strOr(field(x, "status"), "error"), + Foreign: int(numOr(field(x, "foreign"), 0))} + e.GhostsChecked, _ = field(x, "ghosts_checked").(bool) + for _, p := range arr(field(x, "ghosts")) { + e.Ghosts = append(e.Ghosts, parsePeer(p, false)) + } + for _, p := range arr(field(x, "orphans")) { + e.Orphans = append(e.Orphans, parsePeer(p, true)) + } + if b, ok := field(x, "blocked").(float64); ok { + n := int(b) + e.Blocked = &n + } + r.Servers = append(r.Servers, e) + } + return r +} + +func (s *Sub) loadAudit() { + s.lastAudit = parseReport(s.metaGet("audit")) + s.auditSettings = auditSettings{Clean: false, EveryH: 1, Max: 10} + if o, ok := s.metaGet("audit_settings").(jsjson.Object); ok { + if b, ok := o.Get2("clean").(bool); ok { + s.auditSettings.Clean = b + } + if f, ok := o.Get2("every_h").(float64); ok { + s.auditSettings.EveryH = int(f) + } + if f, ok := o.Get2("max").(float64); ok { + s.auditSettings.Max = int(f) + } + } + for _, x := range arr(s.metaGet("audit_cleaned")) { + o, _ := x.(jsjson.Object) + k, hasKey := o.Get("key") + auto, _ := o.Get2("auto").(bool) + s.cleanedLog = append(s.cleanedLog, cleanedPeer{At: int64(numOr(o.Get2("at"), 0)), ServerID: int(numOr(o.Get2("server_id"), 0)), + PubKey: strOr(o.Get2("pub_key"), ""), Kind: strOr(o.Get2("kind"), ""), Key: k, HasKey: hasKey, Auto: auto}) + } +} + +func (s *Sub) cleanedJSON() []any { + out := []any{} + for _, c := range s.cleanedLog { + out = append(out, c.json()) + } + return out +} + +// ── Проход ────────────────────────────────────────────────────────────────── + +var reOwnerMaster = regexp.MustCompile(`^[^/]+/m(\d+)/`) + +func (s *Sub) auditNow(id int) (*auditEntry, error) { + e := &auditEntry{ServerID: id, Status: "ok", GhostsChecked: true} + if !s.a.nodes.Exists(id) || !s.a.nodes.IsOnline(id) { + e.Status, e.GhostsChecked = "offline", false + return e, nil + } + ctrl := s.a.nodes.CtrlFor(id) + // Сначала обычное сведение: отчёт — про то, что не снялось само. + if err := s.reconcileNow(id); err != nil { + return nil, err + } + a, err := ctrl("GET", "/api/audit", nil) + if err != nil { + return nil, err + } + p, err := ctrl("GET", "/api/peers", nil) + if err != nil { + return nil, err + } + fail := func() (*auditEntry, error) { + e.Status, e.GhostsChecked = "error", false + e.Ghosts, e.Orphans = nil, nil + return e, nil + } + if !ok2xx(p.Status) { + return fail() + } + if ok2xx(a.Status) { + for _, g := range arr(field(a.Data, "ghosts")) { + e.Ghosts = append(e.Ghosts, &auditPeer{PubKey: strOr(field(g, "pub_key"), ""), Online: field(g, "online"), LastHandshake: field(g, "lastHandshake")}) + } + } else if a.Status == 404 { + e.GhostsChecked = false + } else { + return fail() + } + + keep := map[string]bool{} + for _, k := range s.keepOf(id) { + keep[k] = true + } + prefix := s.panelID + "/" + for _, peer := range arr(field(p.Data, "peers")) { + owner, ok := field(peer, "owner").(string) + if !ok { + return nil, errTypeError // peer.owner.startsWith бросил бы TypeError + } + pub := strOr(field(peer, "pub_key"), "") + if !strings.HasPrefix(owner, prefix) { + if strings.Contains(owner, "/") { + e.Foreign++ + } + continue + } + if keep[pub] { + continue + } + key := any("(нет в ui.db)") + if m := reOwnerMaster.FindStringSubmatch(owner); m != nil { + if mid, err := strconv.ParseInt(m[1], 10, 64); err == nil && mid <= 9007199254740991 { + if row := s.masterRowByID(s.a.db, mid); row != nil { + if row.RevokedAt.Valid { + key = row.Label + " (удалён)" + } else { + key = row.Label + } + } + } + } + e.Orphans = append(e.Orphans, &auditPeer{PubKey: pub, Owner: owner, Online: field(peer, "online"), + LastHandshake: field(peer, "lastHandshake"), Key: key, orphan: true}) + } + return e, nil +} + +type jsTypeError struct{} + +func (jsTypeError) Error() string { return "TypeError" } + +var errTypeError = jsTypeError{} + +// audit — под той же блокировкой, что и сведение: пир, которого прямо сейчас добавляет проход, не «призрак». +func (s *Sub) audit(id int) *auditEntry { + l := s.lock(id) + l.Lock() + defer l.Unlock() + e, err := func() (e *auditEntry, err error) { + defer func() { + if recover() != nil { + e, err = nil, errTypeError + } + }() + return s.auditNow(id) + }() + if err != nil || e == nil { + return &auditEntry{ServerID: id, Status: "error"} + } + return e +} + +// cleanEntry — снять «мёртвых» сервера: только подтверждённых (seen_before), не больше max за проход. +// «Призраки» — через awg-ctrl (он сам перепроверяет ключи), лишние — обычным DELETE. +func (s *Sub) cleanEntry(e *auditEntry, auto bool) error { + seen := func(ps []*auditPeer) []*auditPeer { + var out []*auditPeer + for _, p := range ps { + if p.SeenBefore != nil && *p.SeenBefore { + out = append(out, p) + } + } + return out + } + ghosts, orphans := seen(e.Ghosts), seen(e.Orphans) + if len(ghosts)+len(orphans) == 0 || e.Status != "ok" { + return nil + } + s.auditMu.Lock() + max := s.auditSettings.Max + s.auditMu.Unlock() + if auto && len(ghosts)+len(orphans) > max { + n := len(ghosts) + len(orphans) + e.Blocked = &n + warnf("sub: очистка сервера #%d пропущена: %d пиров больше предела %d", e.ServerID, n, max) + return nil + } + ctrl := s.a.nodes.CtrlFor(e.ServerID) + var done []cleanedPeer + log := func(p *auditPeer, kind string) { + done = append(done, cleanedPeer{At: now(), ServerID: e.ServerID, PubKey: p.PubKey, Kind: kind, Key: p.Key, HasKey: p.orphan, Auto: auto}) + } + if len(ghosts) > 0 { + keys := []any{} + for _, g := range ghosts { + keys = append(keys, g.PubKey) + } + r, err := ctrl("POST", "/api/audit/clean", jsjson.Object{{Key: "pub_keys", Val: keys}}) + if err != nil { + return err + } + if ok2xx(r.Status) { + removed := map[string]bool{} + for _, x := range arr(field(r.Data, "removed")) { + if k, ok := x.(string); ok { + removed[k] = true + } + } + for _, g := range ghosts { + if removed[g.PubKey] { + log(g, "ghost") + } + } + } + } + for _, p := range orphans { + r, err := ctrl("DELETE", "/api/peers/"+pubParam(p.PubKey), nil) + if err != nil { + return err + } + if ok2xx(r.Status) || r.Status == 404 { + log(p, "orphan") + } else if gone(r.Status) { + break + } + } + if len(done) == 0 { + return nil + } + cleaned := map[string]bool{} + var short []string + for _, d := range done { + cleaned[d.PubKey] = true + k := d.PubKey + if len(k) > 8 { + k = k[:8] + } + short = append(short, k) + } + drop := func(ps []*auditPeer) []*auditPeer { + var out []*auditPeer + for _, p := range ps { + if !cleaned[p.PubKey] { + out = append(out, p) + } + } + return out + } + e.Ghosts, e.Orphans = drop(e.Ghosts), drop(e.Orphans) + s.auditMu.Lock() + s.cleanedLog = append(done, s.cleanedLog...) + if len(s.cleanedLog) > 100 { + s.cleanedLog = s.cleanedLog[:100] + } + s.metaSet("audit_cleaned", s.cleanedJSON()) + s.auditMu.Unlock() + what := "очистка" + if auto { + what = "автоочистка" + } + warnf("sub: %s сервера #%d: снято %d (%s)", what, e.ServerID, len(done), strings.Join(short, ", ")) + return nil +} + +// cleanLocked — очистка под блокировкой сервера, как и сам аудит. +func (s *Sub) cleanLocked(e *auditEntry, auto bool) { + l := s.lock(e.ServerID) + l.Lock() + defer l.Unlock() + if err := s.cleanEntry(e, auto); err != nil && auto { + warnf("sub: очистка сервера #%d не удалась: %v", e.ServerID, err) + } +} + +// auditAll — проход по всем серверам; одновременно идёт не больше одного (повторный вызов ждёт +// текущий). auto — плановый: очищает сам, если включено. Ручной только смотрит. +func (s *Sub) auditAll(auto bool) { + s.auditMu.Lock() + if ch := s.auditing; ch != nil { + s.auditMu.Unlock() + <-ch + return + } + ch := make(chan struct{}) + s.auditing = ch + prev := s.lastAudit + s.auditMu.Unlock() + defer func() { + s.auditMu.Lock() + s.auditing = nil + s.auditMu.Unlock() + close(ch) + }() + + ids := s.a.nodes.IDs() + report := &auditReport{At: now(), Servers: make([]*auditEntry, len(ids))} + done := make(chan struct{}, len(ids)) + for i, id := range ids { + go func() { report.Servers[i] = s.audit(id); done <- struct{}{} }() + } + for range ids { + <-done + } + s.auditMu.Lock() + clean := s.auditSettings.Clean + s.auditMu.Unlock() + for _, e := range report.Servers { + was := map[string]bool{} + if prev != nil { + for _, x := range prev.Servers { + if x.ServerID == e.ServerID { + for _, p := range append(append([]*auditPeer{}, x.Ghosts...), x.Orphans...) { + was[p.PubKey] = true + } + break + } + } + } + for _, p := range append(append([]*auditPeer{}, e.Ghosts...), e.Orphans...) { + b := was[p.PubKey] + p.SeenBefore = &b + } + if auto && clean { + s.cleanLocked(e, true) + } + } + s.auditMu.Lock() + s.lastAudit = report + s.metaSet("audit", report.json()) + s.auditMu.Unlock() + for _, e := range report.Servers { + if e.Status == "error" { + warnf("sub: аудит сервера #%d не удался", e.ServerID) + } + if n := len(e.Ghosts) + len(e.Orphans); n > 0 { + online := 0 + for _, p := range append(append([]*auditPeer{}, e.Ghosts...), e.Orphans...) { + if truthy(p.Online) { + online++ + } + } + warnf("sub: аудит сервера #%d: мёртвых пиров %d (в ядре без записи: %d, лишних у awg-ctrl: %d, онлайн: %d)", + e.ServerID, n, len(e.Ghosts), len(e.Orphans), online) + } + } +} + +// cleanNow — ручная очистка подтверждённых «мёртвых» по последнему отчёту. +func (s *Sub) cleanNow() { + s.auditMu.Lock() + rep := s.lastAudit + s.auditMu.Unlock() + if rep == nil { + return + } + for _, e := range rep.Servers { + s.cleanLocked(e, false) + } + s.auditMu.Lock() + s.metaSet("audit", rep.json()) + s.auditMu.Unlock() +} + +func (s *Sub) auditState() jsjson.Object { + s.auditMu.Lock() + defer s.auditMu.Unlock() + return jsjson.Object{{Key: "enabled", Val: s.auditOn}, {Key: "settings", Val: s.auditSettings.json()}, + {Key: "report", Val: s.lastAudit.json()}, {Key: "cleaned", Val: s.cleanedJSON()}} +} diff --git a/internal/ui/jsutil.go b/internal/ui/jsutil.go new file mode 100644 index 0000000..761ba31 --- /dev/null +++ b/internal/ui/jsutil.go @@ -0,0 +1,122 @@ +package ui + +import ( + "io" + "log" + "math" + "net/http" + "strconv" + "strings" + "unicode/utf16" +) + +// jsToNumber — Number(s) для строки: пробелы по краям, пусто → 0, 0x/0o/0b, Infinity; +// всё прочее нечисловое — NaN. +func jsToNumber(s string) float64 { + t := strings.TrimSpace(s) + if t == "" { + return 0 + } + switch t { + case "Infinity", "+Infinity": + return math.Inf(1) + case "-Infinity": + return math.Inf(-1) + } + if len(t) > 2 && t[0] == '0' { + base := 0 + switch t[1] { + case 'x', 'X': + base = 16 + case 'o', 'O': + base = 8 + case 'b', 'B': + base = 2 + } + if base != 0 { + n, err := strconv.ParseUint(t[2:], base, 64) + if err != nil { + return math.NaN() + } + return float64(n) + } + } + for _, r := range t { + if !(r >= '0' && r <= '9' || r == '.' || r == 'e' || r == 'E' || r == '+' || r == '-') { + return math.NaN() + } + } + f, err := strconv.ParseFloat(t, 64) + if err != nil { + return math.NaN() + } + return f +} + +// jsToNumberAny — Number(v) для значения из JSON. +func jsToNumberAny(v any) float64 { + switch x := v.(type) { + case nil: + return 0 + case bool: + if x { + return 1 + } + return 0 + case float64: + return x + case string: + return jsToNumber(x) + } + return math.NaN() +} + +// encodeURIComponent — как в JS: не трогает A-Z a-z 0-9 - _ . ! ~ * ' ( ). +func encodeURIComponent(s string) string { + const hexd = "0123456789ABCDEF" + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || strings.IndexByte("-_.!~*'()", c) >= 0 { + b.WriteByte(c) + } else { + b.WriteByte('%') + b.WriteByte(hexd[c>>4]) + b.WriteByte(hexd[c&15]) + } + } + return b.String() +} + +func readAll(resp *http.Response) ([]byte, error) { return io.ReadAll(resp.Body) } + +// utf16Len — длина строки в JS (кодовые единицы UTF-16). +func utf16Len(s string) int { + n := 0 + for _, r := range s { + if r >= 0x10000 { + n += 2 + } else { + n++ + } + } + return n +} + +// lenOK — `{min,max}` в JS-регулярке над строкой целиком: длина в единицах UTF-16. +func lenOK(s string, min, max int) bool { + n := utf16Len(s) + return n >= min && n <= max +} + +// sliceUTF16 — s.slice(0, n) в JS. Разрезанная суррогатная пара при записи в SQLite +// превращается в U+FFFD — так же делаем и здесь. +func sliceUTF16(s string, n int) string { + units := utf16.Encode([]rune(s)) + if len(units) <= n { + return s + } + return string(utf16.Decode(units[:n])) +} + +func quietLog() *log.Logger { return log.New(io.Discard, "", 0) } diff --git a/internal/ui/metrics.go b/internal/ui/metrics.go new file mode 100644 index 0000000..974391e --- /dev/null +++ b/internal/ui/metrics.go @@ -0,0 +1,468 @@ +package ui + +// Prometheus: GET /metrics на ядре (порт awg-ui/metrics.ts). Метрики ядра и всех нод +// собираются здесь же — у нод через уже открытый WSS. Доступ — отдельный токен «только чтение +// метрик»; он не открывает ни /api/v1, ни панель. + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "math" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +const ( + scrapeDeadline = 5 * time.Second + touchEveryS = 60 // last_used пишем не чаще раза в минуту, а не на каждый скрейп +) + +func hashToken(t string) []byte { + s := sha256.Sum256([]byte(t)) + return s[:] +} + +type Metrics struct { + a *App + perPeer bool + localFile string + + mu sync.Mutex + localMod time.Time + localSeen bool + localHash []byte +} + +func newMetrics(a *App) (*Metrics, error) { + m := &Metrics{ + a: a, + // Пер-пировые серии (peer = внутренний IP, name = имя пользователя) выключены: это и + // приватность, и кардинальность. Включается явно. + perPeer: strings.ToLower(envOr("METRICS_PEERS", "off")) == "on", + localFile: envOr("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"), + } + _, err := a.db.Exec(` + CREATE TABLE IF NOT EXISTS metrics_token ( + id INTEGER PRIMARY KEY CHECK (id = 1), + token_hash BLOB NOT NULL, + prefix TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used INTEGER + ) + `) + return m, err +} + +var reHex64 = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// localTokenHash — токен встроенного Prometheus (`awg-ctrl monitor install`): здесь его хэш. +// Файл перечитывается по mtime: установка не требует перезапуска панели. +func (m *Metrics) localTokenHash() []byte { + m.mu.Lock() + defer m.mu.Unlock() + st, err := os.Stat(m.localFile) + if err != nil { + m.localSeen, m.localHash = false, nil + return nil + } + if !m.localSeen || !st.ModTime().Equal(m.localMod) { + raw, err := os.ReadFile(m.localFile) + if err != nil { + m.localSeen, m.localHash = false, nil + return nil + } + m.localSeen, m.localMod, m.localHash = true, st.ModTime(), nil + if h := strings.TrimSpace(string(raw)); reHex64.MatchString(h) { + m.localHash, _ = hex.DecodeString(h) + } + } + return m.localHash +} + +type tokenRow struct { + hash []byte + prefix string + createdAt int64 + lastUsed sql.NullInt64 +} + +func (m *Metrics) token() *tokenRow { + t := &tokenRow{} + if m.a.db.QueryRow("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&t.hash, &t.prefix, &t.createdAt, &t.lastUsed) != nil { + return nil + } + return t +} + +func (m *Metrics) view() jsjson.Object { + if t := m.token(); t != nil { + return jsjson.Object{{Key: "exists", Val: true}, {Key: "prefix", Val: t.prefix}, {Key: "created_at", Val: t.createdAt}, + {Key: "last_used", Val: nullInt(t.lastUsed)}, {Key: "per_peer", Val: m.perPeer}} + } + return jsjson.Object{{Key: "exists", Val: false}, {Key: "per_peer", Val: m.perPeer}} +} + +// router — управление токеном (роуты панели, за JWT). Выпуск и перевыпуск — одно и то же: +// старый токен перестаёт работать. Значение отдаётся один раз. +func (m *Metrics) router() *Router { + rt := newRouter() + rt.Get("/", func(c *Ctx) bool { return c.JSON(200, m.view()) }) + rt.Post("/", func(c *Ctx) bool { + b := make([]byte, 24) + rand.Read(b) + tok := "awgm_" + b64url.EncodeToString(b) + m.a.db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)", + hashToken(tok), tok[:13]+"…", now()) + return c.JSON(201, append(m.view(), jsjson.KV{Key: "token", Val: tok})) + }) + rt.Delete("/", func(c *Ctx) bool { + m.a.db.Exec("DELETE FROM metrics_token WHERE id = 1") + return c.JSON(200, m.view()) + }) + return rt +} + +func writeText(c *Ctx, status int, ctype, body string) bool { + c.W.Header().Set("Content-Type", ctype) + c.W.WriteHeader(status) + c.W.Write([]byte(body)) + return false +} + +// requireToken — нет токена или он неверный — одинаковый 401: не подсказываем, выключены +// метрики или токен не тот. +func (m *Metrics) requireToken(c *Ctx) bool { + given := bearer(c.R) + row := m.token() + var gh []byte + if strings.HasPrefix(given, "awgm_") { + gh = hashToken(given) + } + panelOK := row != nil && gh != nil && len(row.hash) == len(gh) && subtle.ConstantTimeCompare(gh, row.hash) == 1 + local := m.localTokenHash() + localOK := gh != nil && local != nil && subtle.ConstantTimeCompare(gh, local) == 1 + if !panelOK && !localOK { + c.W.Header().Set("WWW-Authenticate", "Bearer") + return writeText(c, 401, "text/plain; charset=utf-8", "unauthorized\n") + } + // last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует. + if t := now(); panelOK && (!row.lastUsed.Valid || row.lastUsed.Int64 == 0 || t-row.lastUsed.Int64 >= touchEveryS) { + m.a.db.Exec("UPDATE metrics_token SET last_used = ? WHERE id = 1", t) + } + return true +} + +// ── Экспозиция ────────────────────────────────────────────────────────────── + +type label struct { + k string + v any +} + +type sample struct { + labels []label + v any // число; не число и не конечное — 0 +} + +type exposition struct { + b bytes.Buffer + seen map[string]bool +} + +var ( + escLabel = strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`) + escHelp = strings.NewReplacer(`\`, `\\`, "\n", `\n`) +) + +// labelString — String(v) для значения метки. +func labelString(v any) string { + switch x := v.(type) { + case string: + return x + case int: + return jsjson.Number(float64(x)) + case int64: + return jsjson.Number(float64(x)) + case float64: + return jsjson.Number(x) + case bool: + if x { + return "true" + } + return "false" + case nil: + return "undefined" + } + return "[object Object]" +} + +func (e *exposition) add(name, typ, help string, samples []sample) { + if len(samples) == 0 { + return + } + if !e.seen[name] { + e.seen[name] = true + e.b.WriteString("# HELP " + name + " " + escHelp.Replace(help) + "\n# TYPE " + name + " " + typ + "\n") + } + for _, s := range samples { + e.b.WriteString(name) + if len(s.labels) > 0 { + parts := make([]string, len(s.labels)) + for i, l := range s.labels { + parts[i] = l.k + `="` + escLabel.Replace(labelString(l.v)) + `"` + } + e.b.WriteString("{" + strings.Join(parts, ",") + "}") + } + v := 0.0 + switch x := s.v.(type) { + case float64: + v = x + case int: + v = float64(x) + case int64: + v = float64(x) + } + if math.IsNaN(v) || math.IsInf(v, 0) { + v = 0 + } + e.b.WriteString(" " + jsjson.Number(v) + "\n") + } +} + +func b2f(b bool) float64 { + if b { + return 1 + } + return 0 +} + +// scrapeServer — один сервер за скрейп: сам сервер не отвечает — остальные не страдают. +func (m *Metrics) scrapeServer(s serverInfo) any { + if s.Pending || !m.a.nodes.IsOnline(s.ID) { + return nil + } + ch := make(chan any, 1) + go func() { + r, err := m.a.nodes.CtrlFor(s.ID)("GET", "/api/metrics", nil) + if err != nil || r.Status != 200 { + ch <- nil + return + } + ch <- r.Data + }() + select { + case d := <-ch: + return d + case <-time.After(scrapeDeadline): + return nil + } +} + +func (m *Metrics) render() string { + t0 := time.Now() + list := m.a.nodes.List() + data := make([]any, len(list)) + var wg sync.WaitGroup + for i, s := range list { + wg.Add(1) + go func() { defer wg.Done(); data[i] = m.scrapeServer(s) }() + } + wg.Wait() + out := &exposition{seen: map[string]bool{}} + L := func(s serverInfo, extra ...label) []label { + return append([]label{{"server", s.Name}, {"server_id", s.ID}}, extra...) + } + // Серии только тех серверов, чьи метрики получены. + per := func(fn func(s serverInfo, d any) []sample) []sample { + var res []sample + for i, s := range list { + if truthy(data[i]) { + res = append(res, fn(s, data[i])...) + } + } + return res + } + one := func(fn func(d any) any) func(serverInfo, any) []sample { + return func(s serverInfo, d any) []sample { return []sample{{L(s), fn(d)}} } + } + or0 := func(v any) any { + if v == nil { + return 0.0 + } + return v + } + + out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", []sample{{[]label{{"version", m.a.version}}, 1.0}}) + out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", []sample{{nil, float64(len(list))}}) + var online, scrapeOK, info []sample + for i, s := range list { + online = append(online, sample{L(s), b2f(s.Online)}) + scrapeOK = append(scrapeOK, sample{L(s), b2f(truthy(data[i]))}) + if !s.Pending { + gen, module, tools := any(""), any(""), any("") + if s.Health != nil { + gen, module, tools = s.Health.Gen, s.Health.Module, s.Health.Tools + } + ver := s.Version + if ver == nil { + ver = "" + } + info = append(info, sample{L(s, label{"version", ver}, label{"gen", gen}, label{"module", module}, label{"tools", tools}), 1.0}) + } + } + out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", online) + out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", scrapeOK) + out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.", info) + + out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per(one(func(d any) any { return b2f(truthy(field(d, "up"))) }))) + out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per(one(func(d any) any { return or0(field(d, "peers", "total")) }))) + out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per(one(func(d any) any { return or0(field(d, "peers", "online")) }))) + // Сумма по пирам — gauge, а не counter: при удалении пира сумма уменьшается. + out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "rx_bytes")) }))) + out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per(one(func(d any) any { return or0(field(d, "tx_bytes")) }))) + for _, x := range [][3]string{ + {"rx_bytes", "receive_bytes", "байт"}, {"tx_bytes", "transmit_bytes", "байт"}, + {"rx_packets", "receive_packets", "пакетов"}, {"tx_packets", "transmit_packets", "пакетов"}, + {"rx_dropped", "receive_dropped", "потерянных пакетов"}, {"tx_dropped", "transmit_dropped", "потерянных пакетов"}, + {"rx_errors", "receive_errors", "ошибок"}, {"tx_errors", "transmit_errors", "ошибок"}, + } { + out.add("awg_link_"+x[1]+"_total", "counter", "Интерфейс awg1: "+x[2]+" ("+x[0]+").", per(one(func(d any) any { return or0(field(d, "link", x[0])) }))) + } + + // Нагрузка хоста. CPU — счётчики секунд по режимам: доля = rate() в Prometheus. + out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).", + per(func(s serverInfo, d any) []sample { + cpu, _ := field(d, "host", "cpu").(jsjson.Object) + var res []sample + for _, kv := range cpu { + res = append(res, sample{L(s, label{"mode", kv.Key}), jsToNumberAny(kv.Val)}) + } + return res + })) + hostOne := func(fn func(h any) any) func(serverInfo, any) []sample { + return func(s serverInfo, d any) []sample { + h := field(d, "host") + if !truthy(h) { + return nil + } + return []sample{{L(s), fn(h)}} + } + } + out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per(hostOne(func(h any) any { return or0(field(h, "cores")) }))) + out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "total")) }))) + out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per(hostOne(func(h any) any { return or0(field(h, "mem", "available")) }))) + out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).", per(func(s serverInfo, d any) []sample { + h := field(d, "host") + if !truthy(h) { + return nil + } + load := arr(field(h, "load")) + var res []sample + for i, p := range []string{"1m", "5m", "15m"} { + var v any = 0.0 + if i < len(load) && load[i] != nil { + v = load[i] + } + res = append(res, sample{L(s, label{"period", p}), v}) + } + return res + })) + + // Деление канала (CAKE) + sh := func(fn func(x any) any) []sample { + return per(func(s serverInfo, d any) []sample { + x := field(d, "shaping") + if !truthy(x) { + return nil + } + return []sample{{L(s), fn(x)}} + }) + } + out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "settings", "enabled"))) })) + out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "active"))) })) + out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(func(x any) any { return b2f(truthy(field(x, "congested"))) })) + out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(func(x any) any { + if h, ok := field(x, "heavy").([]any); ok { + return float64(len(h)) + } + return 0.0 + })) + out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(func(x any) any { return or0(field(x, "devices")) })) + // dirs — значение nil (undefined) серию не даёт. + dirs := func(fn func(x any, dir string) any) []sample { + return per(func(s serverInfo, d any) []sample { + x := field(d, "shaping") + if !truthy(x) { + return nil + } + var res []sample + for _, dir := range []string{"down", "up"} { + if v := fn(x, dir); v != nil { + res = append(res, sample{L(s, label{"direction", dir}), v}) + } + } + return res + }) + } + mbit := func(v any) any { + if v == nil { + return math.NaN() // undefined * 1e6 + } + return jsToNumberAny(v) * 1e6 + } + out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs(func(x any, dir string) any { + if dir == "down" { + return mbit(field(x, "settings", "downMbit")) + } + return mbit(field(x, "settings", "upMbit")) + })) + out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs(func(x any, dir string) any { + if dir == "down" { + return mbit(field(x, "rateDown")) + } + return mbit(field(x, "rateUp")) + })) + out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "drops") })) + out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs(func(x any, dir string) any { return field(x, dir, "backlog") })) + + if m.perPeer { + peers := func(fn func(p any) any) []sample { + return per(func(s serverInfo, d any) []sample { + var res []sample + for _, p := range arr(field(d, "list")) { + name := field(p, "name") + if name == nil { + name = "" + } + res = append(res, sample{L(s, label{"peer", field(p, "ip")}, label{"name", name}, label{"kind", field(p, "kind")}), fn(p)}) + } + return res + }) + } + out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(func(p any) any { return b2f(truthy(field(p, "online"))) })) + out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(func(p any) any { return b2f(field(p, "priority") == "background") })) + out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(func(p any) any { return field(p, "rx") })) + out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(func(p any) any { return field(p, "tx") })) + } + + var masters, devices int64 + m.a.db.QueryRow("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").Scan(&masters) + m.a.db.QueryRow("SELECT COUNT(*) AS n FROM devices").Scan(&devices) + out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", []sample{{nil, masters}}) + out.add("awg_devices", "gauge", "Устройств sen://.", []sample{{nil, devices}}) + out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", []sample{{nil, time.Since(t0).Seconds()}}) + return out.b.String() +} + +func (m *Metrics) handler(c *Ctx) bool { + return writeText(c, 200, "text/plain; version=0.0.4; charset=utf-8", m.render()) +} diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go new file mode 100644 index 0000000..7c83e26 --- /dev/null +++ b/internal/ui/monitor.go @@ -0,0 +1,327 @@ +package ui + +// Вкладка «Монитор» (порт awg-ui/monitor.ts): встроенный Prometheus и основные графики. +// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки +// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root) +// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove` +// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч +// Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами. + +import ( + "errors" + "io" + "math" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +type promRange struct{ seconds, step int64 } + +// Шаг — чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с). +var promRanges = map[string]promRange{"1h": {3600, 15}, "6h": {21600, 60}, "24h": {86400, 300}} + +// Запросы по серверам. Трафик — со счётчиков интерфейса (монотонны), а не с сумм по пирам. +var promQueries = []struct{ key, q string }{ + {"down", `sum by (server_id, server) (rate(awg_link_transmit_bytes_total[2m])) * 8`}, + {"up", `sum by (server_id, server) (rate(awg_link_receive_bytes_total[2m])) * 8`}, + {"online", `sum by (server_id, server) (awg_peers_online)`}, + {"heavy", `sum by (server_id, server) (awg_shaping_heavy_devices)`}, + {"drops", `sum by (server_id, server) (rate(awg_shaping_qdisc_drops_total[2m]))`}, + {"congested", `max by (server_id, server) (awg_shaping_congested)`}, + {"scrape_ok", `max by (server_id, server) (awg_scrape_ok)`}, + {"cpu", `100 * (1 - sum by (server_id, server) (rate(awg_host_cpu_seconds_total{mode=~"idle|iowait"}[2m])) / sum by (server_id, server) (rate(awg_host_cpu_seconds_total[2m])))`}, + {"mem", `100 * (1 - sum by (server_id, server) (awg_host_memory_available_bytes) / sum by (server_id, server) (awg_host_memory_total_bytes))`}, + {"cores", `max by (server_id, server) (awg_host_cpu_cores)`}, + {"mem_total", `max by (server_id, server) (awg_host_memory_total_bytes)`}, +} + +type point [2]float64 + +type seriesOne struct { + name any + points []point +} + +// series — server_id → ряд, в порядке ответа Prometheus (как Map в JS). +type series struct { + ids []int + m map[int]*seriesOne +} + +func (s *series) get(id int) *seriesOne { + if s == nil { + return nil + } + return s.m[id] +} + +type Monitor struct { + a *App + promURL string + localEnabled bool + http *http.Client +} + +func newMonitor(a *App) *Monitor { + return &Monitor{ + a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"), + localEnabled: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off", + http: &http.Client{Transport: &http.Transport{Proxy: nil}}, + } +} + +var errProm = errors.New("Prometheus не отвечает") + +func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, error) { + q := url.Values{} + q.Set("query", query) + q.Set("start", strconv.FormatInt(start, 10)) + q.Set("end", strconv.FormatInt(end, 10)) + q.Set("step", strconv.FormatInt(step, 10)) + c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second} + resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode()) + if err != nil { + return nil, errProm + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, errProm + } + out := &series{m: map[int]*seriesOne{}} + for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) { + metric := field(r, "metric") + idf := jsToNumberAny(field(metric, "server_id")) + if field(metric, "server_id") == nil { + idf = math.NaN() + } + if idf != float64(int(idf)) { + continue + } + id := int(idf) + name := field(metric, "server") + if name == nil { + name = strconv.Itoa(id) + } + s := &seriesOne{name: name} + for _, v := range arr(field(r, "values")) { + pair := arr(v) + if len(pair) < 2 { + continue + } + t := jsToNumberAny(pair[0]) + val := jsToNumberAny(pair[1]) + // NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость + if math.IsNaN(val) || math.IsInf(val, 0) { + continue + } + s.points = append(s.points, point{t, val}) + } + if _, dup := out.m[id]; !dup { + out.ids = append(out.ids, id) + } + out.m[id] = s + } + return out, nil +} + +// total — сумма (или максимум) рядов по меткам времени. +func total(s *series, max bool) []any { + acc := map[float64]float64{} + var order []float64 + for _, id := range s.ids { + for _, p := range s.m[id].points { + cur, seen := acc[p[0]] + if !seen { + order = append(order, p[0]) + } + if max { + acc[p[0]] = math.Max(cur, p[1]) + } else { + acc[p[0]] = cur + p[1] + } + } + } + sort.Float64s(order) + out := []any{} + for _, t := range order { + out = append(out, []any{t, acc[t]}) + } + return out +} + +func last(s *seriesOne) any { + if s == nil || len(s.points) == 0 { + return nil + } + return s.points[len(s.points)-1][1] +} + +func (m *Monitor) promReady() bool { + c := &http.Client{Transport: m.http.Transport, Timeout: 1500 * time.Millisecond} + resp, err := c.Get(m.promURL + "/-/ready") + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == 200 +} + +type monitorJob struct { + can bool + action any + running bool + exit any + log string +} + +// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут. +func (m *Monitor) jobState() monitorJob { + if !m.localEnabled { + return monitorJob{} + } + r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil) + if err != nil || r.Status != 200 { + return monitorJob{} + } + j := monitorJob{can: true, action: field(r.Data, "action"), running: truthy(field(r.Data, "running")), exit: field(r.Data, "exit")} + switch l := field(r.Data, "log").(type) { + case nil: + case string: + j.log = l + default: + j.log, _ = jsString(l) + } + return j +} + +func (m *Monitor) router() *Router { + rt := newRouter() + rt.Get("/", func(c *Ctx) bool { + var ready bool + var job monitorJob + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); ready = m.promReady() }() + go func() { defer wg.Done(); job = m.jobState() }() + wg.Wait() + var jobOut any + if job.can && truthy(job.action) { + jobOut = jsjson.Object{{Key: "action", Val: job.action}, {Key: "running", Val: job.running}, {Key: "exit", Val: job.exit}, {Key: "log", Val: job.log}} + } + return c.JSON(200, jsjson.Object{{Key: "ready", Val: ready}, {Key: "can_install", Val: job.can}, {Key: "job", Val: jobOut}, + {Key: "command", Val: "awg-ctrl monitor install"}}) + }) + // Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root. + ask := func(action string) handler { + return func(c *Ctx) bool { + if !m.localEnabled { + return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине") + } + r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil) + if err != nil { + return c.Error(502, "awg-ctrl недоступен") + } + if r.Status == 404 { + return c.Error(501, "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную") + } + return c.JSON(r.Status, r.Data) + } + } + rt.Post("/install", ask("install")) + rt.Post("/remove", ask("remove")) + + rt.Get("/overview", func(c *Ctx) bool { + qs := c.R.URL.Query() + rangeKey := "1h" + if _, ok := qs["range"]; ok { + rangeKey = strings.Join(qs["range"], ",") + } + rg, ok := promRanges[rangeKey] + if !ok { + return c.Error(400, "range: 1h, 6h или 24h") + } + end := now() + start := end - rg.seconds + got := make([]*series, len(promQueries)) + errs := make([]error, len(promQueries)) + var wg sync.WaitGroup + for i, pq := range promQueries { + wg.Add(1) + go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(pq.q, start, end, rg.step) }() + } + wg.Wait() + for _, err := range errs { + if err != nil { + return c.Error(502, err.Error()) + } + } + // Вкладка показывает один сервер — выбранный в сервер-баре (?server=). + if _, has := qs["server"]; has { + if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) { + only := int(f) + for i, s := range got { + f := &series{m: map[int]*seriesOne{}} + if x, ok := s.m[only]; ok { + f.ids, f.m[only] = []int{only}, x + } + got[i] = f + } + } + } + s := map[string]*series{} + for i, pq := range promQueries { + s[pq.key] = got[i] + } + ids := map[int]bool{} + for _, x := range got { + for _, id := range x.ids { + ids[id] = true + } + } + var sorted []int + for id := range ids { + sorted = append(sorted, id) + } + sort.Ints(sorted) + servers := []any{} + for _, id := range sorted { + var name any = strconv.Itoa(id) + for _, k := range []string{"scrape_ok", "down", "online"} { + if x := s[k].get(id); x != nil { + name = x.name + break + } + } + servers = append(servers, jsjson.Object{ + {Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "ok", Val: last(s["scrape_ok"].get(id)) == 1.0}, + {Key: "down", Val: last(s["down"].get(id))}, {Key: "up", Val: last(s["up"].get(id))}, + {Key: "online", Val: last(s["online"].get(id))}, {Key: "heavy", Val: last(s["heavy"].get(id))}, + {Key: "congested", Val: last(s["congested"].get(id)) == 1.0}, {Key: "cpu", Val: last(s["cpu"].get(id))}, + {Key: "mem", Val: last(s["mem"].get(id))}, {Key: "cores", Val: last(s["cores"].get(id))}, + {Key: "mem_total", Val: last(s["mem_total"].get(id))}, + }) + } + var rangeOut any = "1h" + if _, ok := qs["range"]; ok { + rangeOut = rangeKey + } + return c.JSON(200, jsjson.Object{ + {Key: "range", Val: rangeOut}, {Key: "step", Val: rg.step}, {Key: "from", Val: start}, {Key: "to", Val: end}, + {Key: "servers", Val: servers}, + {Key: "total", Val: jsjson.Object{ + {Key: "down", Val: total(s["down"], false)}, {Key: "up", Val: total(s["up"], false)}, {Key: "online", Val: total(s["online"], false)}, + {Key: "heavy", Val: total(s["heavy"], false)}, {Key: "drops", Val: total(s["drops"], false)}, + {Key: "cpu", Val: total(s["cpu"], true)}, {Key: "mem", Val: total(s["mem"], true)}, + }}, + }) + }) + return rt +} diff --git a/internal/ui/nodes.go b/internal/ui/nodes.go new file mode 100644 index 0000000..ca531fd --- /dev/null +++ b/internal/ui/nodes.go @@ -0,0 +1,863 @@ +package ui + +// Несколько серверов под одной панелью (порт awg-ui/nodes.ts): core (эта панель) и ноды — +// VPN-серверы, которые сами открывают к core исходящее WSS-соединение (awg-control agent). +// Реестр нод лежит в ui.db, RPC к ноде идёт по этому соединению, а нода исполняет запрос на +// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает. +// +// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает +// как чистая панель. Протокол и формат join-строки — docs/node-protocol.md. + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "database/sql" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +const ( + joinTTL = 3600 // секунд жизни одноразового секрета + authTimeout = 10 * time.Second + healthEvery = 15 * time.Second + pingEvery = 20 * time.Second + rpcTimeout = 15 * time.Second + rpcSlow = 120 * time.Second // /awg/*: рестарт и upgrade идут долго + hubMaxFrame = 4 * 1024 * 1024 + failWindow = 15 * time.Minute + failMaxHits = 20 +) + +var ( + reNodeName = regexp.MustCompile(`^[^\r\n<>]+$`) // длину — lenOK: в JS {1,40} считает UTF-16 + reHost = regexp.MustCompile(`^[A-Za-z0-9.:_-]{1,253}$`) + reVersion = regexp.MustCompile(`^[\w.+-]{1,32}$`) +) + +// authMsg — сообщение, которое нода подписывает своим ключом. Дублируется в internal/agent. +func authMsg(nonce string, nodeID int64) []byte { + return []byte("awg-node-v1\n" + nonce + "\n" + strconv.FormatInt(nodeID, 10)) +} + +type nodeRow struct { + ID int64 + Name string + Pub sql.NullString + JoinHash []byte + JoinExpires sql.NullInt64 + CreatedAt int64 + LastSeen sql.NullInt64 + Version sql.NullString +} + +const nodeCols = "id, name, pub, join_hash, join_expires, created_at, last_seen, version" + +func scanNode(row interface{ Scan(...any) error }) (*nodeRow, error) { + n := &nodeRow{} + if err := row.Scan(&n.ID, &n.Name, &n.Pub, &n.JoinHash, &n.JoinExpires, &n.CreatedAt, &n.LastSeen, &n.Version); err != nil { + return nil, err + } + return n, nil +} + +// health — сводка /health сервера; ip — как пришло (`d.ip ?? ""`). +type health struct { + Server string + IP any + Gen any + Peers any + AwgUp bool + Module any + Tools any +} + +func (h *health) json() any { + if h == nil { + return nil + } + return jsjson.Object{{Key: "server", Val: h.Server}, {Key: "ip", Val: h.IP}, {Key: "gen", Val: h.Gen}, {Key: "peers", Val: h.Peers}, + {Key: "awg_up", Val: h.AwgUp}, {Key: "module", Val: h.Module}, {Key: "tools", Val: h.Tools}} +} + +type pending struct { + ch chan *CtrlResp + timer *time.Timer +} + +type nodeConn struct { + ws *websocket.Conn + mu sync.Mutex // seq, pending, alive + seq int64 + alive bool + pending map[int64]*pending + closed bool +} + +type deleteVeto struct { + Error string + Forcible bool +} + +type Nodes struct { + a *App + port int + local bool + tls *tls.Config + pin []byte + mu sync.Mutex + conns map[int64]*nodeConn + health map[int64]*health + failHits map[string]*rateEntry + + hmu sync.Mutex + onlineHandler []func(id int) + beforeDelete []func(id int, force bool) *deleteVeto + afterDelete []func(id int) +} + +var offline = &CtrlResp{502, errObj("Сервер недоступен")} + +func newNodes(a *App) (*Nodes, error) { + n := &Nodes{ + a: a, port: envNum("NODE_PORT"), + local: strings.ToLower(envOr("LOCAL_NODE", "on")) != "off", + conns: map[int64]*nodeConn{}, health: map[int64]*health{}, failHits: map[string]*rateEntry{}, + } + file := func(env, name string) string { + if v := os.Getenv(env); v != "" { + return v + } + return filepath.Join(a.baseDir, name) + } + // TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же, + // сколько core: пересоздать = переподключить все ноды. + if n.port != 0 { + keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt") + if err := func() error { + if !exists(keyF) && !exists(crtF) { + cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", + "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node") + if err := cmd.Run(); err != nil { + return err + } + os.Chmod(keyF, 0o600) + os.Chmod(crtF, 0o600) + logf("nodes: создан самоподписанный сертификат %s", crtF) + } + crt, err := os.ReadFile(crtF) + if err != nil { + return err + } + cert, err := tls.LoadX509KeyPair(crtF, keyF) + if err != nil { + return err + } + pin, err := spkiPin(crt) + if err != nil { + return err + } + n.tls = &tls.Config{Certificates: []tls.Certificate{cert}} + n.pin = pin + return nil + }(); err != nil { + warnf("nodes: сертификат хаба недоступен (%s) — приём нод отключён", crtF) + n.tls, n.pin = nil, nil + } + } + + if _, err := a.db.Exec(` + CREATE TABLE IF NOT EXISTS nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + pub TEXT, + join_hash BLOB, + join_expires INTEGER, + created_at INTEGER NOT NULL, + last_seen INTEGER + ); + `); err != nil { + return nil, err + } + // Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают). + if !hasColumn(a.db, "nodes", "version") { + if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN version TEXT"); err != nil { + return nil, err + } + } + return n, nil +} + +func exists(f string) bool { + _, err := os.Stat(f) + return err == nil +} + +func hasColumn(db queryer, table, col string) bool { + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + return false + } + defer rows.Close() + cols, _ := rows.Columns() + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + for rows.Next() { + rows.Scan(ptrs...) + for i, c := range cols { + if c == "name" && fmt.Sprint(vals[i]) == col { + return true + } + } + } + return false +} + +func (n *Nodes) row(id int64) *nodeRow { + r, _ := scanNode(n.a.db.QueryRow("SELECT "+nodeCols+" FROM nodes WHERE id = ?", id)) + return r +} + +func (n *Nodes) all() []*nodeRow { + rows, err := n.a.db.Query("SELECT " + nodeCols + " FROM nodes ORDER BY id") + if err != nil { + return nil + } + defer rows.Close() + var out []*nodeRow + for rows.Next() { + if r, err := scanNode(rows); err == nil { + out = append(out, r) + } + } + return out +} + +// ── RPC к нодам ───────────────────────────────────────────────────────────── + +func (n *Nodes) rpc(id int64, method, path string, body any) *CtrlResp { + n.mu.Lock() + c := n.conns[id] + n.mu.Unlock() + if c == nil { + return offline + } + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return offline + } + c.seq++ + rid := c.seq + p := &pending{ch: make(chan *CtrlResp, 1)} + timeout := rpcTimeout + if strings.HasPrefix(path, "/awg/") { + timeout = rpcSlow + } + p.timer = time.AfterFunc(timeout, func() { + c.mu.Lock() + delete(c.pending, rid) + c.mu.Unlock() + p.ch <- &CtrlResp{504, errObj("Сервер не ответил")} + }) + c.pending[rid] = p + c.mu.Unlock() + + msg := jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: rid}, {Key: "method", Val: method}, {Key: "path", Val: path}} + if body != nil { + msg = append(msg, jsjson.KV{Key: "body", Val: body}) + } + if b, err := jsjson.Marshal(msg); err == nil { + go c.ws.Write(context.Background(), websocket.MessageText, b) + } + return <-p.ch +} + +// CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS. +func (n *Nodes) CtrlFor(id int) Ctrl { + if id == 0 { + if !n.local { + return func(string, string, any) (*CtrlResp, error) { + return &CtrlResp{404, errObj("Локальный сервер не настроен")}, nil + } + } + return n.a.ctrlLocal + } + if n.row(int64(id)) == nil { + return func(string, string, any) (*CtrlResp, error) { + return &CtrlResp{404, errObj("Сервер не найден")}, nil + } + } + return func(m, p string, b any) (*CtrlResp, error) { return n.rpc(int64(id), m, p, b), nil } +} + +func (n *Nodes) Exists(id int) bool { + if id == 0 { + return n.local + } + return n.row(int64(id)) != nil +} + +// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда). +func (n *Nodes) IsOnline(id int) bool { + if id == 0 { + return n.local + } + n.mu.Lock() + defer n.mu.Unlock() + return n.conns[int64(id)] != nil +} + +// IDs — все серверы реестра: локальный (если есть) и ноды. +func (n *Nodes) IDs() []int { + var ids []int + if n.local { + ids = append(ids, 0) + } + for _, r := range n.all() { + ids = append(ids, int(r.ID)) + } + return ids +} + +// OnOnline — подписчики на «сервер снова на связи» (подписка догоняет пропущенное). +func (n *Nodes) OnOnline(cb func(id int)) { + n.hmu.Lock() + n.onlineHandler = append(n.onlineHandler, cb) + n.hmu.Unlock() +} + +// BeforeDelete — проверка перед удалением сервера: может запретить (forcible — запрет +// снимается подтверждением оператора). AfterDelete — уборка ссылок на уже удалённый сервер. +func (n *Nodes) BeforeDelete(cb func(id int, force bool) *deleteVeto) { + n.hmu.Lock() + n.beforeDelete = append(n.beforeDelete, cb) + n.hmu.Unlock() +} + +func (n *Nodes) AfterDelete(cb func(id int)) { + n.hmu.Lock() + n.afterDelete = append(n.afterDelete, cb) + n.hmu.Unlock() +} + +func (n *Nodes) fireOnline(id int) { + n.hmu.Lock() + hs := append([]func(int){}, n.onlineHandler...) + n.hmu.Unlock() + for _, cb := range hs { + func() { + defer func() { recover() }() // подписчик сам разберётся + cb(id) + }() + } +} + +// DefaultID — сервер по умолчанию: локальный, а без него — первая нода. +func (n *Nodes) DefaultID() int { + if n.local { + return 0 + } + if all := n.all(); len(all) > 0 { + return int(all[0].ID) + } + return 0 +} + +func (n *Nodes) refreshHealth(id int64) { + var r *CtrlResp + if id == 0 { + r, _ = n.a.ctrlLocal("GET", "/health", nil) + } else { + r = n.rpc(id, "GET", "/health", nil) + } + var d any + if r != nil { + d = r.Data + } + n.mu.Lock() + defer n.mu.Unlock() + if server, ok := field(d, "server").(string); ok { + or := func(v any, def any) any { + if v == nil { + return def + } + return v + } + n.health[id] = &health{ + Server: server, IP: or(field(d, "ip"), ""), Gen: or(field(d, "gen"), "2"), Peers: or(field(d, "awg", "peers"), 0.0), + AwgUp: field(d, "awg", "status") == "ok", Module: or(field(d, "awg", "module"), ""), Tools: or(field(d, "awg", "tools"), ""), + } + } else { + delete(n.health, id) + } +} + +// ── Хаб: приём соединений от нод ──────────────────────────────────────────── + +func (n *Nodes) failed(ip string) { + n.mu.Lock() + defer n.mu.Unlock() + t := nowFn() + e := n.failHits[ip] + if e == nil || e.resetAt.Before(t) { + n.failHits[ip] = &rateEntry{1, t.Add(failWindow)} + } else { + e.count++ + } +} + +func (n *Nodes) blocked(ip string) bool { + n.mu.Lock() + defer n.mu.Unlock() + e := n.failHits[ip] + return e != nil && e.resetAt.After(nowFn()) && e.count >= failMaxHits +} + +// dropConn — все ждущие запросы получают «недоступен»; из реестра соединений — только если +// там всё ещё это соединение. +func (n *Nodes) dropConn(id int64, c *nodeConn) { + c.mu.Lock() + c.closed = true + ps := c.pending + c.pending = map[int64]*pending{} + c.mu.Unlock() + for _, p := range ps { + if p.timer.Stop() { + p.ch <- offline + } + } + n.mu.Lock() + if n.conns[id] == c { + delete(n.conns, id) + delete(n.health, id) + } + n.mu.Unlock() +} + +func closeWS(ws *websocket.Conn, code int, reason string) { + go ws.Close(websocket.StatusCode(code), reason) +} + +func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true, CompressionMode: websocket.CompressionDisabled}) + if err != nil { + return + } + ws.SetReadLimit(hubMaxFrame) + if n.blocked(ip) { + ws.Close(4029, "rate_limited") + return + } + nb := make([]byte, 16) + rand.Read(nb) + nonce := b64url.EncodeToString(nb) + + var conn *nodeConn + var nodeID int64 + authTimer := time.AfterFunc(authTimeout, func() { closeWS(ws, 4001, "auth_timeout") }) + defer authTimer.Stop() + deny := func() { n.failed(ip); closeWS(ws, 4003, "unauthorized") } + + authenticate := func(m jsjson.Object) { + sig, sigOK := m.Get2("sig").(string) + if m.Get2("t") != "auth" || !isInt(m.Get2("node_id")) || !sigOK { + deny() + return + } + row := n.row(int64(m.Get2("node_id").(float64))) + if row == nil { + deny() + return + } + pubB64 := row.Pub.String + joining := !row.Pub.Valid + if joining { + secret, sOK := field(m, "join", "secret").(string) + pub, pOK := field(m, "join", "pub").(string) + if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || !sOK || !pOK { + deny() + return + } + h := sha256.Sum256(fromB64url(secret)) + if len(row.JoinHash) != len(h) || subtle.ConstantTimeCompare(h[:], row.JoinHash) != 1 { + deny() + return + } + pubB64 = pub + } + key := fromB64url(pubB64) + if len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, authMsg(nonce, row.ID), fromB64url(sig)) { + deny() + return + } + authTimer.Stop() + if joining { + n.a.db.Exec("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?", pubB64, row.ID) + } + // Версия — только для показа в панели: не подписана, но и доверия не даёт. + var version any + if v, ok := m.Get2("version").(string); ok && reVersion.MatchString(v) { + version = v + } + n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID) + + conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}} + nodeID = row.ID + n.mu.Lock() + old := n.conns[row.ID] + n.conns[row.ID] = conn + n.mu.Unlock() + if old != nil { + n.dropConn(row.ID, old) + closeWS(old.ws, 4000, "replaced") + } + ws.Write(context.Background(), websocket.MessageText, []byte(`{"t":"ready"}`)) + first := "" + if joining { + first = " (первое подключение)" + } + logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first) + go n.refreshHealth(row.ID) + go n.fireOnline(int(row.ID)) + } + + hello, _ := jsjson.Marshal(jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: nonce}}) + ws.Write(r.Context(), websocket.MessageText, hello) + + ctx := context.Background() + for { + _, raw, err := ws.Read(ctx) + if err != nil { + break + } + v, err := jsjson.Decode(raw) + if err != nil { + closeWS(ws, 4002, "bad_frame") + continue + } + m, _ := v.(jsjson.Object) + if conn == nil { + authenticate(m) + continue + } + if m.Get2("t") == "res" && isInt(m.Get2("id")) { + rid := int64(m.Get2("id").(float64)) + conn.mu.Lock() + p := conn.pending[rid] + delete(conn.pending, rid) + conn.mu.Unlock() + if p == nil || !p.timer.Stop() { + continue + } + st := jsToNumberAny(m.Get2("status")) + status := 502 + if st == st && st != 0 { + status = int(st) + } + p.ch <- &CtrlResp{status, m.Get2("data")} + } + } + if conn != nil { + n.dropConn(nodeID, conn) + logf("nodes: #%d отключена", nodeID) + } +} + +// Start поднимает хаб (если настроен) и периодические ping/health. +func (n *Nodes) Start() { + if n.local { + go n.refreshHealth(0) + go n.fireOnline(0) + } + if n.port == 0 || n.tls == nil { + return + } + mux := http.NewServeMux() + mux.HandleFunc("/node/v1", n.serveWS) + srv := &http.Server{Addr: fmt.Sprintf(":%d", n.port), Handler: mux, TLSConfig: n.tls, ReadHeaderTimeout: 15 * time.Second, + ErrorLog: quietLog()} + go func() { + ln, err := net.Listen("tcp", srv.Addr) + if err != nil { + warnf("nodes: хаб не запущен: %v", err) + return + } + logf("nodes: hub on :%d (wss)", n.port) + srv.ServeTLS(ln, "", "") + }() + + go func() { + for range time.Tick(pingEvery) { + n.mu.Lock() + conns := map[int64]*nodeConn{} + for id, c := range n.conns { + conns[id] = c + } + n.mu.Unlock() + for id, c := range conns { + c.mu.Lock() + alive := c.alive + c.alive = false + c.mu.Unlock() + if !alive { + c.ws.CloseNow() + n.dropConn(id, c) + continue + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), pingEvery) + defer cancel() + if c.ws.Ping(ctx) == nil { + c.mu.Lock() + c.alive = true + c.mu.Unlock() + } + }() + } + } + }() + go func() { + for range time.Tick(healthEvery) { + n.mu.Lock() + var ids []int64 + for id := range n.conns { + ids = append(ids, id) + } + n.mu.Unlock() + for _, id := range ids { + go n.refreshHealth(id) + } + } + }() +} + +// ── Роуты панели (/ui/nodes, за JWT) ──────────────────────────────────────── + +func (n *Nodes) hubReady() bool { return n.port > 0 && n.tls != nil && n.pin != nil } + +func (n *Nodes) joinString(host string, id int64, secret []byte) string { + b := jsjson.MustMarshal(jsjson.Object{{Key: "v", Val: 1}, {Key: "h", Val: host}, {Key: "p", Val: n.port}, + {Key: "pin", Val: b64url.EncodeToString(n.pin)}, {Key: "n", Val: id}, {Key: "s", Val: b64url.EncodeToString(secret)}}) + return "awgjoin://" + b64url.EncodeToString(b) +} + +var rePortSuffix = regexp.MustCompile(`:\d+$`) + +func (n *Nodes) hostOf(c *Ctx) string { + if given, ok := c.Obj().Get2("host").(string); ok && reHost.MatchString(given) { + return given + } + h := rePortSuffix.ReplaceAllString(c.R.Host, "") + return strings.TrimSuffix(strings.TrimPrefix(h, "["), "]") +} + +func nullStr(s sql.NullString) any { + if s.Valid { + return s.String + } + return nil +} + +func orNil(s string) any { + if s == "" { + return nil + } + return s +} + +// List — серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и метрик. +func (n *Nodes) List() []serverInfo { + var out []serverInfo + n.mu.Lock() + hs := map[int64]*health{} + for k, v := range n.health { + hs[k] = v + } + online := map[int64]bool{} + for k := range n.conns { + online[k] = true + } + n.mu.Unlock() + if n.local { + h := hs[0] + name := "VPN" + if h != nil { + name = h.Server + } + out = append(out, serverInfo{ID: 0, Name: name, Local: true, Online: h != nil, Version: orNil(n.a.version), Health: h}) + } + for _, r := range n.all() { + out = append(out, serverInfo{ID: int(r.ID), Name: r.Name, Online: online[r.ID], Pending: !r.Pub.Valid, + LastSeen: nullInt(r.LastSeen), Version: nullStr(r.Version), Health: hs[r.ID]}) + } + return out +} + +type serverInfo struct { + ID int + Name string + Local bool + Online bool + Pending bool + LastSeen any + Version any + Health *health +} + +func (s serverInfo) json() jsjson.Object { + return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}, {Key: "local", Val: s.Local}, {Key: "online", Val: s.Online}, + {Key: "pending", Val: s.Pending}, {Key: "last_seen", Val: s.LastSeen}, {Key: "version", Val: s.Version}, {Key: "health", Val: s.Health.json()}} +} + +func (n *Nodes) idOf(c *Ctx) (int64, bool) { + f := jsToNumber(c.Params["id"]) + return int64(f), f == float64(int64(f)) && f >= 1 +} + +func (n *Nodes) router() *Router { + rt := newRouter() + rt.Get("/", func(c *Ctx) bool { + if n.local { + n.refreshHealth(0) + } + list := []any{} + for _, s := range n.List() { + list = append(list, s.json()) + } + return c.JSON(200, jsjson.Object{{Key: "hub", Val: n.hubReady()}, {Key: "local", Val: n.local}, + {Key: "version", Val: orNil(n.a.version)}, {Key: "nodes", Val: list}}) + }) + rt.Post("/", func(c *Ctx) bool { + if !n.hubReady() { + return c.Error(503, "Приём нод не настроен (NODE_PORT / сертификат)") + } + name, ok := c.Obj().Get2("name").(string) + if !ok || !reNodeName.MatchString(name) || !lenOK(name, 1, 40) { + return c.Error(400, "Имя: до 40 символов, без переводов строк и <>") + } + host := n.hostOf(c) + if !reHost.MatchString(host) { + return c.Error(400, "Не удалось определить адрес core") + } + secret := make([]byte, 16) + rand.Read(secret) + h := sha256.Sum256(secret) + res, err := n.a.db.Exec("INSERT INTO nodes (name, join_hash, join_expires, created_at) VALUES (?, ?, ?, ?)", name, h[:], now()+joinTTL, now()) + if err != nil { + return c.Error(500, "Internal server error") + } + id, _ := res.LastInsertId() + return c.JSON(201, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name}, {Key: "join", Val: n.joinString(host, id, secret)}, {Key: "expires_in", Val: joinTTL}}) + }) + // Новая join-строка для ноды, которая ещё не подключалась (истёк срок / потеряли). + rt.Post("/:id/join", func(c *Ctx) bool { + if !n.hubReady() { + return c.Error(503, "Приём нод не настроен (NODE_PORT / сертификат)") + } + id, ok := n.idOf(c) + var row *nodeRow + if ok { + row = n.row(id) + } + if row == nil { + return c.Error(404, "Не найден") + } + if row.Pub.Valid { + return c.Error(409, "Нода уже подключена") + } + host := n.hostOf(c) + if !reHost.MatchString(host) { + return c.Error(400, "Не удалось определить адрес core") + } + secret := make([]byte, 16) + rand.Read(secret) + h := sha256.Sum256(secret) + n.a.db.Exec("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL", h[:], now()+joinTTL, id) + return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: row.Name}, {Key: "join", Val: n.joinString(host, id, secret)}, {Key: "expires_in", Val: joinTTL}}) + }) + rt.Patch("/:id", func(c *Ctx) bool { + id, ok := n.idOf(c) + if !ok || n.row(id) == nil { + return c.Error(404, "Не найден") + } + name, ok := c.Obj().Get2("name").(string) + if !ok || !reNodeName.MatchString(name) || !lenOK(name, 1, 40) { + return c.Error(400, "Неверное имя") + } + n.a.db.Exec("UPDATE nodes SET name = ? WHERE id = ?", name, id) + return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name}}) + }) + rt.Delete("/:id", func(c *Ctx) bool { + id, ok := n.idOf(c) + if !ok || n.row(id) == nil { + return c.Error(404, "Не найден") + } + // Ключи API и мастер-ключи проверяют их владельцы (BeforeDelete). + force := c.R.URL.Query().Get("force") == "1" + n.hmu.Lock() + before := append([]func(int, bool) *deleteVeto{}, n.beforeDelete...) + after := append([]func(int){}, n.afterDelete...) + n.hmu.Unlock() + for _, cb := range before { + veto := func() (v *deleteVeto) { + defer func() { + if recover() != nil { + v = &deleteVeto{"Не удалось подготовить сервер к удалению", true} + } + }() + return cb(int(id), force) + }() + if veto != nil { + return c.JSON(409, jsjson.Object{{Key: "error", Val: veto.Error}, {Key: "can_force", Val: veto.Forcible && !force}}) + } + } + if n.row(id) == nil { + return c.Error(404, "Не найден") + } + n.mu.Lock() + conn := n.conns[id] + n.mu.Unlock() + if conn != nil { + n.dropConn(id, conn) + closeWS(conn.ws, 4004, "removed") + } + n.a.db.Exec("DELETE FROM nodes WHERE id = ?", id) + // Хвосты подписки для этого сервера больше некому доделать. + for _, t := range []string{"peer_tombstones", "server_profiles"} { + n.a.db.Exec("DELETE FROM "+t+" WHERE server_id = ?", id) + } + for _, cb := range after { + func() { + defer func() { + if p := recover(); p != nil { + warnf("nodes: уборка после удаления #%d: %v", id, p) + } + }() + cb(int(id)) + }() + } + return c.JSON(200, jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: id}}) + }) + return rt +} diff --git a/internal/ui/sen.go b/internal/ui/sen.go new file mode 100644 index 0000000..10fe12c --- /dev/null +++ b/internal/ui/sen.go @@ -0,0 +1,430 @@ +package ui + +// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1 (порт awg-ui/sen.ts). +// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент реализуется по +// ней, поэтому любое изменение здесь — изменение протокола. + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "hash/crc32" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +const ( + senPrefix = "sen://" + senVersion = 1 + flagTLS = 0x01 + + addrV4 = 0x04 + addrV6 = 0x06 + addrDomain = 0x44 +) + +type senLinkError struct{ msg string } + +func (e *senLinkError) Error() string { return e.msg } + +func senErr(f string, a ...any) error { return &senLinkError{fmt.Sprintf(f, a...)} } + +type SenAddr struct { + Host string `json:"host"` + Port int `json:"port"` +} + +type SenLink struct { + TLS bool + Addrs []SenAddr + Secret []byte // 16 байт + SignPub []byte // 32 байта, Ed25519 + TLSPin []byte // 32 байта, только при TLS + Name string +} + +// b64url — Buffer.toString("base64url"): без паддинга. +var b64url = base64.RawURLEncoding + +// fromB64url повторяет Buffer.from(s, "base64url") в Node: терпит паддинг и «+/», хвостовой +// одиночный символ (неполный байт) молча отбрасывает, мусор после первого чужого символа — тоже. +func fromB64url(s string) []byte { + s = strings.NewReplacer("+", "-", "/", "_").Replace(s) + if i := strings.IndexFunc(s, func(r rune) bool { + return !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_') + }); i >= 0 { + s = s[:i] + } + if len(s)%4 == 1 { + s = s[:len(s)-1] + } + b, _ := b64url.DecodeString(s) + return b +} + +// fromB64 — Buffer.from(s, "base64") (обычный алфавит, тоже нестрого). +func fromB64(s string) []byte { + return fromB64url(strings.TrimRight(s, "=")) +} + +// ── Адреса ───────────────────────────────────────────────────────────────── + +var ( + reHex4 = regexp.MustCompile(`^[0-9a-fA-F]{1,4}$`) + reIPv4 = regexp.MustCompile(`^\d{1,3}(\.\d{1,3}){3}$`) + reDomain = regexp.MustCompile(`^[A-Za-z0-9.-]+$`) +) + +func ipv6ToBytes(host string) []byte { + parts := strings.Split(host, "::") + if len(parts) > 2 { + return nil + } + var head, tail []string + if parts[0] != "" { + head = strings.Split(parts[0], ":") + } + if len(parts) == 2 && parts[1] != "" { + tail = strings.Split(parts[1], ":") + } + missing := 8 - len(head) - len(tail) + if len(parts) == 1 && len(head) != 8 || len(parts) == 2 && missing < 1 { + return nil + } + groups := append([]string{}, head...) + if len(parts) == 2 { + for range missing { + groups = append(groups, "0") + } + } + groups = append(groups, tail...) + if len(groups) != 8 { + return nil + } + out := make([]byte, 16) + for i, g := range groups { + if !reHex4.MatchString(g) { + return nil + } + n, _ := strconv.ParseUint(g, 16, 16) + binary.BigEndian.PutUint16(out[i*2:], uint16(n)) + } + return out +} + +func bytesToIPv6(b []byte) string { + g := make([]string, 8) + for i := range 8 { + g[i] = strconv.FormatUint(uint64(binary.BigEndian.Uint16(b[i*2:])), 16) + } + // Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи. + bestStart, bestLen := -1, 0 + for i := 0; i < 8; { + if g[i] != "0" { + i++ + continue + } + j := i + for j < 8 && g[j] == "0" { + j++ + } + if j-i > bestLen { + bestStart, bestLen = i, j-i + } + i = j + } + if bestLen < 2 { + return strings.Join(g, ":") + } + return strings.Join(g[:bestStart], ":") + "::" + strings.Join(g[bestStart+bestLen:], ":") +} + +func encodeAddr(a SenAddr) ([]byte, error) { + if a.Port < 1 || a.Port > 65535 { + return nil, senErr("Неверный порт: %d", a.Port) + } + p := binary.BigEndian.AppendUint16(nil, uint16(a.Port)) + if reIPv4.MatchString(a.Host) { + out := []byte{addrV4} + for _, o := range strings.Split(a.Host, ".") { + n, _ := strconv.Atoi(o) + if n > 255 { + return nil, senErr("Неверный IPv4: %s", a.Host) + } + out = append(out, byte(n)) + } + return append(out, p...), nil + } + if strings.Contains(a.Host, ":") { + b := ipv6ToBytes(a.Host) + if b == nil { + return nil, senErr("Неверный IPv6: %s", a.Host) + } + return append(append([]byte{addrV6}, b...), p...), nil + } + if len(a.Host) < 1 || len(a.Host) > 253 || !reDomain.MatchString(a.Host) { + return nil, senErr("Неверный домен: %s", a.Host) + } + return append(append([]byte{addrDomain, byte(len(a.Host))}, a.Host...), p...), nil +} + +// ── Ссылка ───────────────────────────────────────────────────────────────── + +func EncodeSenLink(l SenLink) (string, error) { + if len(l.Addrs) < 1 || len(l.Addrs) > 3 { + return "", senErr("Адресов должно быть от 1 до 3") + } + if len(l.Secret) != 16 { + return "", senErr("secret: 16 байт") + } + if len(l.SignPub) != 32 { + return "", senErr("signPub: 32 байта") + } + if l.TLS && len(l.TLSPin) != 32 { + return "", senErr("tlsPin: 32 байта при TLS") + } + if len(l.Name) > 64 { + return "", senErr("Имя: до 64 байт") + } + var flags byte + if l.TLS { + flags = flagTLS + } + body := []byte{senVersion, flags, byte(len(l.Addrs))} + for _, a := range l.Addrs { + b, err := encodeAddr(a) + if err != nil { + return "", err + } + body = append(body, b...) + } + body = append(body, l.Secret...) + body = append(body, l.SignPub...) + if l.TLS { + body = append(body, l.TLSPin...) + } + body = append(body, byte(len(l.Name))) + body = append(body, l.Name...) + body = binary.BigEndian.AppendUint32(body, crc32.ChecksumIEEE(body)) + return senPrefix + b64url.EncodeToString(body), nil +} + +var ( + reB64urlChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + reSpaces = regexp.MustCompile(`\s+`) +) + +func DecodeSenLink(link string) (SenLink, error) { + var l SenLink + t := strings.TrimSpace(link) + if !strings.HasPrefix(strings.ToLower(t), senPrefix) { + return l, senErr("Ссылка должна начинаться с sen://") + } + payload := reSpaces.ReplaceAllString(t[len(senPrefix):], "") + if !reB64urlChars.MatchString(payload) { + return l, senErr("Ссылка повреждена: недопустимые символы") + } + raw := fromB64url(payload) + if len(raw) < 3+4 { + return l, senErr("Ссылка слишком короткая") + } + body := raw[:len(raw)-4] + if binary.BigEndian.Uint32(raw[len(raw)-4:]) != crc32.ChecksumIEEE(body) { + return l, senErr("Ссылка повреждена: не сошлась контрольная сумма") + } + + off := 0 + errCut := senErr("Ссылка обрезана") + take := func(n int) ([]byte, error) { + if off+n > len(body) { + return nil, errCut + } + s := body[off : off+n] + off += n + return s, nil + } + b1 := func() (byte, error) { + b, err := take(1) + if err != nil { + return 0, err + } + return b[0], nil + } + + version, err := b1() + if err != nil { + return l, err + } + if version != senVersion { + return l, senErr("Неизвестная версия ссылки: %d", version) + } + flags, err := b1() + if err != nil { + return l, err + } + if flags&^flagTLS != 0 { + return l, senErr("Неизвестные флаги ссылки") + } + n, err := b1() + if err != nil { + return l, err + } + if n < 1 || n > 3 { + return l, senErr("Неверное число адресов") + } + for range n { + typ, err := b1() + if err != nil { + return l, err + } + var host string + switch typ { + case addrV4: + b, err := take(4) + if err != nil { + return l, err + } + host = fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3]) + case addrV6: + b, err := take(16) + if err != nil { + return l, err + } + host = bytesToIPv6(b) + case addrDomain: + ln, err := b1() + if err != nil { + return l, err + } + if ln < 1 { + return l, senErr("Пустой домен") + } + b, err := take(int(ln)) + if err != nil { + return l, err + } + host = jsUTF8(b) + if !reDomain.MatchString(host) { + return l, senErr("Неверный домен") + } + default: + return l, senErr("Неизвестный тип адреса: %d", typ) + } + pb, err := take(2) + if err != nil { + return l, err + } + port := int(binary.BigEndian.Uint16(pb)) + if port == 0 { + return l, senErr("Порт 0") + } + l.Addrs = append(l.Addrs, SenAddr{host, port}) + } + if l.Secret, err = take(16); err != nil { + return l, err + } + if l.SignPub, err = take(32); err != nil { + return l, err + } + l.TLS = flags&flagTLS != 0 + if l.TLS { + if l.TLSPin, err = take(32); err != nil { + return l, err + } + } + nameLen, err := b1() + if err != nil { + return l, err + } + if nameLen > 64 { + return l, senErr("Имя длиннее 64 байт") + } + nb, err := take(int(nameLen)) + if err != nil { + return l, err + } + l.Name = jsUTF8(nb) + if off != len(body) { + return l, senErr("Лишние данные в конце ссылки") + } + l.Secret, l.SignPub = bytes.Clone(l.Secret), bytes.Clone(l.SignPub) + l.TLSPin = bytes.Clone(l.TLSPin) + return l, nil +} + +// jsUTF8 — Buffer.toString("utf8"): битые последовательности заменяются на U+FFFD. +func jsUTF8(b []byte) string { + if utf8.Valid(b) { + return string(b) + } + return strings.ToValidUTF8(string(b), "�") +} + +// ── Ключи Ed25519 ────────────────────────────────────────────────────────── + +// loadEd25519Private читает PKCS#8 PEM (как crypto.createPrivateKey в Node). +func loadEd25519Private(raw []byte) (ed25519.PrivateKey, error) { + blk, _ := pem.Decode(raw) + if blk == nil { + return nil, errors.New("not a PEM key") + } + k, err := x509.ParsePKCS8PrivateKey(blk.Bytes) + if err != nil { + return nil, err + } + priv, ok := k.(ed25519.PrivateKey) + if !ok { + return nil, errors.New("not an Ed25519 key") + } + return priv, nil +} + +// spkiPin — SHA-256 от SPKI DER сертификата (первого в PEM) — то, что кладётся в ссылку как pin. +func spkiPin(certPEM []byte) ([]byte, error) { + blk, _ := pem.Decode(certPEM) + if blk == nil { + return nil, errors.New("not a PEM certificate") + } + c, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + return nil, err + } + sum := sha256.Sum256(c.RawSubjectPublicKeyInfo) + return sum[:], nil +} + +// ── Подписи протокола ────────────────────────────────────────────────────── + +// signedResponse — {"body": "", "sig": ""}; sig — Ed25519 над +// UTF-8 байтами строки body. body — именно строка, чтобы клиенту не нужна была +// каноническая сериализация JSON. +func signResponse(payload any, key ed25519.PrivateKey) jsjson.Object { + body := string(jsjson.MustMarshal(payload)) + return jsjson.Object{{Key: "body", Val: body}, {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(key, []byte(body)))}} +} + +// requestSigString — METHOD \n /path \n ts \n hex(sha256(тело запроса)); path — без query. +func requestSigString(method, path string, ts int64, body []byte) string { + h := sha256.Sum256(body) + return strings.ToUpper(method) + "\n" + path + "\n" + strconv.FormatInt(ts, 10) + "\n" + hex.EncodeToString(h[:]) +} + +func verifyRequest(method, path string, ts int64, body []byte, sig string, authPub []byte) bool { + if len(authPub) != ed25519.PublicKeySize { + return false + } + return ed25519.Verify(authPub, []byte(requestSigString(method, path, ts, body)), fromB64url(sig)) +} + +func crc32IEEE(b []byte) uint32 { return crc32.ChecksumIEEE(b) } diff --git a/internal/ui/sen_test.go b/internal/ui/sen_test.go new file mode 100644 index 0000000..0476d6c --- /dev/null +++ b/internal/ui/sen_test.go @@ -0,0 +1,142 @@ +package ui + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "os" + "testing" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +// Эталон — вывод самого awg-ui/sen.ts (tools/golden/sen.ts). +type senFixture struct { + SignSeed string `json:"sign_seed"` + SignPub string `json:"sign_pub"` + CRC32 []struct { + In string `json:"in"` + Out uint32 `json:"out"` + } `json:"crc32"` + Encode []struct { + TLS bool `json:"tls"` + Addrs []SenAddr `json:"addrs"` + Secret string `json:"secret"` + TLSPin string `json:"tlsPin"` + Name string `json:"name"` + Out string `json:"out"` + Error string `json:"error"` + } `json:"encode"` + EncodeErrors []struct { + TLS bool `json:"tls"` + Addrs []SenAddr `json:"addrs"` + Secret string `json:"secret"` + Name string `json:"name"` + Error string `json:"error"` + } `json:"encode_errors"` + Decode []struct { + In string `json:"in"` + Error string `json:"error"` + TLS bool `json:"tls"` + Addrs []SenAddr `json:"addrs"` + Secret string `json:"secret"` + SignPub string `json:"signPub"` + TLSPin *string `json:"tlsPin"` + Name string `json:"name"` + } `json:"decode"` + SigString []struct { + Method, Path, Body, Out, Sig string + TS int64 `json:"ts"` + } `json:"sig_string"` + SignResponse []struct { + In json.RawMessage `json:"in"` + Out struct{ Body, Sig string } + } `json:"sign_response"` +} + +func unhex(t *testing.T, s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestSenAgainstNode(t *testing.T) { + raw, err := os.ReadFile("../../testdata/golden/sen.json") + if err != nil { + t.Fatal(err) + } + var f senFixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + key := ed25519.NewKeyFromSeed(unhex(t, f.SignSeed)) + signPub := key.Public().(ed25519.PublicKey) + if hex.EncodeToString(signPub) != f.SignPub { + t.Fatal("sign key mismatch") + } + + for _, c := range f.CRC32 { + if got := crc32IEEE([]byte(c.In)); got != c.Out { + t.Errorf("crc32(%q) = %d, want %d", c.In, got, c.Out) + } + } + for _, c := range f.Encode { + l := SenLink{TLS: c.TLS, Addrs: c.Addrs, Secret: unhex(t, c.Secret), SignPub: signPub, Name: c.Name} + if c.TLSPin != "" { + l.TLSPin = unhex(t, c.TLSPin) + } + got, err := EncodeSenLink(l) + if err != nil || got != c.Out { + t.Errorf("encode %+v:\n got %s %v\nwant %s", c.Addrs, got, err, c.Out) + } + } + for _, c := range f.EncodeErrors { + _, err := EncodeSenLink(SenLink{TLS: c.TLS, Addrs: c.Addrs, Secret: unhex(t, c.Secret), SignPub: signPub, Name: c.Name}) + if err == nil || err.Error() != c.Error { + t.Errorf("encode error %+v: got %v, want %s", c.Addrs, err, c.Error) + } + } + for _, c := range f.Decode { + l, err := DecodeSenLink(c.In) + if c.Error != "" { + if err == nil || err.Error() != c.Error { + t.Errorf("decode %q: got %v, want error %s", c.In, err, c.Error) + } + continue + } + if err != nil { + t.Errorf("decode %q: %v", c.In, err) + continue + } + gotAddrs, _ := json.Marshal(l.Addrs) + wantAddrs, _ := json.Marshal(c.Addrs) + pin := "" + if c.TLSPin != nil { + pin = *c.TLSPin + } + if l.TLS != c.TLS || string(gotAddrs) != string(wantAddrs) || hex.EncodeToString(l.Secret) != c.Secret || + hex.EncodeToString(l.SignPub) != c.SignPub || hex.EncodeToString(l.TLSPin) != pin || l.Name != c.Name { + t.Errorf("decode %q:\n got %+v %s", c.In, l, gotAddrs) + } + } + for _, c := range f.SigString { + if got := requestSigString(c.Method, c.Path, c.TS, []byte(c.Body)); got != c.Out { + t.Errorf("sig string: got %q want %q", got, c.Out) + } + if !verifyRequest(c.Method, c.Path, c.TS, []byte(c.Body), c.Sig, signPub) { + t.Errorf("Node signature for %s %s does not verify", c.Method, c.Path) + } + if verifyRequest(c.Method, c.Path, c.TS+1, []byte(c.Body), c.Sig, signPub) { + t.Error("signature verifies for another ts") + } + } + for _, c := range f.SignResponse { + v, _ := jsjson.Decode(c.In) + got := signResponse(v, key) + if got.Get2("body") != c.Out.Body || got.Get2("sig") != c.Out.Sig { + t.Errorf("signResponse: got %v want %+v", got, c.Out) + } + } +} diff --git a/internal/ui/server.go b/internal/ui/server.go new file mode 100644 index 0000000..86d4f9f --- /dev/null +++ b/internal/ui/server.go @@ -0,0 +1,949 @@ +// Package ui — awg-ui на Go (порт awg-ui/server.ts и модулей): веб-панель с JWT, прокси к +// awg-ctrl, публичный API /api/v1 по ключам, хаб нод (WSS), подписка sen:// и /metrics. +// Фронтенд тот же (React, собранный в dist/) — сервер его только раздаёт. +package ui + +import ( + "crypto/ed25519" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + _ "modernc.org/sqlite" + + "github.com/maeneko/forgetting/internal/iauth" + "github.com/maeneko/forgetting/internal/jsjson" +) + +// ── Журнал: как console.log / console.warn у Node-версии (CLI пишет оба потока в один файл) ── + +func logf(f string, a ...any) { fmt.Fprintf(os.Stdout, f+"\n", a...) } +func warnf(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) } + +var nowFn = time.Now + +func now() int64 { return nowFn().Unix() } + +func envOr(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +// envNum — Number(process.env.X) || 0. +func envNum(key string) int { + f := jsToNumber(os.Getenv(key)) + if f != f || f == 0 || f != float64(int(f)) { + return 0 + } + return int(f) +} + +// envFromFile — значение KEY=… из .env (первая строка, обрезанная). +func envFromFile(file, key string) string { + raw, err := os.ReadFile(file) + if err != nil { + return "" + } + m := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `\s*=\s*(.*)$`).FindSubmatch(raw) + if m == nil { + return "" + } + return strings.TrimSpace(string(m[1])) +} + +// ── Клиент awg-ctrl ───────────────────────────────────────────────────────── + +type CtrlResp struct { + Status int + Data any // разобранный JSON, иначе строка (как axios) +} + +// Ctrl — запрос к awg-ctrl сервера: локальному по HTTP или к ноде по WSS. Ошибка — только +// когда ответа нет вовсе (сеть, нет ключа); статусы ≥ 400 приходят как есть. +type Ctrl func(method, path string, body any) (*CtrlResp, error) + +// decodeLikeAxios — тело ответа так, как его отдал бы axios: JSON — значением, иначе строкой. +func decodeLikeAxios(raw []byte) any { + if len(raw) == 0 { + return "" + } + if v, err := jsjson.Decode(raw); err == nil { + return v + } + return string(raw) +} + +type App struct { + port int + uiUser string + uiPass string + jwtSecret []byte + brand string + channel string + version string + ctrlURL string + baseDir string + + internalKey ed25519.PrivateKey + httpLocal *http.Client + + db *sql.DB + + mu sync.Mutex + revoked map[string]bool + loginAttempts map[string]*rateEntry + + nodes *Nodes + sub *Sub + metrics *Metrics + monitor *Monitor +} + +// New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса +// (CLI запускает сервис с cwd = /awg-ui): там dist/, panel.id и ключи по умолчанию. +func New() (*App, error) { + cwd, _ := os.Getwd() + rootEnv := filepath.Join(cwd, "..", ".env") + pick := func(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + if v := envFromFile(rootEnv, key); v != "" { + return v + } + return def + } + a := &App{ + port: envNum("PORT"), + uiUser: envOr("UI_USER", "admin"), + uiPass: os.Getenv("UI_PASS"), + brand: pick("BRAND", "Forgetting"), + channel: pick("CHANNEL", "Beta"), + version: pick("VERSION", ""), + ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"), + baseDir: cwd, + // awg-ctrl на loopback: системный прокси сюда не нужен. + httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}}, + revoked: map[string]bool{}, + loginAttempts: map[string]*rateEntry{}, + } + if a.port == 0 { + a.port = 8080 + } + // Как `process.env.JWT_SECRET ?? random` в Node: заданный (даже пустой) секрет берётся как есть. + if s, ok := os.LookupEnv("JWT_SECRET"); ok { + a.jwtSecret = []byte(s) + } else { + b := make([]byte, 32) + rand.Read(b) + a.jwtSecret = []byte(hex.EncodeToString(b)) + } + + keyFile := envOr("INTERNAL_AUTH_KEY_FILE", filepath.Join(cwd, "internal_auth_private.key")) + if k, err := iauth.LoadPrivate(keyFile); err == nil { + a.internalKey = k + } else { + warnf("ui: приватный ключ внутренней авторизации не найден: %s — запросы к awg-ctrl будут отклоняться", keyFile) + } + + dbFile := os.Getenv("UI_DB_FILE") + if dbFile == "" { + dbFile = filepath.Join(cwd, "ui.db") + } + db, err := openUIDB(dbFile) + if err != nil { + return nil, err + } + a.db = db + if err := a.initAPIKeys(); err != nil { + return nil, err + } + + if a.nodes, err = newNodes(a); err != nil { + return nil, err + } + if a.sub, err = newSub(a); err != nil { + return nil, err + } + if a.metrics, err = newMetrics(a); err != nil { + return nil, err + } + a.monitor = newMonitor(a) + a.nodes.BeforeDelete(a.apiKeysBeforeDelete) + a.nodes.AfterDelete(a.apiKeysAfterDelete) + return a, nil +} + +func openUIDB(path string) (*sql.DB, error) { + dsn := "file:" + (&url.URL{Path: path}).EscapedPath() + + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + // Одно соединение: запросы и транзакции идут по очереди, как в однопоточном Node. + // Внутри tx() нельзя обращаться к a.db — только к переданному tx, иначе взаимоблокировка. + db.SetMaxOpenConns(1) + if err := db.Ping(); err != nil { + db.Close() + return nil, err + } + return db, nil +} + +// tx — uidb.transaction(fn)(): всё или ничего. +func (a *App) tx(fn func(tx *sql.Tx) error) error { + t, err := a.db.Begin() + if err != nil { + return err + } + if err := fn(t); err != nil { + t.Rollback() + return err + } + return t.Commit() +} + +// ctrlLocal — запрос к awg-ctrl этой машины с внутренним токеном. +func (a *App) ctrlLocal(method, path string, body any) (*CtrlResp, error) { + if a.internalKey == nil { + return nil, fmt.Errorf("приватный ключ внутренней авторизации не загружен") + } + var rd *strings.Reader + if body != nil { + b, err := jsjson.Marshal(body) + if err != nil { + return nil, err + } + rd = strings.NewReader(string(b)) + } + var req *http.Request + var err error + if rd != nil { + req, err = http.NewRequest(method, a.ctrlURL+path, rd) + } else { + req, err = http.NewRequest(method, a.ctrlURL+path, nil) + } + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-ui", nowFn())) + req.Header.Set("Content-Type", "application/json") + resp, err := a.httpLocal.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := readAll(resp) + if err != nil { + return nil, err + } + return &CtrlResp{resp.StatusCode, decodeLikeAxios(raw)}, nil +} + +// ── JWT панели (HS256) ────────────────────────────────────────────────────── + +func tokenSig(token string) string { + if p := strings.Split(token, "."); len(p) > 2 { + return p[2] + } + return token +} + +func (a *App) sign() string { + h := b64url.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + p := b64url.EncodeToString(jsjson.MustMarshal(jsjson.Object{{Key: "exp", Val: now() + 86400}})) + m := hmac.New(sha256.New, a.jwtSecret) + m.Write([]byte(h + "." + p)) + return h + "." + p + "." + b64url.EncodeToString(m.Sum(nil)) +} + +func (a *App) verify(token string) bool { + a.mu.Lock() + rev := a.revoked[tokenSig(token)] + a.mu.Unlock() + if rev { + return false + } + parts := strings.Split(token, ".") + if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return false + } + m := hmac.New(sha256.New, a.jwtSecret) + m.Write([]byte(parts[0] + "." + parts[1])) + if !hmac.Equal(fromB64url(parts[2]), m.Sum(nil)) { + return false + } + v, err := jsjson.Decode(fromB64url(parts[1])) + if err != nil { + return false + } + exp, ok := field(v, "exp").(float64) + return ok && exp > float64(now()) +} + +func bearer(r *http.Request) string { + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "Bearer ") { + return h[7:] + } + return "" +} + +func (a *App) requireAuth(c *Ctx) bool { + tok := bearer(c.R) + if tok == "" || !a.verify(tok) { + return c.Error(401, "Unauthorized") + } + return true +} + +// ── Лимит входа ───────────────────────────────────────────────────────────── + +const ( + rateLimit = 5 + rateWindow = 15 * time.Minute +) + +type rateEntry struct { + count int + resetAt time.Time +} + +func (a *App) checkRate(ip string) (bool, int) { + a.mu.Lock() + defer a.mu.Unlock() + t := nowFn() + e := a.loginAttempts[ip] + if e == nil || e.resetAt.Before(t) { + a.loginAttempts[ip] = &rateEntry{1, t.Add(rateWindow)} + return true, 0 + } + if e.count >= rateLimit { + ms := e.resetAt.Sub(t).Milliseconds() + return false, int((ms + 999) / 1000) + } + e.count++ + return true, 0 +} + +// ── Ключи внешнего API (/api/v1) ──────────────────────────────────────────── +// Живут в собственной БД awg-ui: awg-ctrl про них ничего не знает. Открытое значение +// ключа не хранится — только SHA-256 и префикс для показа в панели. + +type apiKeyRow struct { + ID int64 + Label string + KeyHash string + Prefix string + ServerID int + CreatedAt int64 + LastUsed sql.NullInt64 +} + +func (a *App) initAPIKeys() error { + _, err := a.db.Exec(` + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + server_id INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_used INTEGER + ); + -- Какие серверы видит ключ: на них он заводит vpn://-пользователей и в их пределах + -- управляет мастер-ключами. api_keys.server_id — сервер по умолчанию для /api/v1/users, + -- всегда входит в набор. + CREATE TABLE IF NOT EXISTS api_key_servers ( + key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + server_id INTEGER NOT NULL, + PRIMARY KEY (key_id, server_id) + ); + -- Ключи со времён одного сервера: набор — их server_id. + INSERT OR IGNORE INTO api_key_servers (key_id, server_id) + SELECT id, server_id FROM api_keys WHERE id NOT IN (SELECT key_id FROM api_key_servers); +`) + return err +} + +func scanKey(row interface{ Scan(...any) error }) (*apiKeyRow, error) { + k := &apiKeyRow{} + if err := row.Scan(&k.ID, &k.Label, &k.KeyHash, &k.Prefix, &k.ServerID, &k.CreatedAt, &k.LastUsed); err != nil { + return nil, err + } + return k, nil +} + +const keyCols = "id, label, key_hash, prefix, server_id, created_at, last_used" + +func (a *App) keyByID(q queryer, id int64) *apiKeyRow { + k, _ := scanKey(q.QueryRow("SELECT "+keyCols+" FROM api_keys WHERE id = ?", id)) + return k +} + +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) +} + +// ints — первая колонка всех строк. +func ints(q queryer, query string, args ...any) []int { + rows, err := q.Query(query, args...) + if err != nil { + return nil + } + defer rows.Close() + out := []int{} + for rows.Next() { + var n int + if rows.Scan(&n) == nil { + out = append(out, n) + } + } + return out +} + +func (a *App) keyServers(q queryer, id int64) []int { + return ints(q, "SELECT server_id FROM api_key_servers WHERE key_id = ? ORDER BY server_id", id) +} + +// setKeyServers — набор серверов ключа целиком. Сервер по умолчанию остаётся, если он в +// наборе, иначе — наименьший из набора. +func (a *App) setKeyServers(id int64, ids []int) error { + row := a.keyByID(a.db, id) + if row == nil { + return nil + } + return a.tx(func(tx *sql.Tx) error { + if _, err := tx.Exec("DELETE FROM api_key_servers WHERE key_id = ?", id); err != nil { + return err + } + for _, sid := range ids { + if _, err := tx.Exec("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)", id, sid); err != nil { + return err + } + } + if !containsInt(ids, row.ServerID) { + _, err := tx.Exec("UPDATE api_keys SET server_id = ? WHERE id = ?", ids[0], id) + return err + } + return nil + }) +} + +func containsInt(s []int, v int) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +// uniqueInts — [...new Set(v)] для массива из JSON: все элементы — целые серверы из реестра. +// Иначе — ok=false. +func (a *App) serverList(v any, exists func(int) bool) ([]int, bool) { + list := arr(v) + seen := map[float64]bool{} + var ids []int + for _, x := range list { + f, isNum := x.(float64) + if !isNum || !isInt(x) || !exists(int(f)) { + return nil, false + } + if !seen[f] { + seen[f] = true + ids = append(ids, int(f)) + } + } + sort.Ints(ids) + return ids, true +} + +// parseKeyServers — набор серверов из тела: непустой, без повторов, только существующие. +func (a *App) parseKeyServers(v any) ([]int, string) { + if l, ok := v.([]any); !ok || len(l) == 0 { + return nil, "Нужен хотя бы один сервер" + } + ids, ok := a.serverList(v, a.nodes.Exists) + if !ok { + return nil, "Такого сервера нет" + } + return ids, "" +} + +func hashKey(key string) string { + s := sha256.Sum256([]byte(key)) + return hex.EncodeToString(s[:]) +} + +func genAPIKey() (key, hash, prefix string) { + b := make([]byte, 24) + rand.Read(b) + key = "awgk_" + b64url.EncodeToString(b) + return key, hashKey(key), key[:13] + "…" +} + +var reKeyLabel = regexp.MustCompile(`^[\w \-]{1,40}$`) + +func intsJSON(s []int) []any { + out := make([]any, len(s)) + for i, v := range s { + out[i] = v + } + return out +} + +func nullInt(n sql.NullInt64) any { + if n.Valid { + return n.Int64 + } + return nil +} + +func (a *App) apiKeysList(c *Ctx) bool { + rows, err := a.db.Query("SELECT " + keyCols + " FROM api_keys ORDER BY id") + if err != nil { + return c.Error(500, "Internal server error") + } + var keys []*apiKeyRow + for rows.Next() { + if k, err := scanKey(rows); err == nil { + keys = append(keys, k) + } + } + rows.Close() + out := []any{} + for _, k := range keys { + out = append(out, jsjson.Object{ + {Key: "id", Val: k.ID}, {Key: "label", Val: k.Label}, {Key: "prefix", Val: k.Prefix}, + {Key: "server_id", Val: k.ServerID}, {Key: "created_at", Val: k.CreatedAt}, {Key: "last_used", Val: nullInt(k.LastUsed)}, + {Key: "servers", Val: intsJSON(a.keyServers(a.db, k.ID))}, + }) + } + return c.JSON(200, jsjson.Object{{Key: "keys", Val: out}}) +} + +// jsString — String(v) для меток: строка, число или логическое значение. Остальное — не метка. +func jsString(v any) (string, bool) { + switch x := v.(type) { + case string: + return x, true + case float64: + return jsjson.Number(x), true + case bool: + return strconv.FormatBool(x), true + } + return "", false +} + +// servers — набор серверов ключа; без него — server_id (по-старому) или сервер по умолчанию. +func (a *App) apiKeysCreate(c *Ctx) bool { + b := c.Obj() + label, labelOK := jsString(b.Get2("label")) + if !truthy(b.Get2("label")) || !labelOK || !reKeyLabel.MatchString(label) { + return c.Error(400, "Метка: буквы, цифры, пробел, _ и -, до 40 символов") + } + // server_id === undefined в JSON — только отсутствие ключа; null — это Number(null) = 0. + sidRaw, sidPresent := b.Get("server_id") + var serversV any + if s := b.Get2("servers"); s != nil { + serversV = s + } else if !sidPresent { + serversV = []any{float64(a.nodes.DefaultID())} + } else { + serversV = []any{jsToNumberAny(sidRaw)} + } + ids, msg := a.parseKeyServers(serversV) + if msg != "" { + return c.Error(400, msg) + } + sid := ids[0] + if sidPresent { + if f := jsToNumberAny(sidRaw); f == float64(int(f)) && containsInt(ids, int(f)) { + sid = int(f) + } + } + key, hash, prefix := genAPIKey() + var id int64 + err := a.tx(func(tx *sql.Tx) error { + r, err := tx.Exec("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)", + label, hash, prefix, sid, now()) + if err != nil { + return err + } + id, _ = r.LastInsertId() + for _, s := range ids { + if _, err := tx.Exec("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)", id, s); err != nil { + return err + } + } + return nil + }) + if err != nil { + return c.Error(500, "Internal server error") + } + // Открытое значение отдаётся один раз — дальше в БД только хэш. + return c.JSON(201, jsjson.Object{{Key: "id", Val: id}, {Key: "label", Val: b.Get2("label")}, {Key: "prefix", Val: prefix}, + {Key: "key", Val: key}, {Key: "server_id", Val: sid}, {Key: "servers", Val: intsJSON(ids)}}) +} + +// paramID — Number(req.params.id): ok, если целое. +func paramID(s string) (int64, bool) { + f := jsToNumber(s) + if f != f || f != float64(int64(f)) { + return 0, false + } + return int64(f), true +} + +func (a *App) apiKeysPatch(c *Ctx) bool { + id, ok := paramID(c.Params["id"]) + if !ok || a.keyByID(a.db, id) == nil { + return c.Error(404, "Не найден") + } + ids, msg := a.parseKeyServers(c.Obj().Get2("servers")) + if msg != "" { + return c.Error(400, msg) + } + if err := a.setKeyServers(id, ids); err != nil { + return c.Error(500, "Internal server error") + } + row := a.keyByID(a.db, id) + return c.JSON(200, jsjson.Object{{Key: "id", Val: id}, {Key: "server_id", Val: row.ServerID}, {Key: "servers", Val: intsJSON(ids)}}) +} + +func (a *App) apiKeysDelete(c *Ctx) bool { + id, ok := paramID(c.Params["id"]) + if !ok { + return c.Error(400, "Неверный id") + } + a.tx(func(tx *sql.Tx) error { + tx.Exec("DELETE FROM api_key_servers WHERE key_id = ?", id) + _, err := tx.Exec("DELETE FROM api_keys WHERE id = ?", id) + return err + }) + return c.JSON(200, jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: id}}) +} + +// Сервер удаляют из реестра: ключ API, для которого он единственный, — повод отказать +// (ключ остался бы без серверов); у остальных он убирается из набора, когда сервер удалён. +type keyOnServer struct { + id int64 + label string + serverID int + n int +} + +func (a *App) keysOnServer(id int) []keyOnServer { + rows, err := a.db.Query("SELECT k.id, k.label, k.server_id, (SELECT COUNT(*) FROM api_key_servers x WHERE x.key_id = k.id) AS n FROM api_keys k JOIN api_key_servers s ON s.key_id = k.id WHERE s.server_id = ?", id) + if err != nil { + return nil + } + defer rows.Close() + var out []keyOnServer + for rows.Next() { + var k keyOnServer + if rows.Scan(&k.id, &k.label, &k.serverID, &k.n) == nil { + out = append(out, k) + } + } + return out +} + +func (a *App) apiKeysBeforeDelete(id int, _ bool) *deleteVeto { + var only []string + for _, k := range a.keysOnServer(id) { + if k.n == 1 { + only = append(only, k.label) + } + } + if len(only) > 0 { + return &deleteVeto{Error: "Это единственный сервер ключей API: " + strings.Join(only, ", ") + " — сначала удалите их или добавьте им другой сервер"} + } + return nil +} + +func (a *App) apiKeysAfterDelete(id int) { + for _, k := range a.keysOnServer(id) { + var rest []int + for _, s := range a.keyServers(a.db, k.id) { + if s != id { + rest = append(rest, s) + } + } + a.setKeyServers(k.id, rest) + } +} + +// ── Внешний API: ключ и сервер ────────────────────────────────────────────── + +func (a *App) requireAPIKey(c *Ctx) bool { + key := c.R.Header.Get("X-Api-Key") + if !strings.HasPrefix(key, "awgk_") { + return c.Error(401, "API key required") + } + row, err := scanKey(a.db.QueryRow("SELECT "+keyCols+" FROM api_keys WHERE key_hash = ?", hashKey(key))) + if err != nil { + return c.Error(401, "Invalid API key") + } + a.db.Exec("UPDATE api_keys SET last_used = ? WHERE id = ?", now(), row.ID) + c.apiKey = row + c.servers = a.keyServers(a.db, row.ID) + return true +} + +// pickKeyServer — сервер для /api/v1/users: X-Server-Id из набора ключа, без заголовка — +// сервер ключа по умолчанию. Сервер вне набора — как несуществующий. +func (a *App) pickKeyServer(c *Ctx) bool { + id := c.apiKey.ServerID + if raw, ok := c.R.Header["X-Server-Id"]; ok { + f := jsToNumber(strings.Join(raw, ", ")) + if f != float64(int(f)) { + return c.Error(404, "Сервер не найден") + } + id = int(f) + } + if !containsInt(c.servers, id) { + return c.Error(404, "Сервер не найден") + } + c.serverID = id + return true +} + +// serverIDOf — сервер запроса панели: X-Server-Id (0 — локальный awg-ctrl), без него — по умолчанию. +func (a *App) serverIDOf(c *Ctx) int { + raw, ok := c.R.Header["X-Server-Id"] + if !ok { + return a.nodes.DefaultID() + } + f := jsToNumber(strings.Join(raw, ", ")) + if f == float64(int(f)) && f >= 0 { + return int(f) + } + return a.nodes.DefaultID() +} + +func (a *App) proxy(c *Ctx) bool { + r, err := a.nodes.CtrlFor(a.serverIDOf(c))(c.R.Method, c.R.RequestURI, c.Body) + if err != nil { + return c.Error(502, "awg-ctrl недоступен") + } + return c.JSON(r.Status, r.Data) +} + +// extUser — внешний контракт: наружу только name/ip/gen/vpn_key, psk_key и pub_key остаются внутри. +func extUser(d any) jsjson.Object { + o := jsjson.Object{} + for _, k := range [][2]string{{"name", "name"}, {"ip", "ip"}, {"gen", "key_gen"}, {"vpn_key", "vpn_key"}} { + if hasField(d, k[1]) { // undefined JSON.stringify опускает + o = append(o, jsjson.KV{Key: k[0], Val: field(d, k[1])}) + } + } + return o +} + +func hasField(v any, k string) bool { + o, ok := v.(jsjson.Object) + return ok && has(o, k) +} + +func (a *App) extCtrl(c *Ctx) Ctrl { return a.nodes.CtrlFor(c.serverID) } + +// unavailable — ответ, когда awg-ctrl не ответил (как wrap() в Node-версии). +func unavailable(c *Ctx) bool { return c.Error(502, "awg-ctrl недоступен") } + +func (a *App) extUsersCreate(c *Ctx) bool { + body := jsjson.Object{} + if v, ok := c.Obj().Get("name"); ok { + body = append(body, jsjson.KV{Key: "name", Val: v}) + } + r, err := a.extCtrl(c)("POST", "/api/users", body) + if err != nil { + return unavailable(c) + } + if r.Status >= 400 { + return c.JSON(r.Status, r.Data) + } + return c.JSON(201, extUser(r.Data)) +} + +func (a *App) extUsersList(c *Ctx) bool { + r, err := a.extCtrl(c)("GET", "/api/users/stats", nil) + if err != nil { + return unavailable(c) + } + return c.JSON(r.Status, r.Data) +} + +func (a *App) extUsersGet(c *Ctx) bool { + r, err := a.extCtrl(c)("POST", "/api/users/"+encodeURIComponent(c.Params["name"]), nil) + if err != nil { + return unavailable(c) + } + if r.Status >= 400 { + return c.JSON(r.Status, r.Data) + } + return c.JSON(200, extUser(r.Data)) +} + +func (a *App) extUsersDelete(c *Ctx) bool { + r, err := a.extCtrl(c)("DELETE", "/api/users/"+encodeURIComponent(c.Params["name"]), nil) + if err != nil { + return unavailable(c) + } + return c.JSON(r.Status, r.Data) +} + +// ── Маршруты ──────────────────────────────────────────────────────────────── + +func (a *App) login(c *Ctx) bool { + ip := c.remoteIP() + if ok, retry := a.checkRate(ip); !ok { + return c.Error(429, fmt.Sprintf("Слишком много попыток. Повтори через %d сек.", retry)) + } + b := c.Obj() + user, uok := b.Get2("user").(string) + pass, pok := b.Get2("pass").(string) + if a.uiPass == "" || !uok || !pok || user != a.uiUser || pass != a.uiPass { + return c.Error(401, "Неверный логин или пароль") + } + a.mu.Lock() + delete(a.loginAttempts, ip) + a.mu.Unlock() + return c.JSON(200, jsjson.Object{{Key: "token", Val: a.sign()}}) +} + +func (a *App) logout(c *Ctx) bool { + a.mu.Lock() + a.revoked[tokenSig(bearer(c.R))] = true + a.mu.Unlock() + return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}}) +} + +func (a *App) routes() *Router { + app := newRouter() + auth := a.requireAuth + + app.Post("/login", a.login) + app.Post("/logout", auth, a.logout) + // Единственный роут без авторизации: вордмарк нужен уже на экране логина. + app.Get("/ui/brand", func(c *Ctx) bool { + return c.JSON(200, jsjson.Object{{Key: "brand", Val: a.brand}, {Key: "channel", Val: a.channel}, {Key: "version", Val: a.version}}) + }) + app.Get("/ui/apikeys", auth, a.apiKeysList) + app.Post("/ui/apikeys", auth, a.apiKeysCreate) + app.Patch("/ui/apikeys/:id", auth, a.apiKeysPatch) + app.Delete("/ui/apikeys/:id", auth, a.apiKeysDelete) + + // ВАЖНО: /api/v1 — до JWT-прокси на /api, иначе прокси потребует JWT вместо ключа. + app.Use("/api/v1", a.requireAPIKey) + app.Use("/api/v1/users", a.pickKeyServer) + app.Post("/api/v1/users", a.extUsersCreate) + app.Get("/api/v1/users", a.extUsersList) + app.Get("/api/v1/users/:name", a.extUsersGet) + app.Delete("/api/v1/users/:name", a.extUsersDelete) + app.Mount("/api/v1/masterkeys", a.sub.apiRouter(func(c *Ctx) []int { return c.servers })) + + app.Use("/ui/nodes", auth) + app.Mount("/ui/nodes", a.nodes.router()) + app.Use("/ui/masterkeys", auth) + app.Mount("/ui/masterkeys", a.sub.masterKeysRouter()) + app.Use("/ui/devices", auth) + app.Mount("/ui/devices", a.sub.devicesRouter()) + + // Prometheus: /metrics за отдельным токеном «только метрики» (не JWT и не ключ API). + app.Use("/ui/metrics-token", auth) + app.Mount("/ui/metrics-token", a.metrics.router()) + app.Get("/metrics", a.metrics.requireToken, a.metrics.handler) + app.Use("/ui/monitor", auth) + app.Mount("/ui/monitor", a.monitor.router()) + + for _, p := range []string{"/api", "/health", "/awg"} { + app.Use(p, auth, a.proxy) + } + app.Use("/", a.static) + return app +} + +// static — express.static(dist) и SPA-фолбэк app.get("*") → dist/index.html. +func (a *App) static(c *Ctx) bool { + if c.R.Method != "GET" && c.R.Method != "HEAD" { + return true + } + dist := filepath.Join(a.baseDir, "dist") + p, err := url.PathUnescape(c.R.URL.EscapedPath()) + if err == nil && !strings.Contains(p, "\x00") { + clean := filepath.Clean("/" + p) + hidden := false + for _, s := range strings.Split(clean, "/") { + if strings.HasPrefix(s, ".") { + hidden = true + } + } + f := filepath.Join(dist, clean) + if st, err := os.Stat(f); err == nil && !hidden { + if st.IsDir() { + if !strings.HasSuffix(c.R.URL.Path, "/") { + http.Redirect(c.W, c.R, c.R.URL.Path+"/", http.StatusMovedPermanently) + return false + } + f = filepath.Join(f, "index.html") + } + if st, err := os.Stat(f); err == nil && !st.IsDir() { + serveFile(c.W, c.R, f) + return false + } + } + } + index := filepath.Join(dist, "index.html") + if _, err := os.Stat(index); err != nil { + writeHTMLError(c.W, 404, "Error: ENOENT: no such file or directory, stat '"+index+"'") + return false + } + serveFile(c.W, c.R, index) + return false +} + +func serveFile(w http.ResponseWriter, r *http.Request, f string) { + fh, err := os.Open(f) + if err != nil { + writeHTMLError(w, 404, "Not Found") + return + } + defer fh.Close() + st, _ := fh.Stat() + w.Header().Set("Cache-Control", "public, max-age=0") + http.ServeContent(w, r, st.Name(), st.ModTime(), fh) +} + +// Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку). +func (a *App) Run() error { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port)) + if err != nil { + return err + } + srv := &http.Server{Handler: a.routes(), ReadHeaderTimeout: 30 * time.Second} + logf("ui: listening on :%d", a.port) + a.nodes.Start() + a.sub.Start() + return srv.Serve(ln) +} diff --git a/internal/ui/sub.go b/internal/ui/sub.go new file mode 100644 index 0000000..57a5820 --- /dev/null +++ b/internal/ui/sub.go @@ -0,0 +1,1265 @@ +package ui + +// sen://-подписка (порт awg-ui/sub.ts): реестр мастер-ключей/устройств (ui.db), админ-роуты +// панели (/ui/masterkeys, /ui/devices, за JWT) и публичный листенер /sub/v1/* на отдельном +// порту SUB_PORT. Протокол и формат ссылки — docs/sen-link.md. +// +// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по публичному ключу +// (/api/peers*) и общий профиль сервера (/api/profile). +// +// Несколько серверов. Мастер-ключ отдаёт набор серверов (master_servers): локальный awg-ctrl +// (id 0) и/или ноды. Устройство регистрирует один ключ WireGuard, и он становится пиром на +// каждом сервере набора — IP и PSK на каждом свои (device_peers). Нода может быть не в сети, +// поэтому изменения не «толкаются», а сводятся: device_peers хранит, какой ключ сейчас стоит на +// сервере, peer_tombstones — что там надо удалить, а reconcile() приводит сервер к нужному +// состоянию, как только до него можно достучаться (сразу при действии, при подключении ноды +// и раз в минуту). +// +// Каждый проход заканчивается сведением пиров панели (PUT /api/peers/sync): awg-ctrl получает +// полный список ключей, которые этой панели нужны на сервере, и снимает из её области всё +// остальное. Область — id панели в owner пира («/m1/d2»): у ноды может быть своя панель +// на том же awg-ctrl, чужих не трогаем. Удаляем только по такому списку, никогда по времени. + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +const ( + tsWindow = 300 // секунд, ± + defaultLimit = 3 + maxServers = 32 // серверов в одном мастер-ключе + syncEvery = 60 * time.Second + auditHour = time.Hour + auditFirst = 2 * time.Minute // первый аудит — вскоре после старта, когда ноды подключились +) + +var ( + reB64Pub = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`) + reClientVersion = regexp.MustCompile(`^[\w.+-]{1,32}$`) // версия приложения, чисто информационная + reSubSecret = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`) + reDeviceID = regexp.MustCompile(`^[\w.-]{1,64}$`) + reAuthPub = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) +) + +// pubParam — pub_key WireGuard в пути к awg-ctrl: base64url (в обычном base64 есть «/»). +func pubParam(pub string) string { return b64url.EncodeToString(fromB64(pub)) } + +// ── Простой лимитер по IP ────────────────────────────────────────────────── +// onLimited зовётся один раз за окно — на первом отказе, чтобы лог не забивало. +type limiter struct { + mu sync.Mutex + hits map[string]*rateEntry + max int + window time.Duration + onLimited func(ip string) +} + +func newLimiter(max int, window time.Duration, onLimited func(string)) *limiter { + return &limiter{hits: map[string]*rateEntry{}, max: max, window: window, onLimited: onLimited} +} + +func (l *limiter) allow(ip string) bool { + l.mu.Lock() + t := nowFn() + e := l.hits[ip] + if e == nil || e.resetAt.Before(t) { + l.hits[ip] = &rateEntry{1, t.Add(l.window)} + l.mu.Unlock() + return true + } + if e.count >= l.max { + fire := e.count == l.max + if fire { + e.count++ + } + l.mu.Unlock() + if fire && l.onLimited != nil { + l.onLimited(ip) + } + return false + } + e.count++ + l.mu.Unlock() + return true +} + +// ── Строки реестра ────────────────────────────────────────────────────────── + +type masterRow struct { + ID int64 + UUID string + Label string + Secret []byte + DeviceLimit int64 + ServerID int64 + CreatedAt int64 + RevokedAt sql.NullInt64 + Devices int64 // только у запросов со счётчиком +} + +type deviceRow struct { + ID int64 + MasterID int64 + DeviceID string + DeviceName string + Platform string + PubKey string + AuthPub string + CreatedAt int64 + LastSeen sql.NullInt64 + RekeyRequested int64 + Version string +} + +type linkRow struct { + DeviceID int64 + ServerID int + PeerPub sql.NullString + IP sql.NullString + PSK sql.NullString + PSKPending int64 + DevPub string // linksToFix + MasterID int64 // linksToFix +} + +const ( + masterCols = "m.id, m.uuid, m.label, m.secret, m.device_limit, m.server_id, m.created_at, m.revoked_at" + deviceCols = "id, master_id, device_id, device_name, platform, pub_key, auth_pub, created_at, last_seen, rekey_requested, version" + linkCols = "device_id, server_id, peer_pub, ip, psk, psk_pending" +) + +func scanMaster(row interface{ Scan(...any) error }, withCount bool) *masterRow { + m := &masterRow{} + var u sql.NullString + dst := []any{&m.ID, &u, &m.Label, &m.Secret, &m.DeviceLimit, &m.ServerID, &m.CreatedAt, &m.RevokedAt} + if withCount { + dst = append(dst, &m.Devices) + } + if row.Scan(dst...) != nil { + return nil + } + m.UUID = u.String + return m +} + +func scanDevice(row interface{ Scan(...any) error }) *deviceRow { + d := &deviceRow{} + if row.Scan(&d.ID, &d.MasterID, &d.DeviceID, &d.DeviceName, &d.Platform, &d.PubKey, &d.AuthPub, &d.CreatedAt, &d.LastSeen, &d.RekeyRequested, &d.Version) != nil { + return nil + } + return d +} + +type Sub struct { + a *App + port int + useTLS bool + host string + panelID string + auditOn bool + + keyMu sync.RWMutex + signKey ed25519.PrivateKey // nil — подписка выключена + signPub []byte + tlsCert *tls.Certificate + tlsPin []byte + + locksMu sync.Mutex + locks map[int]*sync.Mutex // один проход на сервер за раз + + sigMu sync.Mutex + seenSigs map[string]time.Time // подпись → когда забыть + + registerLimit, failLimit *limiter + + auditMu sync.Mutex + lastAudit *auditReport + auditSettings auditSettings + cleanedLog []cleanedPeer + auditing chan struct{} // идёт проход: закроется по окончании +} + +func newSub(a *App) (*Sub, error) { + s := &Sub{ + a: a, port: envNum("SUB_PORT"), + useTLS: strings.ToLower(envOr("SUB_TLS", "off")) == "on", + host: strings.TrimSpace(os.Getenv("SUB_HOST")), + auditOn: strings.ToLower(envOr("AUDIT", "on")) != "off", + locks: map[int]*sync.Mutex{}, + seenSigs: map[string]time.Time{}, + } + limited := func(what string) func(string) { + return func(ip string) { + warnf("sub: 429 %s ip=%s — лимит исчерпан на 15 минут", what, ip) + } + } + s.registerLimit = newLimiter(30, 15*time.Minute, limited("register")) + s.failLimit = newLimiter(30, 15*time.Minute, limited("неудачные запросы")) + + file := func(env, name string) string { + if v := os.Getenv(env); v != "" { + return v + } + return filepath.Join(a.baseDir, name) + } + if raw, err := os.ReadFile(file("SUB_SIGN_KEY_FILE", "sub_sign.key")); err == nil { + if k, err := loadEd25519Private(raw); err == nil { + s.signKey, s.signPub = k, k.Public().(ed25519.PublicKey) + } + } + if s.signKey == nil { + warnf("sub: ключ подписи не найден — sen://-подписка отключена") + } + if s.useTLS { + keyF, crtF := file("SUB_TLS_KEY_FILE", "sub_tls.key"), file("SUB_TLS_CERT_FILE", "sub_tls.crt") + crt, err1 := os.ReadFile(crtF) + cert, err2 := tls.LoadX509KeyPair(crtF, keyF) + var pin []byte + var err3 error + if err1 == nil { + pin, err3 = spkiPin(crt) + } + if err1 != nil || err2 != nil || err3 != nil { + warnf("sub: SUB_TLS=on, но сертификат не найден — подписка отключена") + s.signKey = nil + } else { + s.tlsCert, s.tlsPin = &cert, pin + } + } + if err := s.initSchema(); err != nil { + return nil, err + } + s.loadAudit() + // Как в Node: подписки на события реестра — при создании, до старта хаба и до + // проверок ключей API (порядок вето при удалении сервера тот же). + a.nodes.OnOnline(func(id int) { go s.reconcile(id) }) + a.nodes.BeforeDelete(s.beforeServerDelete) + a.nodes.AfterDelete(s.afterServerDelete) + return s, nil +} + +func (s *Sub) initSchema() error { + db := s.a.db + if _, err := db.Exec(fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS master_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + secret BLOB NOT NULL UNIQUE, + device_limit INTEGER NOT NULL DEFAULT %d, + server_id INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + revoked_at INTEGER + ); + CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE, + device_id TEXT NOT NULL, + device_name TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + pub_key TEXT NOT NULL UNIQUE, + auth_pub TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_seen INTEGER, + rekey_requested INTEGER NOT NULL DEFAULT 0, + UNIQUE (master_id, device_id) + ); + -- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и + -- дальше не читается (кроме переноса ниже). + CREATE TABLE IF NOT EXISTS master_servers ( + master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE, + server_id INTEGER NOT NULL, + PRIMARY KEY (master_id, server_id) + ); + -- Пир устройства на сервере. peer_pub — ключ, который сейчас стоит на сервере + -- (NULL — ещё не добавлен); расходится с devices.pub_key после смены ключа, пока + -- сервер не догнали. ip/psk — как их выдал awg-ctrl этого сервера. + CREATE TABLE IF NOT EXISTS device_peers ( + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + server_id INTEGER NOT NULL, + peer_pub TEXT, + ip TEXT, + psk TEXT, + psk_pending INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (device_id, server_id) + ); + -- Пиры, которые надо удалить с сервера, когда до него дотянемся. + CREATE TABLE IF NOT EXISTS peer_tombstones ( + server_id INTEGER NOT NULL, + pub_key TEXT NOT NULL, + PRIMARY KEY (server_id, pub_key) + ); + -- Последний известный профиль сервера: config собирается из него, даже когда + -- нода не в сети, — иначе сервер пропадал бы из подписки на время обрыва. + CREATE TABLE IF NOT EXISTS server_profiles ( + server_id INTEGER PRIMARY KEY, + profile TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS sub_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `, defaultLimit)); err != nil { + return err + } + // Версия клиента — необязательное поле, добавленное позже. + if !hasColumn(db, "devices", "version") { + if _, err := db.Exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + } + // uuid мастер-ключа — постоянный идентификатор; существующим ключам выдаётся здесь один раз. + if !hasColumn(db, "master_keys", "uuid") { + if _, err := db.Exec("ALTER TABLE master_keys ADD COLUMN uuid TEXT"); err != nil { + return err + } + } + for _, id := range ints(db, "SELECT id FROM master_keys WHERE uuid IS NULL") { + db.Exec("UPDATE master_keys SET uuid = ? WHERE id = ?", uuid.NewString(), id) + } + if _, err := db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS master_keys_uuid ON master_keys (uuid)"); err != nil { + return err + } + // Перенос со времени одного сервера (см. sub.ts). + if _, err := db.Exec(` + INSERT OR IGNORE INTO master_servers (master_id, server_id) + SELECT id, server_id FROM master_keys + WHERE revoked_at IS NULL AND id NOT IN (SELECT master_id FROM master_servers); + INSERT OR IGNORE INTO device_peers (device_id, server_id, peer_pub) + SELECT d.id, m.server_id, d.pub_key FROM devices d JOIN master_keys m ON m.id = d.master_id + WHERE d.id NOT IN (SELECT device_id FROM device_peers); + `); err != nil { + return err + } + // Id панели — область её пиров на серверах. Копия в panel.id нужна CLI. + pb := make([]byte, 6) + rand.Read(pb) + db.Exec("INSERT OR IGNORE INTO sub_meta (key, value) VALUES ('panel_id', ?)", hex.EncodeToString(pb)) + if err := db.QueryRow("SELECT value FROM sub_meta WHERE key = 'panel_id'").Scan(&s.panelID); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(s.a.baseDir, "panel.id"), []byte(s.panelID+"\n"), 0o644); err != nil { + warnf("sub: не удалось записать panel.id: %v", err) + } + return nil +} + +// ── Запросы ───────────────────────────────────────────────────────────────── + +func (s *Sub) masterByID(q queryer, id int64) *masterRow { + return scanMaster(q.QueryRow("SELECT "+masterCols+" FROM master_keys m WHERE id = ? AND revoked_at IS NULL", id), false) +} + +func (s *Sub) masterRowByID(q queryer, id int64) *masterRow { + return scanMaster(q.QueryRow("SELECT "+masterCols+", (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE id = ?", id), true) +} + +func (s *Sub) masterBySecret(secret []byte) *masterRow { + return scanMaster(s.a.db.QueryRow("SELECT "+masterCols+" FROM master_keys m WHERE secret = ? AND revoked_at IS NULL", secret), false) +} + +func (s *Sub) deviceByID(q queryer, id int64) *deviceRow { + return scanDevice(q.QueryRow("SELECT "+deviceCols+" FROM devices WHERE id = ?", id)) +} + +func (s *Sub) devicesOf(q queryer, masterID int64) []*deviceRow { + rows, err := q.Query("SELECT "+deviceCols+" FROM devices WHERE master_id = ? ORDER BY id", masterID) + if err != nil { + return nil + } + defer rows.Close() + var out []*deviceRow + for rows.Next() { + if d := scanDevice(rows); d != nil { + out = append(out, d) + } + } + return out +} + +func (s *Sub) deviceCount(q queryer, masterID int64) int64 { + var n int64 + q.QueryRow("SELECT COUNT(*) AS n FROM devices WHERE master_id = ?", masterID).Scan(&n) + return n +} + +func (s *Sub) linksOf(q queryer, deviceID int64) []*linkRow { + rows, err := q.Query("SELECT "+linkCols+" FROM device_peers WHERE device_id = ? ORDER BY server_id", deviceID) + if err != nil { + return nil + } + defer rows.Close() + var out []*linkRow + for rows.Next() { + l := &linkRow{} + if rows.Scan(&l.DeviceID, &l.ServerID, &l.PeerPub, &l.IP, &l.PSK, &l.PSKPending) == nil { + out = append(out, l) + } + } + return out +} + +func (s *Sub) serverIDsOf(q queryer, masterID int64) []int { + return ints(q, "SELECT server_id FROM master_servers WHERE master_id = ? ORDER BY server_id", masterID) +} + +func strs(q queryer, query string, args ...any) []string { + rows, err := q.Query(query, args...) + if err != nil { + return nil + } + defer rows.Close() + out := []string{} + for rows.Next() { + var v string + if rows.Scan(&v) == nil { + out = append(out, v) + } + } + return out +} + +func (s *Sub) linkSet(q queryer, peerPub, ip, psk any, pending int64, deviceID int64, serverID int) { + q.Exec("UPDATE device_peers SET peer_pub = ?, ip = ?, psk = ?, psk_pending = ? WHERE device_id = ? AND server_id = ?", + peerPub, ip, psk, pending, deviceID, serverID) +} + +func (s *Sub) tombCount(id int) int64 { + var n int64 + s.a.db.QueryRow("SELECT COUNT(*) AS n FROM peer_tombstones WHERE server_id = ?", id).Scan(&n) + return n +} + +// keepOf — что панели нужно на сервере: текущий ключ каждого устройства и ключ, который там +// стоит сейчас (расходится с текущим, пока смена ключа не догнала сервер). +func (s *Sub) keepOf(id int) []string { + return strs(s.a.db, ` + SELECT d.pub_key AS pub FROM device_peers l JOIN devices d ON d.id = l.device_id WHERE l.server_id = ? + UNION SELECT peer_pub FROM device_peers WHERE server_id = ? AND peer_pub IS NOT NULL`, id, id) +} + +func ok2xx(st int) bool { return st >= 200 && st < 300 } + +// gone — связь пропала посреди прохода. +func gone(st int) bool { return st == 502 || st == 504 } + +// ── Сведение серверов ─────────────────────────────────────────────────────── + +func (s *Sub) storedProfile(id int) jsjson.Object { + var raw string + if s.a.db.QueryRow("SELECT profile FROM server_profiles WHERE server_id = ?", id).Scan(&raw) != nil { + return nil + } + v, err := jsjson.Decode([]byte(raw)) + if err != nil { + return nil + } + o, _ := v.(jsjson.Object) + return o +} + +func (s *Sub) refreshProfile(id int, ctrl Ctrl) (bool, error) { + r, err := ctrl("GET", "/api/profile", nil) + if err != nil { + return false, err + } + if _, isStr := field(r.Data, "endpoint").(string); !ok2xx(r.Status) || !isStr { + return false, nil + } + js := string(jsjson.MustMarshal(r.Data)) + var cur string + if s.a.db.QueryRow("SELECT profile FROM server_profiles WHERE server_id = ?", id).Scan(&cur) != nil || cur != js { + s.a.db.Exec("INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (?, ?, ?) ON CONFLICT(server_id) DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at", id, js, now()) + } + return true, nil +} + +func (s *Sub) lock(id int) *sync.Mutex { + s.locksMu.Lock() + defer s.locksMu.Unlock() + m := s.locks[id] + if m == nil { + m = &sync.Mutex{} + s.locks[id] = m + } + return m +} + +// reconcile — один проход на сервер за раз: параллельные проходы добавили бы одного пира дважды. +func (s *Sub) reconcile(id int) { + l := s.lock(id) + l.Lock() + defer l.Unlock() + if err := s.reconcileNow(id); err != nil { + warnf("sub: сервер #%d не сведён: %v", id, err) + } +} + +func (s *Sub) reconcileAll(ids []int) { + seen := map[int]bool{} + var wg sync.WaitGroup + for _, id := range ids { + if seen[id] { + continue + } + seen[id] = true + wg.Add(1) + go func() { defer wg.Done(); s.reconcile(id) }() + } + wg.Wait() +} + +type peerInfo struct{ ip, psk any } + +func (s *Sub) reconcileNow(id int) (err error) { + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("%v", p) + } + }() + if !s.a.nodes.Exists(id) || !s.a.nodes.IsOnline(id) { + return nil + } + ctrl := s.a.nodes.CtrlFor(id) + if ok, err := s.refreshProfile(id, ctrl); err != nil || !ok { + return err // сервер не отвечает — попробуем позже + } + + // Список пиров сервера — лениво, только если понадобится (409, потерянный ip/psk). + var listed map[string]jsjson.Object + onServer := func(pub string) (*peerInfo, error) { + if listed == nil { + r, err := ctrl("GET", "/api/peers", nil) + if err != nil { + return nil, err + } + if !ok2xx(r.Status) { + return nil, fmt.Errorf("peers %d", r.Status) + } + listed = map[string]jsjson.Object{} + for _, p := range arr(field(r.Data, "peers")) { + if o, ok := p.(jsjson.Object); ok { + if k, ok := o.Get2("pub_key").(string); ok { + listed[k] = o + } + } + } + } + if p, ok := listed[pub]; ok { + return &peerInfo{p.Get2("ip"), p.Get2("psk_key")}, nil + } + return nil, nil + } + asPeer := func(d any) *peerInfo { return &peerInfo{field(d, "ip"), field(d, "psk_key")} } + + for _, pub := range strs(s.a.db, "SELECT pub_key FROM peer_tombstones WHERE server_id = ?", id) { + r, err := ctrl("DELETE", "/api/peers/"+pubParam(pub), nil) + if err != nil { + return err + } + if ok2xx(r.Status) || r.Status == 404 { + s.a.db.Exec("DELETE FROM peer_tombstones WHERE server_id = ? AND pub_key = ?", id, pub) + } else if gone(r.Status) { + return nil + } + } + + rows, err := s.a.db.Query(` + SELECT l.device_id, l.server_id, l.peer_pub, l.ip, l.psk, l.psk_pending, d.pub_key AS dev_pub, d.master_id + FROM device_peers l JOIN devices d ON d.id = l.device_id + WHERE l.server_id = ? AND (l.peer_pub IS NULL OR l.peer_pub <> d.pub_key + OR l.ip IS NULL OR l.psk IS NULL OR l.psk_pending = 1)`, id) + if err != nil { + return err + } + var fix []*linkRow + for rows.Next() { + l := &linkRow{} + if rows.Scan(&l.DeviceID, &l.ServerID, &l.PeerPub, &l.IP, &l.PSK, &l.PSKPending, &l.DevPub, &l.MasterID) == nil { + fix = append(fix, l) + } + } + rows.Close() + + errGone := errors.New("gone") + for _, l := range fix { + // nil — не вышло, попробуем в следующий проход; errGone — связь пропала, проход прекращаем. + add := func() (*peerInfo, error) { + r, err := ctrl("POST", "/api/peers", jsjson.Object{{Key: "pub_key", Val: l.DevPub}, + {Key: "owner", Val: fmt.Sprintf("%s/m%d/d%d", s.panelID, l.MasterID, l.DeviceID)}}) + if err != nil { + return nil, err + } + switch { + case ok2xx(r.Status): + return asPeer(r.Data), nil + case r.Status == 409: // уже добавлен, но ответ потерялся + return onServer(l.DevPub) + case gone(r.Status): + return nil, errGone + } + return nil, nil + } + + var peer *peerInfo + var perr error + switch { + case !l.PeerPub.Valid: + peer, perr = add() + case l.PeerPub.String != l.DevPub: + // Ключ сменили, пока сервер был недоступен: тот же IP и PSK, новый ключ. + r, err := ctrl("PUT", "/api/peers/"+pubParam(l.PeerPub.String), jsjson.Object{{Key: "pub_key", Val: l.DevPub}}) + if err != nil { + return err + } + switch { + case ok2xx(r.Status): + peer = asPeer(r.Data) + case r.Status == 404: // старого пира на сервере уже нет + peer, perr = add() + case r.Status == 409: + peer, perr = onServer(l.DevPub) + case gone(r.Status): + perr = errGone + } + default: + // ip/psk не знаем или пир пропал с сервера + if peer, perr = onServer(l.DevPub); perr == nil && peer == nil { + peer, perr = add() + } + } + if perr == errGone { + return nil + } + if perr != nil { + return perr + } + if peer == nil { + continue + } + var pend int64 + if l.PSKPending == 1 { + r, err := ctrl("POST", "/api/peers/"+pubParam(l.DevPub)+"/psk", nil) + if err != nil { + return err + } + switch { + case ok2xx(r.Status): + peer = asPeer(r.Data) + case gone(r.Status): + return nil + default: + pend = 1 // пир на месте, PSK сменим позже + } + } + s.linkSet(s.a.db, l.DevPub, peer.ip, peer.psk, pend, l.DeviceID, id) + } + return s.syncPeers(id, ctrl) +} + +// syncPeers — сведение пиров панели на сервере, в конце прохода и под его блокировкой. +func (s *Sub) syncPeers(id int, ctrl Ctrl) error { + keep := []any{} + for _, k := range s.keepOf(id) { + keep = append(keep, k) + } + r, err := ctrl("PUT", "/api/peers/sync", jsjson.Object{{Key: "owner", Val: s.panelID}, {Key: "keep", Val: keep}}) + if err != nil { + return err + } + if ok2xx(r.Status) { + // Самолечение: ключ считается стоящим, а его нет — забываем и ставим заново следующим проходом. + present := map[string]bool{} + for _, p := range arr(field(r.Data, "present")) { + if ps, ok := p.(string); ok { + present[ps] = true + } + } + rows, err := s.a.db.Query(` + SELECT l.device_id, l.peer_pub FROM device_peers l JOIN devices d ON d.id = l.device_id + WHERE l.server_id = ? AND l.peer_pub = d.pub_key`, id) + if err != nil { + return err + } + type settled struct { + dev int64 + pub string + } + var all []settled + for rows.Next() { + var x settled + if rows.Scan(&x.dev, &x.pub) == nil { + all = append(all, x) + } + } + rows.Close() + lost := 0 + for _, x := range all { + if present[x.pub] { + continue + } + s.linkSet(s.a.db, nil, nil, nil, 0, x.dev, id) + lost++ + } + if lost > 0 { + go s.reconcile(id) + } + } else if r.Status != 400 && r.Status != 413 { + return nil // 400/413 — awg-ctrl старше sync: чистим, как раньше, одними надгробиями + } + if s.tombCount(id) > 0 { + return nil + } + s.a.db.Exec("DELETE FROM master_servers WHERE server_id = ? AND master_id IN (SELECT id FROM master_keys WHERE revoked_at IS NOT NULL)", id) + s.a.db.Exec("DELETE FROM master_keys WHERE revoked_at IS NOT NULL AND id NOT IN (SELECT master_id FROM master_servers)") + return nil +} + +// forgetDevice — устройство удаляется: его пиров — в список на удаление, строку — из реестра. +// Внутри уже открытой транзакции — с её tx. +func (s *Sub) forgetDevice(q queryer, dev *deviceRow) []int { + links := s.linksOf(q, dev.ID) + for _, l := range links { + if l.PeerPub.Valid { + q.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", l.ServerID, l.PeerPub.String) + } + } + q.Exec("DELETE FROM devices WHERE id = ?", dev.ID) // device_peers уйдут каскадом + ids := []int{} + for _, l := range links { + ids = append(ids, l.ServerID) + } + return ids +} + +func (s *Sub) forgetDeviceTx(dev *deviceRow) []int { + var ids []int + s.a.tx(func(tx *sql.Tx) error { ids = s.forgetDevice(tx, dev); return nil }) + return ids +} + +// ── config ────────────────────────────────────────────────────────────────── + +// hostOfEndpoint — endpoint.slice(0, endpoint.lastIndexOf(":")). +func hostOfEndpoint(ep string) string { + i := strings.LastIndex(ep, ":") + if i < 0 { + u := utf16Len(ep) + if u == 0 { + return "" + } + return sliceUTF16(ep, u-1) + } + return ep[:i] +} + +func (s *Sub) subHost(c *Ctx) string { + if s.host != "" { + return s.host + } + if s.a.nodes.Exists(0) { + if p := s.storedProfile(0); p != nil { + if ep, ok := p.Get2("endpoint").(string); ok { + return hostOfEndpoint(ep) + } + } + } + if c == nil { + return "" + } + return strings.TrimSuffix(strings.TrimPrefix(c.hostname(), "["), "]") +} + +// buildConfig — в servers[] только серверы, где уже стоит текущий ключ устройства и известны +// ip/psk. rev меняется при любом изменении; клиент по нему решает, трогать ли туннель. +func (s *Sub) buildConfig(dev *deviceRow, c *Ctx) jsjson.Object { + list := []any{} + for _, l := range s.linksOf(s.a.db, dev.ID) { + prof := s.storedProfile(l.ServerID) + if prof == nil || !l.PeerPub.Valid || l.PeerPub.String != dev.PubKey || !l.IP.Valid || l.IP.String == "" || !l.PSK.Valid || l.PSK.String == "" { + continue + } + // Поле, которого в профиле нет, JSON.stringify опустил бы (undefined) — опускаем и мы: + // от этого зависит rev. + e := jsjson.Object{{Key: "id", Val: l.ServerID}} + pf := func(k string) { + if v, ok := prof.Get(k); ok { + e = append(e, jsjson.KV{Key: k, Val: v}) + } + } + pf("name") + pf("endpoint") + pf("server_pub") + e = append(e, jsjson.KV{Key: "psk", Val: l.PSK.String}, jsjson.KV{Key: "address", Val: l.IP.String + "/32"}) + for _, k := range []string{"dns", "keepalive", "mtu", "gen", "awg"} { + pf(k) + } + list = append(list, e) + } + cfg := jsjson.Object{ + {Key: "rekey", Val: dev.RekeyRequested == 1}, + {Key: "endpoints", Val: []any{fmt.Sprintf("%s:%d", s.subHost(c), s.port)}}, + {Key: "servers", Val: list}, + } + sum := sha256.Sum256(jsjson.MustMarshal(cfg)) + return append(jsjson.Object{{Key: "rev", Val: hex.EncodeToString(sum[:])[:16]}}, cfg...) +} + +func configServers(cfg jsjson.Object) int { return len(arr(cfg.Get2("servers"))) } + +// readyConfig — конфиг, в котором есть хотя бы один сервер. Пустой не отдаём: клиент снёс бы +// туннель из-за временной недоступности нод. +func (s *Sub) readyConfig(dev *deviceRow, c *Ctx) (jsjson.Object, error) { + cfg := s.buildConfig(dev, c) + if configServers(cfg) == 0 { + var ids []int + for _, l := range s.linksOf(s.a.db, dev.ID) { + ids = append(ids, l.ServerID) + } + s.reconcileAll(ids) + if d := s.deviceByID(s.a.db, dev.ID); d != nil { + cfg = s.buildConfig(d, c) + } + } + if configServers(cfg) == 0 { + return nil, errors.New("no servers") + } + return cfg, nil +} + +// ── Публичная часть: /sub/v1 ──────────────────────────────────────────────── + +func (s *Sub) key() ed25519.PrivateKey { + s.keyMu.RLock() + defer s.keyMu.RUnlock() + return s.signKey +} + +// send — ответ подписан и несёт эхо запроса: device (X-Sen-Device) и ts (X-Sen-Ts). Поля +// ответа важнее эха: device из тела register не перетрёт лишний заголовок. +func (s *Sub) send(w http.ResponseWriter, r *http.Request, status int, payload jsjson.Object) bool { + out := jsjson.Object{} + for _, h := range []struct{ hdr, key string }{{"X-Sen-Device", "device"}, {"X-Sen-Ts", "ts"}} { + if v, ok := r.Header[h.hdr]; ok { + if f := jsToNumber(strings.Join(v, ", ")); isSafeInt(f) { + out = append(out, jsjson.KV{Key: h.key, Val: f}) + } + } + } + for _, kv := range payload { + replaced := false + for i := range out { + if out[i].Key == kv.Key { + out[i].Val, replaced = kv.Val, true + } + } + if !replaced { + out = append(out, kv) + } + } + writeJSON(w, status, signResponse(out, s.key())) + return false +} + +func (s *Sub) sendC(c *Ctx, status int, payload jsjson.Object) bool { + return s.send(c.W, c.R, status, payload) +} + +func subErr(code string) jsjson.Object { return jsjson.Object{{Key: "error", Val: code}} } + +// reqPath — req.path: путь как пришёл, без query. +func reqPath(r *http.Request) string { + p := r.RequestURI + if i := strings.IndexByte(p, '?'); i >= 0 { + p = p[:i] + } + return p +} + +// signFailure — подпись запроса и защита от повторов. "" — всё верно, иначе причина (для лога). +func (s *Sub) signFailure(c *Ctx, authPub []byte) string { + tsF := jsToNumber(strings.Join(c.R.Header["X-Sen-Ts"], ", ")) + if _, has := c.R.Header["X-Sen-Ts"]; !has { + tsF = jsToNumber("x") + } + sig := c.R.Header.Get("X-Sen-Sig") + if tsF != float64(int64(tsF)) || sig == "" { + return "no_signature" + } + ts := int64(tsF) + if d := ts - now(); d > tsWindow || d < -tsWindow { + return fmt.Sprintf("ts_window (часы клиента расходятся на %d с)", d) + } + if !verifyRequest(c.R.Method, reqPath(c.R), ts, c.RawBody, sig, authPub) { + return "bad_signature" + } + s.sigMu.Lock() + defer s.sigMu.Unlock() + t := nowFn() + for k, exp := range s.seenSigs { + if exp.Before(t) { + delete(s.seenSigs, k) + } + } + if _, ok := s.seenSigs[sig]; ok { + return "replay" + } + s.seenSigs[sig] = t.Add(2 * tsWindow * time.Second) + return "" +} + +func (s *Sub) deviceAuth(c *Ctx) bool { + raw, hasHdr := c.R.Header["X-Sen-Device"] + f := jsToNumber(strings.Join(raw, ", ")) + valid := hasHdr && isSafeInt(f) && f > 0 + var dev *deviceRow + if valid { + dev = s.deviceByID(s.a.db, int64(f)) + } + // Устройства нет — его удалили (id не переиспользуются). 410, чтобы клиент снял конфиг, и + // не в лимит неудач: такие клиенты опрашивают по таймеру и выбивали бы лимит соседям за NAT. + if dev == nil && valid { + return s.sendC(c, 410, subErr("revoked")) + } + why := "no_device" + if dev != nil { + why = s.signFailure(c, fromB64url(dev.AuthPub)) + } + if why == "" { + c.device = dev + return true + } + ip := c.remoteIP() + if !s.failLimit.allow(ip) { + return s.sendC(c, 429, subErr("rate_limited")) + } + hdr := c.R.Header.Get("X-Sen-Device") + if !hasHdr { + hdr = "-" + } + warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), hdr, why, ip) + return s.sendC(c, 401, subErr("unauthorized")) +} + +// wrapSub — ошибки обработчика (awg-ctrl не ответил, нет серверов) — подписанный 502. +func (s *Sub) wrapSub(fn func(c *Ctx) error) handler { + return func(c *Ctx) bool { + defer func() { + if p := recover(); p != nil { + s.sendC(c, 502, subErr("unavailable")) + } + }() + if err := fn(c); err != nil { + return s.sendC(c, 502, subErr("unavailable")) + } + return false + } +} + +var errLimit = errors.New("limit") + +func (s *Sub) register(c *Ctx) error { + ip := c.remoteIP() + if !s.registerLimit.allow(ip) { + s.sendC(c, 429, subErr("rate_limited")) + return nil + } + b := c.Obj() + strRe := func(k string, re *regexp.Regexp) (string, bool) { + v, ok := b.Get2(k).(string) + return v, ok && re.MatchString(v) + } + secretB, ok1 := strRe("sub", reSubSecret) + deviceID, ok2 := strRe("device_id", reDeviceID) + pubKey, ok3 := strRe("pub_key", reB64Pub) + authPubS, ok4 := strRe("auth_pub", reAuthPub) + name, platform := "", "" + if v, ok := b.Get2("device_name").(string); ok { + name = sliceUTF16(v, 64) + } + if v, ok := b.Get2("platform").(string); ok { + platform = sliceUTF16(v, 32) + } + version, ok5 := strRe("version", reClientVersion) + if !ok5 { + version = "" + } + if !ok1 || !ok2 || !ok3 || !ok4 { + s.sendC(c, 400, subErr("bad_request")) + return nil + } + + // Регистрация подписана ключом auth_pub из самого тела — доказательство владения. + authPub := fromB64url(authPubS) + why := "bad_auth_pub" + if len(authPub) == 32 { + why = s.signFailure(c, authPub) + } + if why != "" { + warnf("sub: 401 register %s ip=%s", why, ip) + s.sendC(c, 401, subErr("unauthorized")) + return nil + } + + master := s.masterBySecret(fromB64url(secretB)) + if master == nil { + s.sendC(c, 404, subErr("not_found")) + return nil + } + var serverIDs []int + for _, id := range s.serverIDsOf(s.a.db, master.ID) { + if s.a.nodes.Exists(id) { + serverIDs = append(serverIDs, id) + } + } + if len(serverIDs) == 0 { + s.sendC(c, 502, subErr("unavailable")) + return nil + } + + var rowID int64 + var touched []int + err := s.a.tx(func(tx *sql.Tx) error { + if existing := scanDevice(tx.QueryRow("SELECT "+deviceCols+" FROM devices WHERE master_id = ? AND device_id = ?", master.ID, deviceID)); existing != nil { + touched = s.forgetDevice(tx, existing) // переустановка — старые пиры на удаление + } else if s.deviceCount(tx, master.ID) >= master.DeviceLimit { + return errLimit + } + res, err := tx.Exec("INSERT INTO devices (master_id, device_id, device_name, platform, pub_key, auth_pub, created_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + master.ID, deviceID, name, platform, pubKey, authPubS, now(), version) + if err != nil { + return err + } + rowID, _ = res.LastInsertId() + for _, sid := range serverIDs { + if _, err := tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", rowID, sid); err != nil { + return err + } + } + return nil + }) + if err == errLimit { + s.sendC(c, 403, subErr("device_limit")) + return nil + } + if err != nil { + s.sendC(c, 409, subErr("conflict")) // UNIQUE: pub_key/auth_pub уже заняты + return nil + } + + s.reconcileAll(append(touched, serverIDs...)) + dev := s.deviceByID(s.a.db, rowID) + if dev == nil { + return errors.New("device vanished") + } + cfg := s.buildConfig(dev, c) + if configServers(cfg) == 0 { + // Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже. + s.reconcileAll(s.forgetDeviceTx(dev)) + s.sendC(c, 502, subErr("unavailable")) + return nil + } + s.sendC(c, 201, jsjson.Object{{Key: "device", Val: dev.ID}, {Key: "config", Val: cfg}, {Key: "key_uuid", Val: master.UUID}, + {Key: "devices", Val: s.deviceCount(s.a.db, master.ID)}, {Key: "device_limit", Val: master.DeviceLimit}}) + return nil +} + +// peek — сколько мест у ключа занято, по секрету из ссылки (устройства ещё нет). +func (s *Sub) peek(c *Ctx) error { + ip := c.remoteIP() + secretB, ok := c.Obj().Get2("sub").(string) + ok = ok && reSubSecret.MatchString(secretB) + var master *masterRow + if ok { + master = s.masterBySecret(fromB64url(secretB)) + } + if master == nil { + if !s.failLimit.allow(ip) { + s.sendC(c, 429, subErr("rate_limited")) + return nil + } + if ok { + s.sendC(c, 404, subErr("not_found")) + } else { + s.sendC(c, 400, subErr("bad_request")) + } + return nil + } + s.sendC(c, 200, jsjson.Object{{Key: "key_uuid", Val: master.UUID}, {Key: "devices", Val: s.deviceCount(s.a.db, master.ID)}, + {Key: "device_limit", Val: master.DeviceLimit}}) + return nil +} + +func (s *Sub) config(c *Ctx) error { + dev := c.device + s.a.db.Exec("UPDATE devices SET last_seen = ? WHERE id = ?", now(), dev.ID) + // Версия клиента меняется при обновлении приложения; заголовок не подписан — лишь подпись в панели. + if v := strings.Join(c.R.Header["X-Sen-Version"], ", "); reClientVersion.MatchString(v) && v != dev.Version { + s.a.db.Exec("UPDATE devices SET version = ? WHERE id = ?", v, dev.ID) + } + cfg, err := s.readyConfig(dev, c) + if err != nil { + return err + } + s.sendC(c, 200, jsjson.Object{{Key: "config", Val: cfg}}) + return nil +} + +func (s *Sub) devicesList(c *Ctx) error { + dev := c.device + master := s.masterByID(s.a.db, dev.MasterID) + if master == nil { + s.sendC(c, 404, subErr("not_found")) + return nil + } + list := []any{} + for _, d := range s.devicesOf(s.a.db, master.ID) { + list = append(list, jsjson.Object{{Key: "id", Val: d.ID}, {Key: "name", Val: d.DeviceName}, {Key: "platform", Val: d.Platform}, + {Key: "version", Val: d.Version}, {Key: "created_at", Val: d.CreatedAt}, {Key: "last_seen", Val: nullInt(d.LastSeen)}, + {Key: "current", Val: d.ID == dev.ID}}) + } + s.sendC(c, 200, jsjson.Object{{Key: "name", Val: master.Label}, {Key: "key_uuid", Val: master.UUID}, + {Key: "device_limit", Val: master.DeviceLimit}, {Key: "devices", Val: list}}) + return nil +} + +// rekey — новый ключ ставится на все серверы ключа; если не принял ни один — ключ не меняем. +func (s *Sub) rekey(c *Ctx) error { + dev := c.device + pub, ok := c.Obj().Get2("pub_key").(string) + if !ok || !reB64Pub.MatchString(pub) { + s.sendC(c, 400, subErr("bad_request")) + return nil + } + if pub == dev.PubKey { + cfg, err := s.readyConfig(dev, c) + if err != nil { + return err + } + s.sendC(c, 200, jsjson.Object{{Key: "config", Val: cfg}}) + return nil + } + if _, err := s.a.db.Exec("UPDATE devices SET pub_key = ?, rekey_requested = ? WHERE id = ?", pub, 0, dev.ID); err != nil { + s.sendC(c, 409, subErr("conflict")) // этот ключ уже у другого устройства + return nil + } + var ids []int + for _, l := range s.linksOf(s.a.db, dev.ID) { + ids = append(ids, l.ServerID) + } + s.reconcileAll(ids) + d := s.deviceByID(s.a.db, dev.ID) + if d == nil { + return errors.New("device vanished") + } + cfg := s.buildConfig(d, c) + if configServers(cfg) == 0 { + s.a.db.Exec("UPDATE devices SET pub_key = ?, rekey_requested = ? WHERE id = ?", dev.PubKey, dev.RekeyRequested, dev.ID) + s.reconcileAll(ids) // вернуть старый ключ туда, где успели сменить + s.sendC(c, 502, subErr("unavailable")) + return nil + } + s.sendC(c, 200, jsjson.Object{{Key: "config", Val: cfg}}) + return nil +} + +func (s *Sub) deviceDelete(c *Ctx) error { + s.reconcileAll(s.forgetDeviceTx(c.device)) + s.sendC(c, 200, jsjson.Object{{Key: "ok", Val: true}}) + return nil +} + +func (s *Sub) publicRouter() *Router { + rt := newRouter() + rt.bodyLimit = 4 * 1024 + // Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять. + rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) { + s.send(w, r, 400, subErr("bad_request")) + } + rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) } + rt.Post("/sub/v1/register", s.wrapSub(s.register)) + rt.Post("/sub/v1/peek", s.wrapSub(s.peek)) + rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config)) + rt.Get("/sub/v1/devices", s.deviceAuth, s.wrapSub(s.devicesList)) + rt.Post("/sub/v1/rekey", s.deviceAuth, s.wrapSub(s.rekey)) + rt.Delete("/sub/v1/device", s.deviceAuth, s.wrapSub(s.deviceDelete)) + return rt +} + +// ── Листенер и фоновое сведение ───────────────────────────────────────────── + +func (s *Sub) usedServers() []int { + return ints(s.a.db, "SELECT server_id FROM master_servers UNION SELECT server_id FROM peer_tombstones") +} + +// Start — фоновое сведение, плановый аудит и публичный листенер /sub/v1. +func (s *Sub) Start() { + // Сводим и без подписки: у ключей могут остаться хвосты на удаление. + go func() { + for range time.Tick(syncEvery) { + s.reconcileAll(s.usedServers()) + } + }() + if s.auditOn { + // Период читается из настроек каждый раз: смена в панели действует без рестарта. + go func() { + wait := auditFirst + for { + time.Sleep(wait) + s.auditAll(true) + s.auditMu.Lock() + wait = time.Duration(s.auditSettings.EveryH) * auditHour + s.auditMu.Unlock() + } + }() + } + + if s.key() == nil { + return + } + if s.port == 0 { + warnf("sub: SUB_PORT не задан — подписка отключена") + s.keyMu.Lock() + s.signKey = nil + s.keyMu.Unlock() + return + } + srv := &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: s.publicRouter(), ReadHeaderTimeout: 15 * time.Second, ErrorLog: quietLog()} + ln, err := net.Listen("tcp", srv.Addr) + if err != nil { + warnf("sub: листенер не запущен: %v", err) + return + } + scheme := "http" + if s.tlsCert != nil { + scheme = "https" + srv.TLSConfig = &tls.Config{Certificates: []tls.Certificate{*s.tlsCert}} + } + logf("sub: listening on :%d (%s)", s.port, scheme) + go func() { + if s.tlsCert != nil { + srv.ServeTLS(ln, "", "") + } else { + srv.Serve(ln) + } + }() +} diff --git a/internal/ui/sub_admin.go b/internal/ui/sub_admin.go new file mode 100644 index 0000000..f5b522c --- /dev/null +++ b/internal/ui/sub_admin.go @@ -0,0 +1,804 @@ +package ui + +// Мастер-ключи: общая логика панели и внешнего API (порт части awg-ui/sub.ts). Панель +// (/ui/masterkeys, /ui/devices, за JWT) видит всё. Внешний API (/api/v1/masterkeys, ключ +// awgk_) — только ключи, чьи серверы целиком входят в набор серверов API-ключа: иначе он мог бы +// удалить или перенастроить раздачу на сервере, которого не видит. scope: nil — панель. + +import ( + "crypto/rand" + "database/sql" + "errors" + "fmt" + "regexp" + "sort" + "strings" + "sync" + + "github.com/google/uuid" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +type apiError struct { + status int + msg string +} + +func (e *apiError) Error() string { return e.msg } + +func fail(status int, msg string) error { return &apiError{status, msg} } + +// aw — apiError даёт свой статус; остальное (awg-ctrl не ответил посреди действия) — 502. +func aw(fn func(c *Ctx) (int, any, error)) handler { + return func(c *Ctx) bool { + var ( + st int + v any + err error + ) + func() { + defer func() { + if p := recover(); p != nil { + err = fmt.Errorf("%v", p) + } + }() + st, v, err = fn(c) + }() + var ae *apiError + if errors.As(err, &ae) { + return c.Error(ae.status, ae.msg) + } + if err != nil { + return c.Error(502, "awg-ctrl недоступен") + } + return c.JSON(st, v) + } +} + +var ( + reUUID = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + reLabel = regexp.MustCompile(`^[^\r\n<>]+$`) // длина — lenOK(1, 40): в JS {1,40} считает UTF-16 +) + +func labelOK(v any) (string, bool) { + s, ok := v.(string) + return s, ok && reLabel.MatchString(s) && lenOK(s, 1, 40) +} + +// idOf — Number(v), если это безопасное целое, иначе -1 (не найдётся). +func idOf(v string) int64 { + f := jsToNumber(v) + if !isSafeInt(f) { + return -1 + } + return int64(f) +} + +// masterIdOf — мастер-ключ в пути: числовой id или uuid. +func (s *Sub) masterIDOf(v string) int64 { + if reUUID.MatchString(v) { + var id int64 + if s.a.db.QueryRow("SELECT id FROM master_keys WHERE uuid = ?", strings.ToLower(v)).Scan(&id) != nil { + return -1 + } + return id + } + return idOf(v) +} + +func limitOK(v any) (int64, bool) { + f, ok := v.(float64) + return int64(f), ok && f == float64(int64(f)) && f >= 1 && f <= 100 +} + +func (s *Sub) visible(masterID int64, scope []int) bool { + if scope == nil { + return true + } + for _, id := range s.serverIDsOf(s.a.db, masterID) { + if !containsInt(scope, id) { + return false + } + } + return true +} + +// masterFor — живой (не удаляемый) ключ в пределах области; чужой — как несуществующий. +func (s *Sub) masterFor(id int64, scope []int) (*masterRow, error) { + m := s.masterByID(s.a.db, id) + if m == nil || !s.visible(m.ID, scope) { + return nil, fail(404, "Не найден") + } + return m, nil +} + +// deviceFor — устройство; у внешнего API — только вместе со своим мастер-ключом. +func (s *Sub) deviceFor(id int64, master *masterRow) (*deviceRow, error) { + d := s.deviceByID(s.a.db, id) + if d == nil || master != nil && d.MasterID != master.ID { + return nil, fail(404, "Не найдено") + } + return d, nil +} + +// parseServers — набор серверов из тела: непустой, без повторов, только существующие и — для +// внешнего API — только из области его ключа. +func (s *Sub) parseServers(v any, scope []int) ([]int, error) { + list, ok := v.([]any) + if !ok || len(list) == 0 { + return nil, fail(400, "Нужен хотя бы один сервер") + } + if len(list) > maxServers { + return nil, fail(400, fmt.Sprintf("Не больше %d серверов", maxServers)) + } + ids, ok := s.a.serverList(v, s.a.nodes.Exists) + if !ok { + return nil, fail(400, "Такого сервера нет") + } + if scope != nil { + for _, id := range ids { + if !containsInt(scope, id) { + return nil, fail(403, "Сервер вне области API-ключа") + } + } + } + return ids, nil +} + +// masterOut — deleting: ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров. +func (s *Sub) masterOut(m *masterRow) jsjson.Object { + srv := intsJSON(s.serverIDsOf(s.a.db, m.ID)) + o := jsjson.Object{{Key: "id", Val: m.ID}, {Key: "uuid", Val: m.UUID}, {Key: "label", Val: m.Label}, {Key: "device_limit", Val: m.DeviceLimit}, + {Key: "devices", Val: m.Devices}, {Key: "servers", Val: srv}, {Key: "created_at", Val: m.CreatedAt}} + if m.RevokedAt.Valid { + o = append(o, jsjson.KV{Key: "deleting", Val: srv}) + } + return o +} + +func (s *Sub) masterOutByID(id int64) jsjson.Object { return s.masterOut(s.masterRowByID(s.a.db, id)) } + +func (s *Sub) listMasters(scope []int) []any { + rows, err := s.a.db.Query("SELECT " + masterCols + ", (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m ORDER BY id") + if err != nil { + return []any{} + } + var ms []*masterRow + for rows.Next() { + if m := scanMaster(rows, true); m != nil { + ms = append(ms, m) + } + } + rows.Close() + out := []any{} + for _, m := range ms { + if s.visible(m.ID, scope) { + out = append(out, s.masterOut(m)) + } + } + return out +} + +// createMaster — серверы по умолчанию: у панели — сервер по умолчанию, у API-ключа — вся его область. +func (s *Sub) createMaster(body any, scope []int) (jsjson.Object, error) { + b, _ := body.(jsjson.Object) + label, ok := labelOK(b.Get2("label")) + if !ok { + return nil, fail(400, "Метка: до 40 символов, без переводов строк и <>") + } + limitV, hasLimit := b.Get("device_limit") + limit := int64(defaultLimit) + if hasLimit { + var ok bool + if limit, ok = limitOK(limitV); !ok { + return nil, fail(400, "Лимит устройств: от 1 до 100") + } + } + wanted, hasWanted := b.Get("servers") + src := wanted + if src == nil { + if scope != nil { + list := []any{} // как пришло бы из JSON: числа — float64 + for _, id := range scope { + list = append(list, float64(id)) + } + src = list + } else { + src = []any{float64(s.a.nodes.DefaultID())} + } + } + ids, err := s.parseServers(src, scope) + if err != nil { + if !hasWanted && scope == nil { + return nil, fail(400, "Сначала добавьте сервер") + } + return nil, err + } + secret := make([]byte, 16) + rand.Read(secret) + var id int64 + err = s.a.tx(func(tx *sql.Tx) error { + r, err := tx.Exec("INSERT INTO master_keys (uuid, label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", + uuid.NewString(), label, secret, limit, ids[0], now()) + if err != nil { + return err + } + id, _ = r.LastInsertId() + for _, sid := range ids { + if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", id, sid); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return s.masterOutByID(id), nil +} + +func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object, error) { + b, _ := body.(jsjson.Object) + nlV := b.Get2("label") + if nlV == nil { + nlV = m.Label + } + ndV := b.Get2("device_limit") + if ndV == nil { + ndV = float64(m.DeviceLimit) + } + nl, ok := labelOK(nlV) + if !ok { + return nil, fail(400, "Неверная метка") + } + nd, ok := limitOK(ndV) + if !ok { + return nil, fail(400, "Лимит устройств: от 1 до 100") + } + + ids := s.serverIDsOf(s.a.db, m.ID) + var affected []int + if wanted, has := b.Get("servers"); has { + next, err := s.parseServers(wanted, scope) + if err != nil { + return nil, err + } + var added, removed []int + for _, x := range next { + if !containsInt(ids, x) { + added = append(added, x) + } + } + for _, x := range ids { + if !containsInt(next, x) { + removed = append(removed, x) + } + } + devices := s.devicesOf(s.a.db, m.ID) + if err := s.a.tx(func(tx *sql.Tx) error { + for _, sid := range removed { + tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, sid) + for _, d := range devices { + for _, l := range s.linksOf(tx, d.ID) { + if l.ServerID == sid && l.PeerPub.Valid { + tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String) + } + } + tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid) + } + } + for _, sid := range added { + tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", m.ID, sid) + for _, d := range devices { + tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid) + } + } + return nil + }); err != nil { + return nil, err + } + affected = append(append(affected, added...), removed...) + ids = next + } + s.a.db.Exec("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?", nl, nd, m.ID) + // Лимит стал меньше, чем устройств: лишние отвязываются, начиная с подключившихся последними. + unbound := []any{} + devices := s.devicesOf(s.a.db, m.ID) + if int64(len(devices)) > nd { + newest := append([]*deviceRow{}, devices...) + sort.SliceStable(newest, func(i, j int) bool { + if newest[i].CreatedAt != newest[j].CreatedAt { + return newest[i].CreatedAt > newest[j].CreatedAt + } + return newest[i].ID > newest[j].ID + }) + for _, d := range newest[:int64(len(devices))-nd] { + affected = append(affected, s.forgetDeviceTx(d)...) + unbound = append(unbound, d.ID) + } + } + s.reconcileAll(affected) + return jsjson.Object{{Key: "id", Val: m.ID}, {Key: "label", Val: nl}, {Key: "device_limit", Val: nd}, + {Key: "servers", Val: intsJSON(ids)}, {Key: "unbound", Val: unbound}}, nil +} + +// rotateMaster — новая ссылка; устройства продолжают работать: у них auth_pub, а не secret. +func (s *Sub) rotateMaster(m *masterRow) { + secret := make([]byte, 16) + rand.Read(secret) + s.a.db.Exec("UPDATE master_keys SET secret = ? WHERE id = ?", secret, m.ID) +} + +func (s *Sub) rekeyMaster(m *masterRow) { + s.a.db.Exec("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?", -1, m.ID) +} + +// deleteMaster — ключ сразу перестаёт работать, но строка остаётся отозванной, пока каждый его +// сервер не подтвердит, что снял пиров. +func (s *Sub) deleteMaster(m *masterRow) jsjson.Object { + affected := s.serverIDsOf(s.a.db, m.ID) + s.a.tx(func(tx *sql.Tx) error { + for _, d := range s.devicesOf(tx, m.ID) { + affected = append(affected, s.forgetDevice(tx, d)...) + } + _, err := tx.Exec("UPDATE master_keys SET revoked_at = ? WHERE id = ?", now(), m.ID) + return err + }) + s.reconcileAll(affected) + return jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: m.ID}, {Key: "pending", Val: intsJSON(s.serverIDsOf(s.a.db, m.ID))}} +} + +// linkProblem — выдать ссылку можно, только если подписка настроена и известен адрес панели. +func (s *Sub) linkProblem(c *Ctx) string { + if s.key() == nil || s.signPub == nil || s.port == 0 { + return "Подписка не настроена на сервере" + } + if s.subHost(c) == "" { + return "Не удалось определить адрес панели — задайте SUB_HOST" + } + return "" +} + +func (s *Sub) linkOf(m *masterRow, c *Ctx) (jsjson.Object, error) { + if p := s.linkProblem(c); p != "" { + return nil, fail(503, p) + } + link, err := EncodeSenLink(SenLink{TLS: s.tlsPin != nil, Addrs: []SenAddr{{s.subHost(c), s.port}}, + Secret: m.Secret, SignPub: s.signPub, TLSPin: s.tlsPin, Name: m.Label}) + if err != nil { + return nil, err // как в Node: не ApiError — 502 + } + return jsjson.Object{{Key: "link", Val: link}, {Key: "tls", Val: s.tlsPin != nil}}, nil +} + +type devStat struct { + online bool + lastHS float64 + rx, tx float64 + background bool +} + +// masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий +// handshake хоть на одном; трафик суммируется. servers_ok — где уже стоит его текущий ключ. +func (s *Sub) masterDevices(m *masterRow) ([]any, error) { + stats := map[string]*devStat{} + var mu sync.Mutex + var wg sync.WaitGroup + var firstErr error + for _, id := range s.serverIDsOf(s.a.db, m.ID) { + if !s.a.nodes.IsOnline(id) { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil) + mu.Lock() + defer mu.Unlock() + if err != nil { + if firstErr == nil { + firstErr = err + } + return + } + if !ok2xx(r.Status) { + return + } + for _, p := range arr(field(r.Data, "peers")) { + pub, _ := field(p, "pub_key").(string) + st := stats[pub] + if st == nil { + st = &devStat{} + stats[pub] = st + } + st.online = st.online || truthy(field(p, "online")) + if h := numOr(field(p, "lastHandshake"), 0); h > st.lastHS { + st.lastHS = h + } + st.rx += numOr(field(p, "rx"), 0) + st.tx += numOr(field(p, "tx"), 0) + // Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них. + st.background = st.background || field(p, "priority") == "background" + } + }() + } + wg.Wait() + if firstErr != nil { + return nil, firstErr + } + out := []any{} + for _, d := range s.devicesOf(s.a.db, m.ID) { + links := s.linksOf(s.a.db, d.ID) + st := stats[d.PubKey] + if st == nil { + st = &devStat{} + } + okN := 0 + for _, l := range links { + if l.PeerPub.Valid && l.PeerPub.String == d.PubKey && l.IP.Valid && l.IP.String != "" { + okN++ + } + } + prio := "normal" + if st.background { + prio = "background" + } + out = append(out, jsjson.Object{ + {Key: "id", Val: d.ID}, {Key: "device_id", Val: d.DeviceID}, {Key: "device_name", Val: d.DeviceName}, {Key: "platform", Val: d.Platform}, + {Key: "version", Val: d.Version}, {Key: "created_at", Val: d.CreatedAt}, {Key: "last_seen", Val: nullInt(d.LastSeen)}, + {Key: "rekey_requested", Val: d.RekeyRequested == 1}, {Key: "online", Val: st.online}, {Key: "lastHandshake", Val: st.lastHS}, + {Key: "rx", Val: st.rx}, {Key: "tx", Val: st.tx}, {Key: "priority", Val: prio}, + {Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN}, + }) + } + return out, nil +} + +func (s *Sub) deleteDevice(d *deviceRow) jsjson.Object { + s.reconcileAll(s.forgetDeviceTx(d)) + return jsjson.Object{{Key: "success", Val: true}, {Key: "id", Val: d.ID}} +} + +func (s *Sub) rekeyDevice(d *deviceRow) { + s.a.db.Exec("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?", d.ID, -1) +} + +// rotatePsk — новый PSK на каждом сервере; недоступные получат его, когда появятся. +func (s *Sub) rotatePsk(d *deviceRow) jsjson.Object { + s.a.db.Exec("UPDATE device_peers SET psk_pending = 1 WHERE device_id = ? AND peer_pub IS NOT NULL", d.ID) + var ids []int + for _, l := range s.linksOf(s.a.db, d.ID) { + ids = append(ids, l.ServerID) + } + s.reconcileAll(ids) + pending := 0 + for _, l := range s.linksOf(s.a.db, d.ID) { + if l.PSKPending == 1 { + pending++ + } + } + return jsjson.Object{{Key: "id", Val: d.ID}, {Key: "pending", Val: pending}} +} + +func idObj(id int64) jsjson.Object { return jsjson.Object{{Key: "id", Val: id}} } + +func merge(a, b jsjson.Object) jsjson.Object { + out := append(jsjson.Object{}, a...) + for _, kv := range b { + replaced := false + for i := range out { + if out[i].Key == kv.Key { + out[i].Val, replaced = kv.Val, true + } + } + if !replaced { + out = append(out, kv) + } + } + return out +} + +// ── Роуты панели (/ui/masterkeys, /ui/devices) ────────────────────────────── + +func (s *Sub) masterKeysRouter() *Router { + rt := newRouter() + auditOff := fail(409, "Аудит выключен на этой панели") + master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), nil) } + + rt.Get("/", func(c *Ctx) bool { + return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)}}) + }) + // Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки. + rt.Get("/audit", func(c *Ctx) bool { return c.JSON(200, s.auditState()) }) + rt.Post("/audit", aw(func(c *Ctx) (int, any, error) { + if !s.auditOn { + return 0, nil, auditOff + } + s.auditAll(false) + return 200, s.auditState(), nil + })) + rt.Post("/audit/clean", aw(func(c *Ctx) (int, any, error) { + if !s.auditOn { + return 0, nil, auditOff + } + s.cleanNow() + return 200, s.auditState(), nil + })) + rt.Put("/audit/settings", aw(func(c *Ctx) (int, any, error) { + if !s.auditOn { + return 0, nil, auditOff + } + b := c.Obj() + s.auditMu.Lock() + next := s.auditSettings + s.auditMu.Unlock() + if v, has := b.Get("clean"); has { + bv, ok := v.(bool) + if !ok { + return 0, nil, fail(400, "clean: true или false") + } + next.Clean = bv + } + if v, has := b.Get("every_h"); has { + if !isInt(v) || v.(float64) < 1 || v.(float64) > 168 { + return 0, nil, fail(400, "Период: от 1 до 168 часов") + } + next.EveryH = int(v.(float64)) + } + if v, has := b.Get("max"); has { + if !isInt(v) || v.(float64) < 1 || v.(float64) > 1000 { + return 0, nil, fail(400, "Предел: от 1 до 1000") + } + next.Max = int(v.(float64)) + } + s.auditMu.Lock() + s.auditSettings = next + s.metaSet("audit_settings", next.json()) + s.auditMu.Unlock() + return 200, s.auditState(), nil + })) + rt.Post("/", aw(func(c *Ctx) (int, any, error) { + out, err := s.createMaster(c.Body, nil) + return 201, out, err + })) + rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + out, err := s.updateMaster(m, c.Body, nil) + return 200, out, err + })) + rt.Post("/:id/rotate", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + s.rotateMaster(m) + return 200, idObj(m.ID), nil + })) + rt.Post("/:id/rekey", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + s.rekeyMaster(m) + return 200, idObj(m.ID), nil + })) + rt.Delete("/:id", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + return 200, s.deleteMaster(m), nil + })) + rt.Get("/:id/link", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + out, err := s.linkOf(m, c) + return 200, out, err + })) + rt.Get("/:id/devices", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + list, err := s.masterDevices(m) + return 200, jsjson.Object{{Key: "devices", Val: list}}, err + })) + return rt +} + +func (s *Sub) devicesRouter() *Router { + rt := newRouter() + dev := func(c *Ctx) (*deviceRow, error) { return s.deviceFor(idOf(c.Params["id"]), nil) } + rt.Delete("/:id", aw(func(c *Ctx) (int, any, error) { + d, err := dev(c) + if err != nil { + return 0, nil, err + } + return 200, s.deleteDevice(d), nil + })) + rt.Post("/:id/rekey", aw(func(c *Ctx) (int, any, error) { + d, err := dev(c) + if err != nil { + return 0, nil, err + } + s.rekeyDevice(d) + return 200, idObj(d.ID), nil + })) + rt.Post("/:id/psk", aw(func(c *Ctx) (int, any, error) { + d, err := dev(c) + if err != nil { + return 0, nil, err + } + return 200, s.rotatePsk(d), nil + })) + return rt +} + +// ── Внешний API: /api/v1/masterkeys (ключ awgk_) ──────────────────────────── +// scopeOf — набор серверов API-ключа запроса. Устройства — вложенными путями, чтобы +// область проверялась через их мастер-ключ. +func (s *Sub) apiRouter(scopeOf func(c *Ctx) []int) *Router { + rt := newRouter() + scope := func(c *Ctx) []int { + if sc := scopeOf(c); sc != nil { + return sc + } + return []int{} + } + master := func(c *Ctx) (*masterRow, error) { return s.masterFor(s.masterIDOf(c.Params["id"]), scope(c)) } + device := func(c *Ctx) (*deviceRow, error) { + m, err := master(c) + if err != nil { + return nil, err + } + return s.deviceFor(idOf(c.Params["device"]), m) + } + withLink := func(c *Ctx, m *masterRow, base jsjson.Object) (jsjson.Object, error) { + l, err := s.linkOf(m, c) + if err != nil { + return nil, err + } + return merge(base, l), nil + } + + rt.Get("/", aw(func(c *Ctx) (int, any, error) { + return 200, jsjson.Object{{Key: "keys", Val: s.listMasters(scope(c))}}, nil + })) + rt.Post("/", aw(func(c *Ctx) (int, any, error) { + if p := s.linkProblem(c); p != "" { // без ссылки ключ внешней программе бесполезен + return 0, nil, fail(503, p) + } + out, err := s.createMaster(c.Body, scope(c)) + if err != nil { + return 0, nil, err + } + m, err := s.masterFor(int64(out.Get2("id").(int64)), nil) + if err != nil { + return 0, nil, err + } + res, err := withLink(c, m, out) + return 201, res, err + })) + rt.Get("/:id", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + // devices — счётчик, как в списке; сами устройства — в device_list. + out := s.masterOutByID(m.ID) + if s.linkProblem(c) != "" { + out = merge(out, jsjson.Object{{Key: "link", Val: nil}, {Key: "tls", Val: s.tlsPin != nil}}) + } else if out, err = withLink(c, m, out); err != nil { + return 0, nil, err + } + list, err := s.masterDevices(m) + if err != nil { + return 0, nil, err + } + return 200, merge(out, jsjson.Object{{Key: "device_list", Val: list}}), nil + })) + rt.Patch("/:id", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + out, err := s.updateMaster(m, c.Body, scope(c)) + return 200, out, err + })) + rt.Post("/:id/rotate", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + s.rotateMaster(m) + m2, err := s.masterFor(m.ID, nil) + if err != nil { + return 0, nil, err + } + out, err := withLink(c, m2, idObj(m.ID)) + return 200, out, err + })) + rt.Post("/:id/rekey", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + s.rekeyMaster(m) + return 200, idObj(m.ID), nil + })) + rt.Delete("/:id", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + return 200, s.deleteMaster(m), nil + })) + rt.Get("/:id/devices", aw(func(c *Ctx) (int, any, error) { + m, err := master(c) + if err != nil { + return 0, nil, err + } + list, err := s.masterDevices(m) + return 200, jsjson.Object{{Key: "devices", Val: list}}, err + })) + rt.Delete("/:id/devices/:device", aw(func(c *Ctx) (int, any, error) { + d, err := device(c) + if err != nil { + return 0, nil, err + } + return 200, s.deleteDevice(d), nil + })) + rt.Post("/:id/devices/:device/rekey", aw(func(c *Ctx) (int, any, error) { + d, err := device(c) + if err != nil { + return 0, nil, err + } + s.rekeyDevice(d) + return 200, idObj(d.ID), nil + })) + rt.Post("/:id/devices/:device/psk", aw(func(c *Ctx) (int, any, error) { + d, err := device(c) + if err != nil { + return 0, nil, err + } + return 200, s.rotatePsk(d), nil + })) + return rt +} + +// ── Удаление сервера из реестра ───────────────────────────────────────────── + +// beforeServerDelete — пока сервером пользуется живой мастер-ключ — нельзя. Иначе сперва +// сводим его (пиры панели снимутся) — после удаления до него уже не дотянуться. Не вышло +// (нода не в сети) — удалять только с force. +func (s *Sub) beforeServerDelete(id int, force bool) *deleteVeto { + live := strs(s.a.db, "SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NULL", id) + if len(live) > 0 { + return &deleteVeto{Error: "Сервер в мастер-ключах: " + strings.Join(live, ", ") + " — сначала уберите его оттуда"} + } + if s.a.nodes.IsOnline(id) { + s.reconcile(id) + } + waiting := strs(s.a.db, "SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NOT NULL", id) + tombs := s.tombCount(id) + if len(waiting) == 0 && tombs == 0 || force { + return nil + } + msg := "Сервер не подтвердил, что снял пиров" + if len(waiting) > 0 { + msg += " удалённых мастер-ключей (" + strings.Join(waiting, ", ") + ")" + } + msg += ". Дождитесь, пока он выйдет на связь, или удалите принудительно — тогда пиры могут остаться на нём (снять: awg-ctrl peers drop)" + return &deleteVeto{Error: msg, Forcible: true} +} + +func (s *Sub) afterServerDelete(id int) { + s.a.db.Exec("DELETE FROM master_servers WHERE server_id = ?", id) + s.a.db.Exec("DELETE FROM master_keys WHERE revoked_at IS NOT NULL AND id NOT IN (SELECT master_id FROM master_servers)") +} diff --git a/internal/ui/web.go b/internal/ui/web.go new file mode 100644 index 0000000..f0c65a5 --- /dev/null +++ b/internal/ui/web.go @@ -0,0 +1,313 @@ +package ui + +// Минимальный роутер с поведением Express 4, на которое опирается фронтенд и внешние клиенты: +// - тело разбирает express.json() до маршрутизации и для всех путей: без тела или не JSON — +// пустой объект {}, битый JSON / не объект и не массив — HTML 400, больше лимита — HTML 413; +// - пути без учёта регистра и с необязательным «/» в конце; :param декодируется; +// - use(prefix) совпадает по границе сегмента; первое подходящее объявление выигрывает; +// - неизвестный путь — HTML 404 «Cannot METHOD /path», как finalhandler. + +import ( + "fmt" + "html" + "io" + "mime" + "net" + "net/http" + "net/url" + "strings" + "unicode" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +type Ctx struct { + W http.ResponseWriter + R *http.Request + Params map[string]string + Body any // jsjson.Object, []any; без тела — пустой Object + RawBody []byte // как пришло (для подписи /sub/v1) + Path string // путь внутри смонтированного use(prefix), как req.path в под-роутере + + // Внешний API: ключ запроса, его серверы и выбранный сервер. + apiKey *apiKeyRow + servers []int + serverID int + device *deviceRow +} + +// handler возвращает false, если ответ уже отправлен и цепочка дальше не идёт. +type handler func(c *Ctx) bool + +type route struct { + method string // "" — любой (use) + prefix bool // use(): совпадение по префиксу + segs []string + hs []handler +} + +type Router struct { + routes []route + // notFound — ответ, если ничего не совпало (у /sub/v1 он подписанный). + notFound func(c *Ctx) + // badBody — ответ на неразобранное тело (у /sub/v1 подписанный 400). + badBody func(w http.ResponseWriter, r *http.Request, status int, msg string) + bodyLimit int64 +} + +func newRouter() *Router { return &Router{bodyLimit: 100 * 1024} } + +func splitPath(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + return strings.Split(p, "/") +} + +func (rt *Router) add(method string, prefix bool, pattern string, hs ...handler) { + rt.routes = append(rt.routes, route{method: method, prefix: prefix, segs: splitPath(pattern), hs: hs}) +} + +func (rt *Router) Get(p string, hs ...handler) { rt.add("GET", false, p, hs...) } +func (rt *Router) Post(p string, hs ...handler) { rt.add("POST", false, p, hs...) } +func (rt *Router) Put(p string, hs ...handler) { rt.add("PUT", false, p, hs...) } +func (rt *Router) Patch(p string, hs ...handler) { rt.add("PATCH", false, p, hs...) } +func (rt *Router) Delete(p string, hs ...handler) { rt.add("DELETE", false, p, hs...) } +func (rt *Router) Use(p string, hs ...handler) { rt.add("", true, p, hs...) } + +// Mount — app.use(prefix, mw..., router): маршруты под-роутера с префиксом и общими mw. +func (rt *Router) Mount(prefix string, sub *Router, mw ...handler) { + pre := splitPath(prefix) + for _, r := range sub.routes { + segs := append(append([]string{}, pre...), r.segs...) + rt.routes = append(rt.routes, route{method: r.method, prefix: r.prefix, segs: segs, hs: append(append([]handler{}, mw...), r.hs...)}) + } +} + +// errDecode — %-последовательность в параметре не декодируется (Express: 400 URIError). +type errDecode struct{} + +func match(r route, method string, segs []string) (map[string]string, int, error) { + if r.method != "" && r.method != method && !(r.method == "GET" && method == "HEAD") { + return nil, 0, nil + } + if len(segs) < len(r.segs) || !r.prefix && len(segs) != len(r.segs) { + return nil, 0, nil + } + params := map[string]string{} + for i, p := range r.segs { + if strings.HasPrefix(p, ":") { + v, err := url.PathUnescape(segs[i]) + if err != nil { + return nil, 0, fmt.Errorf("URIError: Failed to decode param '%s'", segs[i]) + } + params[p[1:]] = v + } else if !strings.EqualFold(p, segs[i]) { + return nil, 0, nil + } + } + return params, len(r.segs), nil +} + +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.Header().Set("Content-Security-Policy", "default-src 'none'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + fmt.Fprintf(w, "\n\n\n\nError\n\n\n
%s
\n\n\n", html.EscapeString(text)) +} + +// readBody — express.json({limit}): только application/json, объект или массив. +func readBody(r *http.Request, limit int64) (body any, raw []byte, status int, msg string) { + empty := jsjson.Object{} + mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + if mt != "application/json" || r.Body == nil || r.Body == http.NoBody { + return empty, nil, 0, "" + } + if r.ContentLength > limit { + return nil, nil, 413, "PayloadTooLargeError: request entity too large" + } + raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1)) + if err != nil { + return nil, nil, 400, "BadRequestError: request aborted" + } + if int64(len(raw)) > limit { + return nil, nil, 413, "PayloadTooLargeError: request entity too large" + } + trimmed := strings.TrimLeftFunc(string(raw), unicode.IsSpace) + if trimmed == "" { + return empty, raw, 0, "" + } + if trimmed[0] != '{' && trimmed[0] != '[' { + return nil, nil, 400, "SyntaxError: Unexpected token in JSON" + } + v, err := jsjson.Decode(raw) + if err != nil { + return nil, nil, 400, "SyntaxError: " + err.Error() + } + return v, raw, 0, "" +} + +func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, raw, status, msg := readBody(r, rt.bodyLimit) + if status != 0 { + if rt.badBody != nil { + rt.badBody(w, r, status, msg) + } else { + writeHTMLError(w, status, msg) + } + return + } + segs := splitPath(r.URL.EscapedPath()) + c := &Ctx{W: w, R: r, Body: body, RawBody: raw} + for _, route := range rt.routes { + params, n, err := match(route, r.Method, segs) + if err != nil { + writeHTMLError(w, 400, err.Error()) + return + } + if params == nil { + continue + } + c.Params, c.Path = params, "/"+strings.Join(segs[n:], "/") + for _, h := range route.hs { + if !h(c) { + return // ответ отправлен + } + } + // Все обработчики вызвали next() — ищем дальше, как Express. + } + if rt.notFound != nil { + rt.notFound(c) + return + } + writeHTMLError(w, 404, "Cannot "+r.Method+" "+r.URL.EscapedPath()) +} + +// ── Ответы ────────────────────────────────────────────────────────────────── + +func (c *Ctx) JSON(status int, v any) bool { + writeJSON(c.W, status, v) + return false +} + +func errObj(msg string) jsjson.Object { return jsjson.Object{{Key: "error", Val: msg}} } + +func (c *Ctx) Error(status int, msg string) bool { return c.JSON(status, errObj(msg)) } + +func (c *Ctx) Obj() jsjson.Object { + o, _ := c.Body.(jsjson.Object) + return o +} + +// remoteIP — req.socket.remoteAddress. +func (c *Ctx) remoteIP() string { + host, _, err := net.SplitHostPort(c.R.RemoteAddr) + if err != nil { + return "unknown" + } + return host +} + +// hostname — req.hostname в Express (trust proxy выключен): Host без порта, у IPv6 — в скобках. +func (c *Ctx) hostname() string { + h := c.R.Host + if strings.HasPrefix(h, "[") { + if i := strings.Index(h, "]"); i >= 0 { + return h[:i+1] + } + return h + } + if i := strings.Index(h, ":"); i >= 0 { + return h[:i] + } + return h +} + +// ── Значения из разобранного JSON (как обращение к полям в JS) ────────────── + +// field — v.a.b.c; чего нет — nil (undefined). +func field(v any, keys ...string) any { + for _, k := range keys { + o, ok := v.(jsjson.Object) + if !ok { + return nil + } + v = o.Get2(k) + } + return v +} + +func has(o jsjson.Object, k string) bool { + _, ok := o.Get(k) + return ok +} + +func str(v any) (string, bool) { + s, ok := v.(string) + return s, ok +} + +func num(v any) (float64, bool) { + f, ok := v.(float64) + return f, ok +} + +// isInt — Number.isInteger. +func isInt(v any) bool { + f, ok := v.(float64) + return ok && f == float64(int64(f)) && f >= -9007199254740991*1024 && f <= 9007199254740991*1024 +} + +// isSafeInt — Number.isSafeInteger. +func isSafeInt(f float64) bool { + return f == float64(int64(f)) && f >= -9007199254740991 && f <= 9007199254740991 +} + +// numOr — `x ?? def` для числового поля. +func numOr(v any, def float64) float64 { + if f, ok := v.(float64); ok { + return f + } + if v == nil { + return def + } + return def +} + +func strOr(v any, def string) string { + if s, ok := v.(string); ok { + return s + } + return def +} + +func truthy(v any) bool { + switch x := v.(type) { + case nil: + return false + case bool: + return x + case float64: + return x != 0 && x == x + case string: + return x != "" + } + return true +} + +func arr(v any) []any { + a, _ := v.([]any) + return a +} diff --git a/testdata/golden/sen.json b/testdata/golden/sen.json new file mode 100644 index 0000000..07e98a8 --- /dev/null +++ b/testdata/golden/sen.json @@ -0,0 +1,323 @@ +{ + "sign_seed": "0b6f395ca14ac202374d5cff678b71157d0bef7e00c3045e9b80aab4ffb85276", + "sign_pub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0", + "crc32": [ + { + "in": "", + "out": 0 + }, + { + "in": "a", + "out": 3904355907 + }, + { + "in": "hello", + "out": 907060870 + }, + { + "in": "кириллица", + "out": 2804376611 + } + ], + "encode": [ + { + "tls": false, + "addrs": [ + { + "host": "203.0.113.7", + "port": 8443 + } + ], + "secret": "e8bc163c82eee18733288c7d4ac636db", + "name": "Семья", + "out": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwg" + }, + { + "tls": true, + "addrs": [ + { + "host": "vpn.example.com", + "port": 443 + } + ], + "secret": "ad328846aa18b32a335816374511cac1", + "tlsPin": "64f46a7526a186d2346552453ae478ca51244674f5b21ba150bd483b39f7c812", + "name": "", + "out": "sen://AQEBRA92cG4uZXhhbXBsZS5jb20Bu60yiEaqGLMqM1gWN0URysGrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8GT0anUmoYbSNGVSRTrkeMpRJEZ09bIboVC9SDs598gSAO9JOXk" + }, + { + "tls": false, + "addrs": [ + { + "host": "2001:db8::1", + "port": 1 + }, + { + "host": "::", + "port": 65535 + }, + { + "host": "fe80:0:0:1:0:0:0:1", + "port": 80 + } + ], + "secret": "41242b9fae56fad4e6e77dfe33cb18d1", + "name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "out": "sen://AQADBiABDbgAAAAAAAAAAAAAAAEAAQYAAAAAAAAAAAAAAAAAAAAA__8G_oAAAAAAAAEAAAAAAAAAAQBQQSQrn65W-tTm533-M8sY0auEMnNtF8YBXD8UTU7398FgRU4NbbHNni55pp4LqVXwQHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHhMjeF6" + }, + { + "tls": false, + "addrs": [ + { + "host": "1:2:3:4:5:6:7:8", + "port": 2 + } + ], + "secret": "5b840157e7e86aef3b3fd0fc24f3add3", + "name": "emoji 😀 & <>", + "out": "sen://AQABBgABAAIAAwAEAAUABgAHAAgAAluEAVfn6GrvOz_Q_CTzrdOrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8A9lbW9qaSDwn5iAICYgPD6UZmVs" + } + ], + "encode_errors": [ + { + "tls": false, + "addrs": [], + "secret": "ca978112ca1bbdcafac231b39a23dc4d", + "name": "", + "error": "Адресов должно быть от 1 до 3" + }, + { + "tls": false, + "addrs": [ + { + "host": "256.1.1.1", + "port": 1 + } + ], + "secret": "ca978112ca1bbdcafac231b39a23dc4d", + "name": "", + "error": "Неверный IPv4: 256.1.1.1" + }, + { + "tls": false, + "addrs": [ + { + "host": "1::2::3", + "port": 1 + } + ], + "secret": "ca978112ca1bbdcafac231b39a23dc4d", + "name": "", + "error": "Неверный IPv6: 1::2::3" + }, + { + "tls": false, + "addrs": [ + { + "host": "bad_host", + "port": 1 + } + ], + "secret": "ca978112ca1bbdcafac231b39a23dc4d", + "name": "", + "error": "Неверный домен: bad_host" + }, + { + "tls": false, + "addrs": [ + { + "host": "a.b", + "port": 0 + } + ], + "secret": "ca978112ca1bbdcafac231b39a23dc4d", + "name": "", + "error": "Неверный порт: 0" + }, + { + "tls": false, + "addrs": [ + { + "host": "a.b", + "port": 1 + } + ], + "secret": "ca978112ca1bbdcafac231b39a23dc4d", + "name": "яяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяя", + "error": "Имя: до 64 байт" + }, + { + "tls": true, + "addrs": [ + { + "host": "a.b", + "port": 1 + } + ], + "secret": "ca978112ca1bbdcafac231b39a23dc4d", + "name": "", + "error": "tlsPin: 32 байта при TLS" + } + ], + "decode": [ + { + "in": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwg", + "tls": false, + "addrs": [ + { + "host": "203.0.113.7", + "port": 8443 + } + ], + "secret": "e8bc163c82eee18733288c7d4ac636db", + "signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0", + "tlsPin": null, + "name": "Семья" + }, + { + "in": "sen://AQEBRA92cG4uZXhhbXBsZS5jb20Bu60yiEaqGLMqM1gWN0URysGrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8GT0anUmoYbSNGVSRTrkeMpRJEZ09bIboVC9SDs598gSAO9JOXk", + "tls": true, + "addrs": [ + { + "host": "vpn.example.com", + "port": 443 + } + ], + "secret": "ad328846aa18b32a335816374511cac1", + "signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0", + "tlsPin": "64f46a7526a186d2346552453ae478ca51244674f5b21ba150bd483b39f7c812", + "name": "" + }, + { + "in": "sen://AQADBiABDbgAAAAAAAAAAAAAAAEAAQYAAAAAAAAAAAAAAAAAAAAA__8G_oAAAAAAAAEAAAAAAAAAAQBQQSQrn65W-tTm533-M8sY0auEMnNtF8YBXD8UTU7398FgRU4NbbHNni55pp4LqVXwQHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHhMjeF6", + "tls": false, + "addrs": [ + { + "host": "2001:db8::1", + "port": 1 + }, + { + "host": "::", + "port": 65535 + }, + { + "host": "fe80:0:0:1::1", + "port": 80 + } + ], + "secret": "41242b9fae56fad4e6e77dfe33cb18d1", + "signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0", + "tlsPin": null, + "name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "in": "sen://AQABBgABAAIAAwAEAAUABgAHAAgAAluEAVfn6GrvOz_Q_CTzrdOrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8A9lbW9qaSDwn5iAICYgPD6UZmVs", + "tls": false, + "addrs": [ + { + "host": "1:2:3:4:5:6:7:8", + "port": 2 + } + ], + "secret": "5b840157e7e86aef3b3fd0fc24f3add3", + "signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0", + "tlsPin": null, + "name": "emoji 😀 & <>" + }, + { + "in": " SEN://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwg \n", + "tls": false, + "addrs": [ + { + "host": "203.0.113.7", + "port": 8443 + } + ], + "secret": "e8bc163c82eee18733288c7d4ac636db", + "signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0", + "tlsPin": null, + "name": "Семья" + }, + { + "in": "sen://AQEBRA92cG4uZX\nhhbXBsZS5jb20Bu60yiEaqGLMqM1gWN0URysGrhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8GT0anUmoYbSNGVSRTrkeMpRJEZ09bIboVC9SDs598gSAO9JOXk", + "tls": true, + "addrs": [ + { + "host": "vpn.example.com", + "port": 443 + } + ], + "secret": "ad328846aa18b32a335816374511cac1", + "signPub": "ab8432736d17c6015c3f144d4ef7f7c160454e0d6db1cd9e2e79a69e0ba955f0", + "tlsPin": "64f46a7526a186d2346552453ae478ca51244674f5b21ba150bd483b39f7c812", + "name": "" + }, + { + "in": "http://x", + "error": "Ссылка должна начинаться с sen://" + }, + { + "in": "sen://", + "error": "Ссылка повреждена: недопустимые символы" + }, + { + "in": "sen://AAAA", + "error": "Ссылка слишком короткая" + }, + { + "in": "sen://a+b", + "error": "Ссылка повреждена: недопустимые символы" + }, + { + "in": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdg", + "error": "Ссылка повреждена: не сошлась контрольная сумма" + }, + { + "in": "sen://AQABBMsAcQcg--i8FjyC7uGHMyiMfUrGNturhDJzbRfGAVw_FE1O9_fBYEVODW2xzZ4ueaaeC6lV8ArQodC10LzRjNGPnrdgwA", + "error": "Ссылка повреждена: не сошлась контрольная сумма" + } + ], + "sig_string": [ + { + "method": "post", + "path": "/sub/v1/register", + "ts": 1700000000, + "body": "{\"a\":1}", + "out": "POST\n/sub/v1/register\n1700000000\n015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862", + "sig": "CHqkcC2BwATFKxopbQlDvSDNKCRzAxKaynJ0SDWuj-ht2L5tFcDmIsjFL2vnKiaDuNSCr-ABbWR_uzgIj7mfCw" + }, + { + "method": "GET", + "path": "/sub/v1/config", + "ts": 1, + "body": "", + "out": "GET\n/sub/v1/config\n1\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "sig": "9p4dzFWv-JeWYVzxxdhjeEMIz1uH_XDVAea94YwQmYTE3j9-9hGRR6mVRs9T4szHvBUExnyXXiApOXEDkd3zCQ" + } + ], + "sign_response": [ + { + "in": { + "config": { + "rev": "ab", + "servers": [] + }, + "device": 3, + "ts": 5 + }, + "out": { + "body": "{\"config\":{\"rev\":\"ab\",\"servers\":[]},\"device\":3,\"ts\":5}", + "sig": "jcH9kAuxkvyBKJFLR-uSbaQgEh6dede75PLezg0v7yATUQojATafYKbQ8-aJAjvlHbv4G04j1eMD5owW9tQZDw" + } + }, + { + "in": { + "error": "revoked" + }, + "out": { + "body": "{\"error\":\"revoked\"}", + "sig": "Eb9F2GY-lyqk7ZxxKYHxSTl-goq7oUCYxBwguLpSw1oQmrXVFpUehQdd6_02UfjtDX1Rtth4-uf8-t4XFuciAA" + } + } + ] +} \ No newline at end of file diff --git a/tools/diffbench/collect-ui.mjs b/tools/diffbench/collect-ui.mjs new file mode 100644 index 0000000..3e54957 --- /dev/null +++ b/tools/diffbench/collect-ui.mjs @@ -0,0 +1,17 @@ +// Состояние после сценария awg-ui: строки ui.db и пиры/пользователи awg-ctrl. +import fs from "fs"; +import { execSync } from "child_process"; +const out = process.argv[2]; +const t = JSON.parse(fs.readFileSync(out, "utf8")); +const q = (db, sql) => JSON.parse(execSync(`sqlite3 -json ${db} "${sql}"`).toString() || "[]"); +const UI = "/run/bench/ui.db", CT = "/etc/amnezia/amneziawg/users.db"; +const tables = ["api_keys", "api_key_servers", "nodes", "master_keys", "master_servers", "devices", "device_peers", + "peer_tombstones", "server_profiles", "sub_meta", "metrics_token"]; +t.state = { + ui: Object.fromEntries(tables.map(x => [x, q(UI, `select * from ${x} order by rowid`)])), + uiSchema: execSync(`sqlite3 ${UI} .schema`).toString(), + ctrlUsers: q(CT, "select name, ip, key_gen from users order by rowid"), + ctrlPeers: q(CT, "select pub_key, ip, owner from peers order by rowid"), +}; +fs.writeFileSync(out, JSON.stringify(t, null, 1)); +console.log("collect-ui: done"); diff --git a/tools/diffbench/compare-ui.mjs b/tools/diffbench/compare-ui.mjs new file mode 100644 index 0000000..f61e4ae --- /dev/null +++ b/tools/diffbench/compare-ui.mjs @@ -0,0 +1,61 @@ +// compare-ui.mjs : расхождения двух прогонов scenario-ui. Случайное +// маскируется (токены, ключи, uuid, ссылки, подписи, время, id панели); подписанные ответы +// /sub/v1 раскрываются: сравнивается body, а подпись — только что она есть. +import fs from "fs"; +const [a, b] = process.argv.slice(2).map(f => JSON.parse(fs.readFileSync(f, "utf8"))); +const panelOf = r => r.state.ui.sub_meta.find(x => x.key === "panel_id")?.value ?? ""; +const TIME_KEYS = /^(created_at|last_used|last_seen|at|ts|updated_at|join_expires|revoked_at|expires_in_at)$/; +function mask(s, panel) { + if (panel) s = s.split(panel).join(""); + return s + .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "") + .replace(/awg[km]_[A-Za-z0-9_-]{8}(…|[A-Za-z0-9_-]{24})/g, "") + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "") + .replace(/sen:\/\/[A-Za-z0-9_-]+/g, "") + .replace(/awgjoin:\/\/[A-Za-z0-9_-]+/g, "") + .replace(/awg_scrape_duration_seconds [0-9.e-]+/g, "awg_scrape_duration_seconds ") + .replace(/(awg_host_[a-z_]+\{[^}]*\}) [0-9.e+-]+/g, "$1 "); +} +function norm(v, panel, key = "") { + if (typeof v === "string") { + if (key === "sig") return ""; + if (key === "body" && v.startsWith("{")) { try { return norm(JSON.parse(v), panel); } catch {} } + return mask(v, panel); + } + if (typeof v === "number" && TIME_KEYS.test(key)) return ""; + if (Array.isArray(v)) return v.map(x => norm(x, panel)); + if (v && typeof v === "object") { + const o = {}; + for (const [k, x] of Object.entries(v)) { + if (["key_hash", "secret", "join_hash", "token_hash", "pub", "prefix"].includes(k) && x !== null) { o[k] = ""; continue; } + if (k === "value" && typeof x === "string" && /^[[{]/.test(x)) { o[k] = norm(JSON.parse(x), panel); continue; } + o[k] = norm(x, panel, k); + } + return o; + } + return v; +} +const pa = panelOf(a), pb = panelOf(b); +let diffs = 0; +const show = (label, x, y) => { + const X = JSON.stringify(norm(x, pa)), Y = JSON.stringify(norm(y, pb)); + if (X === Y) return; + diffs++; + let i = 0; while (X[i] === Y[i]) i++; + const from = Math.max(0, i - 120); + console.log(`### ${label} @${i}\n TS: …${X.slice(from, i + 300)}\n GO: …${Y.slice(from, i + 300)}`); +}; +if (a.error || b.error) console.log("scenario error:", a.error, "|", b.error); +const n = Math.max(a.steps.length, b.steps.length); +for (let i = 0; i < n; i++) { + const s = a.steps[i] ?? {}, t = b.steps[i] ?? {}; + // text не сравниваем: порядок ключей виден и в разобранном body (Object.entries его хранит). + // Стек Express в dev-режиме (NODE_ENV не задан) у Go не воспроизводится — сравниваем тип ошибки. + const html = x => typeof x === "string" ? x.replace(/
([A-Za-z]+Error)[^<]*(
[\s\S]*)?<\/pre>/, "
$1
") : x; + show(`${i} ${s.name ?? t.name} ${s.method} ${s.path}`, { st: s.status, ct: s.ctype, body: html(s.body) }, { st: t.status, ct: t.ctype, body: html(t.body) }); +} +for (const k of Object.keys(a.state.ui)) show("ui." + k, a.state.ui[k], b.state.ui[k]); +show("uiSchema", a.state.uiSchema.replace(/\s+/g, " "), b.state.uiSchema.replace(/\s+/g, " ")); +show("ctrlUsers", a.state.ctrlUsers, b.state.ctrlUsers); +show("ctrlPeers", a.state.ctrlPeers, b.state.ctrlPeers); +console.log(diffs ? `${diffs} differences` : "IDENTICAL"); diff --git a/tools/diffbench/local-ui.sh b/tools/diffbench/local-ui.sh new file mode 100755 index 0000000..800da18 --- /dev/null +++ b/tools/diffbench/local-ui.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# local-ui.sh <имя-прогона> — дифференциальный прогон awg-ui без Docker: awg-ctrl (Go, +# на заглушках, сверен с TS отдельно) + панель нужной реализации + нода из Go-агента на тот же +# awg-ctrl. Как и local.sh — только для одноразовой машины: пересоздаёт /etc/amnezia. +# Нужны node 20+, sqlite3, openssl, go и `npm install` в awg-ui/ (и собранный awg-ui/dist). +set -e +IMPL="$1"; NAME="$2" +B=$(cd "$(dirname "$0")" && pwd); ROOT=$(cd "$B/../.." && pwd) +BENCH=/var/lib/uibench +mkdir -p "$B/bin/stubs" "$B/out" "$BENCH" +for c in awg awg-quick ip tc nft modinfo; do + printf '#!/bin/sh\nexec node %s %s "$@"\n' "$B/stubs/stub.mjs" "$c" > "$B/bin/stubs/$c"; chmod +x "$B/bin/stubs/$c" +done +export PATH="$B/bin/stubs:$PATH" +(cd "$ROOT" && go build ${RACE:+-race} -o "$B/bin/awg-control" ./cmd/awg-control) # RACE=1 — с детектором гонок +# Ключи стенда — одни на все прогоны: подпись sen://, сертификат хаба нод. +[ -f "$BENCH/sub_sign.key" ] || openssl genpkey -algorithm ed25519 -out "$BENCH/sub_sign.key" 2>/dev/null +[ -f "$BENCH/node_tls.key" ] || openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ + -keyout "$BENCH/node_tls.key" -out "$BENCH/node_tls.crt" -days 3650 -subj /CN=awg-node 2>/dev/null +sh "$B/setup.sh" + +PORT=3005 SERVER_IP=203.0.113.7 SERVER_PORT=47619 SERVER_NAME=VPN \ + INTERNAL_AUTH_PUB_FILE=/etc/amnezia/amneziawg/internal_auth_public.key SHAPE_SAMPLE_S=3600 AWG_CLI=/nonexistent/awg-ctrl \ + sh -c 'cd /tmp && exec "$0" ctrl' "$B/bin/awg-control" > /run/bench/ctrl.log 2>&1 & +CTRL=$! +i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $i -gt 150 ] && { cat /run/bench/ctrl.log; kill $CTRL; exit 1; }; sleep 0.1; done + +export PORT=8080 UI_USER=admin UI_PASS=benchpass JWT_SECRET=benchsecret UI_DB_FILE=/run/bench/ui.db AWGCTRL_PORT=3005 \ + INTERNAL_AUTH_KEY_FILE=/etc/amnezia/amneziawg/internal_auth_private.key \ + SUB_PORT=8443 SUB_TLS=off SUB_SIGN_KEY_FILE="$BENCH/sub_sign.key" SUB_HOST=sub.example.com \ + NODE_PORT=8444 NODE_TLS_KEY_FILE="$BENCH/node_tls.key" NODE_TLS_CERT_FILE="$BENCH/node_tls.crt" \ + BRAND=Bench CHANNEL=Beta VERSION=0.0.0-bench MONITOR_HASH_FILE="$BENCH/none" PROM_URL=http://127.0.0.1:9 \ + METRICS_PEERS=on AUDIT=on LOCAL_NODE=on +cd "$ROOT/awg-ui" +if [ "$IMPL" = "ts" ]; then node node_modules/.bin/tsx server.ts > /run/bench/ui.log 2>&1 & +else "$B/bin/awg-control" ui > /run/bench/ui.log 2>&1 & +fi +UIPID=$! +i=0; until curl -s -o /dev/null http://127.0.0.1:8080/ui/brand; do i=$((i+1)); [ $i -gt 200 ] && { cat /run/bench/ui.log; kill $UIPID $CTRL; exit 1; }; sleep 0.1; done +sleep 1 + +AGENT_BIN="$B/bin/awg-control" BENCH_DIR="$BENCH" "$ROOT/awg-ui/node_modules/.bin/tsx" "$B/scenario-ui.ts" "$B/out/$NAME.json" || true +sleep 2 +kill $UIPID $CTRL 2>/dev/null || true; wait $UIPID $CTRL 2>/dev/null || true +node "$B/collect-ui.mjs" "$B/out/$NAME.json" +cp /run/bench/ui.log "$B/out/$NAME.ui.log"; cp /run/bench/ctrl.log "$B/out/$NAME.ctrl.log" diff --git a/tools/diffbench/scenario-ui.ts b/tools/diffbench/scenario-ui.ts new file mode 100644 index 0000000..3bf6c5c --- /dev/null +++ b/tools/diffbench/scenario-ui.ts @@ -0,0 +1,301 @@ +// Сценарий для awg-ui: одинаковая последовательность запросов к TS- и Go-версии панели. +// Запуск: awg-ui/node_modules/.bin/tsx tools/diffbench/scenario-ui.ts +// Окружение: UI (http://127.0.0.1:8080), SUB (http://127.0.0.1:8443), AGENT_BIN — бинарник +// awg-control для ноды (join по строке из панели), BENCH_DIR — каталог с ключами стенда. +import crypto from "crypto"; +import fs from "fs"; +import { spawn, execSync, type ChildProcess } from "child_process"; +import { decodeSenLink, signRequest } from "../../awg-ui/sen"; + +const UI = process.env.UI ?? "http://127.0.0.1:8080"; +const SUB = process.env.SUB ?? "http://127.0.0.1:8443"; +const out = process.argv[2]; +const BENCH = process.env.BENCH_DIR ?? "/var/lib/uibench"; + +type Step = { name: string; method: string; path: string; status: number; ctype: string; body: unknown; text?: string }; +const steps: Step[] = []; +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +async function call(name: string, method: string, path: string, body?: unknown, + o: { token?: string | null; key?: string; raw?: string; headers?: Record; base?: string } = {}) { + const h: Record = { ...(o.headers ?? {}) }; + if (o.token) h.authorization = `Bearer ${o.token}`; + if (o.key) h["x-api-key"] = o.key; + let data: string | undefined; + if (o.raw !== undefined) { h["content-type"] = "application/json"; data = o.raw; } + else if (body !== undefined) { h["content-type"] = "application/json"; data = JSON.stringify(body); } + const r = await fetch((o.base ?? UI) + path, { method, headers: h, body: data }); + const ctype = (r.headers.get("content-type") ?? "").split(";")[0]; + const text = await r.text(); + let parsed: unknown = text; + if (ctype === "application/json") { try { parsed = JSON.parse(text); } catch { /* как есть */ } } + if (ctype === "text/html" && text.length > 2000) parsed = text.slice(0, 200); // index.html + steps.push({ name, method, path, status: r.status, ctype, body: parsed, text: ctype === "application/json" ? text : undefined }); + return parsed as any; +} + +// ── Подписанные запросы устройства (/sub/v1) ──────────────────────────────── +const seedKey = (s: string) => crypto.createPrivateKey({ + key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), crypto.createHash("sha256").update(s).digest()]), + format: "der", type: "pkcs8", +}); +const rawPub = (k: crypto.KeyObject) => (crypto.createPublicKey(k).export({ type: "spki", format: "der" }) as Buffer).subarray(12); +const wgPub = (s: string) => crypto.createHash("sha256").update("wg:" + s).digest("base64"); + +let tsBump = 0; +async function subCall(name: string, method: string, path: string, body: unknown, auth: crypto.KeyObject | null, device?: number, extra: Record = {}) { + const raw = body === undefined ? "" : JSON.stringify(body); + const ts = Math.floor(Date.now() / 1000) + (tsBump++ % 2); // разные подписи для одинаковых запросов + const h: Record = { ...extra }; + if (auth) { h["x-sen-ts"] = String(ts); h["x-sen-sig"] = signRequest(method, path, ts, raw, auth); } + if (device !== undefined) h["x-sen-device"] = String(device); + return call(name, method, path, undefined, { raw: body === undefined ? undefined : raw, headers: h, base: SUB }); +} + +// ── Сценарий ──────────────────────────────────────────────────────────────── +async function main() { +let agent: ChildProcess | null = null; +try { + await call("brand", "GET", "/ui/brand"); + await call("login bad", "POST", "/login", { user: "admin", pass: "nope" }); + await call("login no body", "POST", "/login"); + await call("login broken json", "POST", "/login", undefined, { raw: "{" }); + const { token } = await call("login", "POST", "/login", { user: "admin", pass: "benchpass" }); + const T = { token }; + + await call("apikeys no auth", "GET", "/ui/apikeys"); + await call("apikeys bad token", "GET", "/ui/apikeys", undefined, { token: "a.b.c" }); + await call("nodes no auth", "GET", "/ui/nodes"); + await call("proxy no auth", "GET", "/api/users"); + + // прокси к awg-ctrl + await call("proxy health", "GET", "/health", undefined, T); + await call("proxy users", "GET", "/api/users", undefined, T); + await call("proxy add alice", "POST", "/api/users", { name: "alice" }, T); + await call("proxy add bad", "POST", "/api/users", { name: "a b" }, T); + await call("proxy unknown", "GET", "/api/nope?x=1", undefined, T); + await call("proxy server 5", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": "5" } }); + await call("proxy server junk", "GET", "/api/users/stats", undefined, { ...T, headers: { "x-server-id": "junk" } }); + + // ключи API + await call("apikeys empty", "GET", "/ui/apikeys", undefined, T); + await call("apikey bad label", "POST", "/ui/apikeys", { label: "bad!" }, T); + await call("apikey no label", "POST", "/ui/apikeys", {}, T); + await call("apikey bad servers", "POST", "/ui/apikeys", { label: "k", servers: [9] }, T); + await call("apikey empty servers", "POST", "/ui/apikeys", { label: "k", servers: [] }, T); + const k1 = await call("apikey create", "POST", "/ui/apikeys", { label: "main key" }, T); + const k2 = await call("apikey create sid", "POST", "/ui/apikeys", { label: "second", server_id: 0, servers: [0, 0] }, T); + await call("apikeys list", "GET", "/ui/apikeys", undefined, T); + await call("apikey patch bad", "PATCH", "/ui/apikeys/" + k2.id, { servers: [] }, T); + await call("apikey patch missing", "PATCH", "/ui/apikeys/999", { servers: [0] }, T); + await call("apikey patch", "PATCH", "/ui/apikeys/" + k2.id, { servers: [0] }, T); + await call("apikey delete bad", "DELETE", "/ui/apikeys/x", undefined, T); + await call("apikey delete", "DELETE", "/ui/apikeys/" + k2.id, undefined, T); + + // внешний API: пользователи + const K = { key: k1.key as string }; + await call("v1 no key", "GET", "/api/v1/users"); + await call("v1 bad key", "GET", "/api/v1/users", undefined, { key: "awgk_nope" }); + await call("v1 users", "GET", "/api/v1/users", undefined, K); + await call("v1 add bob", "POST", "/api/v1/users", { name: "bob" }, K); + await call("v1 add bob again", "POST", "/api/v1/users", { name: "bob" }, K); + await call("v1 add no name", "POST", "/api/v1/users", {}, K); + await call("v1 get bob", "GET", "/api/v1/users/bob", undefined, K); + await call("v1 get nobody", "GET", "/api/v1/users/nobody", undefined, K); + await call("v1 get escaped", "GET", "/api/v1/users/b%20b", undefined, K); + await call("v1 server outside", "GET", "/api/v1/users", undefined, { ...K, headers: { "x-server-id": "3" } }); + await call("v1 unknown with key", "GET", "/api/v1/zzz", undefined, K); + await call("v1 delete bob", "DELETE", "/api/v1/users/bob", undefined, K); + + // ноды: заводим и подключаем агента к тому же awg-ctrl + await call("nodes list", "GET", "/ui/nodes", undefined, T); + await call("node bad name", "POST", "/ui/nodes", { name: "" }, T); + const n1 = await call("node create", "POST", "/ui/nodes", { name: "edge-1", host: "127.0.0.1" }, T); + await call("node rename bad", "PATCH", "/ui/nodes/" + n1.id, { name: "a { + const j = JSON.parse(Buffer.from(String(r.join).slice("awgjoin://".length), "base64url").toString()); + fs.rmSync(`${BENCH}/agent-node.key`, { force: true }); + agent = spawn(process.env.AGENT_BIN!, ["agent"], { + env: { ...process.env, CORE_HOST: j.h, CORE_PORT: String(j.p), CORE_PIN: j.pin, NODE_ID: String(j.n), JOIN_SECRET: j.s, + AWGCTRL_PORT: "3005", NODE_KEY_FILE: `${BENCH}/agent-node.key`, VERSION: "0.9.9", + INTERNAL_AUTH_KEY_FILE: "/etc/amnezia/amneziawg/internal_auth_private.key", CLI_ENV_FILE: "" }, + stdio: "ignore", + }); + }); + for (let i = 0; i < 100; i++) { + const r = await fetch(UI + "/ui/nodes", { headers: { authorization: `Bearer ${token}` } }).then(x => x.json()) as any; + if (r.nodes.some((n: any) => n.id === n1.id && n.online && n.health)) break; + await sleep(100); + } + await sleep(500); + await call("nodes online", "GET", "/ui/nodes", undefined, T); + await call("node join bound", "POST", `/ui/nodes/${n1.id}/join`, {}, T); + await call("proxy via node", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": String(n1.id) } }); + await call("proxy via node awg", "GET", "/awg/status", undefined, { ...T, headers: { "x-server-id": String(n1.id) } }); + + // мастер-ключи + await call("mk list empty", "GET", "/ui/masterkeys", undefined, T); + await call("mk bad label", "POST", "/ui/masterkeys", { label: "a\nb" }, T); + await call("mk bad limit", "POST", "/ui/masterkeys", { label: "x", device_limit: 0 }, T); + await call("mk bad servers", "POST", "/ui/masterkeys", { label: "x", servers: [42] }, T); + const m1 = await call("mk create", "POST", "/ui/masterkeys", { label: "Семья", device_limit: 2 }, T); + const m2 = await call("mk create multi", "POST", "/ui/masterkeys", { label: "Multi", servers: [n1.id, 0] }, T); + await call("mk list", "GET", "/ui/masterkeys", undefined, T); + const link = await call("mk link", "GET", `/ui/masterkeys/${m1.id}/link`, undefined, T); + await call("mk link by uuid", "GET", `/ui/masterkeys/${m1.uuid.toUpperCase()}/link`, undefined, T); + await call("mk link missing", "GET", "/ui/masterkeys/999/link", undefined, T); + const sen = decodeSenLink(link.link); + const secret = sen.secret.toString("base64url"); + + // /sub/v1: регистрация и опрос устройства + const d1 = seedKey("dev1"), d2 = seedKey("dev2"), d3 = seedKey("dev3"); + const reg = (dev: string, k: crypto.KeyObject, sec: string, extra: object = {}) => ({ + sub: sec, device_id: dev, device_name: "Phone " + dev, platform: "android", pub_key: wgPub(dev), + auth_pub: rawPub(k).toString("base64url"), version: "1.2.3", ...extra, + }); + await subCall("sub unknown path", "GET", "/sub/v1/nope", undefined, null); + await subCall("sub broken json", "POST", "/sub/v1/peek", undefined, null).catch(() => {}); + await call("sub broken json raw", "POST", "/sub/v1/peek", undefined, { raw: "{", base: SUB }); + await subCall("sub peek bad", "POST", "/sub/v1/peek", { sub: "x" }, null); + await subCall("sub peek missing", "POST", "/sub/v1/peek", { sub: "A".repeat(22) }, null); + await subCall("sub peek", "POST", "/sub/v1/peek", { sub: secret }, null); + await subCall("sub register bad", "POST", "/sub/v1/register", { sub: secret }, d1); + await subCall("sub register unsigned", "POST", "/sub/v1/register", reg("d1", d1, secret), null); + const r1 = await subCall("sub register d1", "POST", "/sub/v1/register", reg("d1", d1, secret), d1, { "x-sen-device": "77" }); + const dev1 = JSON.parse(r1.body).device; + const r2 = await subCall("sub register d2", "POST", "/sub/v1/register", reg("d2", d2, secret), d2); + const dev2 = JSON.parse(r2.body).device; + await subCall("sub register over limit", "POST", "/sub/v1/register", reg("d3", d3, secret), d3); + await subCall("sub config", "GET", "/sub/v1/config", undefined, d1, dev1, { "x-sen-version": "1.2.4" }); + await subCall("sub config wrong key", "GET", "/sub/v1/config", undefined, d2, dev1); + await subCall("sub config no sig", "GET", "/sub/v1/config", undefined, null, dev1); + await subCall("sub config gone", "GET", "/sub/v1/config", undefined, d1, 9999); + await subCall("sub devices", "GET", "/sub/v1/devices", undefined, d2, dev2); + await subCall("sub rekey bad", "POST", "/sub/v1/rekey", { pub_key: "x" }, d1, dev1); + await subCall("sub rekey same", "POST", "/sub/v1/rekey", { pub_key: wgPub("d1") }, d1, dev1); + await subCall("sub rekey taken", "POST", "/sub/v1/rekey", { pub_key: wgPub("d2") }, d1, dev1); + await subCall("sub rekey", "POST", "/sub/v1/rekey", { pub_key: wgPub("d1-new") }, d1, dev1); + // повтор той же подписи — replay + { + const ts = Math.floor(Date.now() / 1000); + const sig = signRequest("GET", "/sub/v1/config", ts, "", d2); + const h = { "x-sen-ts": String(ts), "x-sen-sig": sig, "x-sen-device": String(dev2) }; + await call("sub config sig once", "GET", "/sub/v1/config", undefined, { headers: h, base: SUB }); + await call("sub config replay", "GET", "/sub/v1/config", undefined, { headers: h, base: SUB }); + } + // устройство на ключе с двумя серверами (нода — тот же awg-ctrl) + const link2 = await call("mk2 link", "GET", `/ui/masterkeys/${m2.id}/link`, undefined, T); + const secret2 = decodeSenLink(link2.link).secret.toString("base64url"); + const r3 = await subCall("sub register multi", "POST", "/sub/v1/register", reg("d3", d3, secret2), d3); + const dev3 = JSON.parse(r3.body).device; + await subCall("sub config multi", "GET", "/sub/v1/config", undefined, d3, dev3); + + // панель: устройства и мастер-ключи + await call("mk devices", "GET", `/ui/masterkeys/${m1.id}/devices`, undefined, T); + await call("mk2 devices", "GET", `/ui/masterkeys/${m2.id}/devices`, undefined, T); + await call("dev rekey", "POST", `/ui/devices/${dev2}/rekey`, undefined, T); + await call("dev psk", "POST", `/ui/devices/${dev2}/psk`, undefined, T); + await call("dev missing", "POST", "/ui/devices/999/psk", undefined, T); + await call("mk rekey", "POST", `/ui/masterkeys/${m1.id}/rekey`, undefined, T); + await subCall("sub config after rekey flag", "GET", "/sub/v1/config", undefined, d2, dev2); + await call("mk patch bad", "PATCH", `/ui/masterkeys/${m1.id}`, { device_limit: 101 }, T); + await call("mk patch limit", "PATCH", `/ui/masterkeys/${m1.id}`, { label: "Семья 2", device_limit: 1 }, T); + await subCall("sub config unbound", "GET", "/sub/v1/config", undefined, d2, dev2); + await call("mk2 patch servers", "PATCH", `/ui/masterkeys/${m2.id}`, { servers: [0] }, T); + await call("mk rotate", "POST", `/ui/masterkeys/${m1.id}/rotate`, undefined, T); + await subCall("sub peek old secret", "POST", "/sub/v1/peek", { sub: secret }, null); + await call("dev delete", "DELETE", `/ui/devices/${dev1}`, undefined, T); + await subCall("sub config deleted", "GET", "/sub/v1/config", undefined, d1, dev1); + await subCall("sub delete self", "DELETE", "/sub/v1/device", undefined, d3, dev3); + + // внешний API: мастер-ключи + await call("v1 mk list", "GET", "/api/v1/masterkeys", undefined, K); + await call("v1 mk create outside", "POST", "/api/v1/masterkeys", { label: "ext", servers: [n1.id] }, K); + const em = await call("v1 mk create", "POST", "/api/v1/masterkeys", { label: "ext" }, K); + await call("v1 mk get", "GET", `/api/v1/masterkeys/${em.id}`, undefined, K); + await call("v1 mk get by uuid", "GET", `/api/v1/masterkeys/${em.uuid}`, undefined, K); + await call("v1 mk get invisible", "GET", `/api/v1/masterkeys/${m2.id}`, undefined, K); + await call("v1 mk patch", "PATCH", `/api/v1/masterkeys/${em.id}`, { device_limit: 5 }, K); + await call("v1 mk rotate", "POST", `/api/v1/masterkeys/${em.id}/rotate`, undefined, K); + await call("v1 mk rekey", "POST", `/api/v1/masterkeys/${em.id}/rekey`, undefined, K); + await call("v1 mk devices", "GET", `/api/v1/masterkeys/${em.id}/devices`, undefined, K); + await call("v1 mk device missing", "DELETE", `/api/v1/masterkeys/${em.id}/devices/${dev2}`, undefined, K); + await call("v1 mk delete", "DELETE", `/api/v1/masterkeys/${em.id}`, undefined, K); + + // аудит + await call("audit get", "GET", "/ui/masterkeys/audit", undefined, T); + await call("audit settings bad", "PUT", "/ui/masterkeys/audit/settings", { every_h: 0 }, T); + await call("audit settings", "PUT", "/ui/masterkeys/audit/settings", { clean: false, every_h: 2, max: 5 }, T); + await call("audit run", "POST", "/ui/masterkeys/audit", undefined, T); + await call("audit clean", "POST", "/ui/masterkeys/audit/clean", undefined, T); + + // метрики + await call("metrics no token", "GET", "/metrics"); + await call("metrics token none", "GET", "/ui/metrics-token", undefined, T); + const mt = await call("metrics token create", "POST", "/ui/metrics-token", undefined, T); + await call("metrics", "GET", "/metrics", undefined, { token: mt.token }); + await call("metrics token view", "GET", "/ui/metrics-token", undefined, T); + await call("metrics token delete", "DELETE", "/ui/metrics-token", undefined, T); + await call("metrics after delete", "GET", "/metrics", undefined, { token: mt.token }); + + // монитор (Prometheus не запущен) + await call("monitor", "GET", "/ui/monitor", undefined, T); + await call("monitor overview bad", "GET", "/ui/monitor/overview?range=2h", undefined, T); + await call("monitor overview", "GET", "/ui/monitor/overview?range=6h", undefined, T); + + // аудит с «мёртвыми»: лишний пир панели у awg-ctrl и пир в ядре без записи + { + const panel = fs.readFileSync(__dirname + "/../../awg-ui/panel.id", "utf8").trim(); + await call("orphan add", "POST", "/api/peers", { pub_key: wgPub("orphan"), owner: `${panel}/m1/d77` }, T); + await call("foreign add", "POST", "/api/peers", { pub_key: wgPub("foreign"), owner: "ffffffffffff/m1/d1" }, T); + execSync(`awg set awg1 peer ${wgPub("ghost")} allowed-ips 10.9.9.9/32`); + await call("audit run 1", "POST", "/ui/masterkeys/audit", undefined, T); + await call("audit clean unconfirmed", "POST", "/ui/masterkeys/audit/clean", undefined, T); + await call("audit run 2", "POST", "/ui/masterkeys/audit", undefined, T); + await call("audit clean confirmed", "POST", "/ui/masterkeys/audit/clean", undefined, T); + await call("audit after clean", "GET", "/ui/masterkeys/audit", undefined, T); + } + + // ключ API на ноде мешает её удалить; потом нода уходит офлайн + const k3 = await call("apikey on node", "POST", "/ui/apikeys", { label: "node only", servers: [n1.id] }, T); + await call("node delete key veto", "DELETE", `/ui/nodes/${n1.id}`, undefined, T); + await call("apikey to both", "PATCH", "/ui/apikeys/" + k3.id, { servers: [0, n1.id] }, T); + await call("v1 users on node", "GET", "/api/v1/users", undefined, { key: k3.key, headers: { "x-server-id": String(n1.id) } }); + if (agent) { (agent as ChildProcess).kill(); agent = null; } + for (let i = 0; i < 50; i++) { + const r = await fetch(UI + "/ui/nodes", { headers: { authorization: `Bearer ${token}` } }).then(x => x.json()) as any; + if (r.nodes.some((n: any) => n.id === n1.id && !n.online)) break; + await sleep(100); + } + await call("nodes offline", "GET", "/ui/nodes", undefined, T); + await call("proxy via offline node", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": String(n1.id) } }); + await call("mk2 back to node", "PATCH", `/ui/masterkeys/${m2.id}`, { servers: [0, n1.id] }, T); + await call("mk2 devices offline", "GET", `/ui/masterkeys/${m2.id}/devices`, undefined, T); + + // удаление ноды + await call("node delete in use", "DELETE", `/ui/nodes/${n1.id}`, undefined, T); + await call("mk2 delete", "DELETE", `/ui/masterkeys/${m2.id}`, undefined, T); + await call("node delete pending", "DELETE", `/ui/nodes/${n1.id}`, undefined, T); + await call("node delete", "DELETE", `/ui/nodes/${n1.id}?force=1`, undefined, T); + await call("apikeys after node delete", "GET", "/ui/apikeys", undefined, T); + await call("node delete again", "DELETE", `/ui/nodes/${n1.id}`, undefined, T); + await call("mk list final", "GET", "/ui/masterkeys", undefined, T); + + // статика и общие ответы + await call("static index", "GET", "/"); + await call("static spa", "GET", "/some/route"); + await call("static ui fallback", "GET", "/ui/unknown", undefined, T); + await call("post unknown", "POST", "/nope"); + await call("wrong method", "PUT", "/ui/apikeys", undefined, T); + await call("logout", "POST", "/logout", undefined, T); + await call("after logout", "GET", "/ui/apikeys", undefined, T); +} finally { + if (agent) (agent as ChildProcess).kill(); +} + +fs.writeFileSync(out, JSON.stringify({ steps }, null, 1)); +console.log(`scenario-ui: ${steps.length} steps → ${out}`); +} + +main().catch(e => { console.error(e); fs.writeFileSync(out, JSON.stringify({ steps, error: String(e) }, null, 1)); process.exit(1); }); diff --git a/tools/golden/sen.ts b/tools/golden/sen.ts new file mode 100644 index 0000000..a1e106e --- /dev/null +++ b/tools/golden/sen.ts @@ -0,0 +1,63 @@ +// Эталон для internal/ui (sen.go): ссылки sen:// и подписи протокола, как их делает awg-ui/sen.ts. +// Запуск: awg-ui/node_modules/.bin/tsx tools/golden/sen.ts > testdata/golden/sen.json +import crypto from "crypto"; +import { + encodeSenLink, decodeSenLink, crc32, requestSigString, signResponse, signRequest, rawPublicKey, + type SenLink, +} from "../../awg-ui/sen"; + +const seed = (s: string) => crypto.createHash("sha256").update(s).digest(); +// Ed25519 из фиксированного seed: PKCS#8 = префикс + 32 байта seed. +const keyFromSeed = (s: Buffer) => crypto.createPrivateKey({ + key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), s]), format: "der", type: "pkcs8", +}); +const signKey = keyFromSeed(seed("sign")); +const signPub = rawPublicKey(crypto.createPublicKey(signKey)); +const hex = (b?: Buffer) => b?.toString("hex"); + +const links: SenLink[] = [ + { tls: false, addrs: [{ host: "203.0.113.7", port: 8443 }], secret: seed("s1").subarray(0, 16), signPub, name: "Семья" }, + { tls: true, addrs: [{ host: "vpn.example.com", port: 443 }], secret: seed("s2").subarray(0, 16), signPub, tlsPin: seed("pin"), name: "" }, + { tls: false, addrs: [{ host: "2001:db8::1", port: 1 }, { host: "::", port: 65535 }, { host: "fe80:0:0:1:0:0:0:1", port: 80 }], + secret: seed("s3").subarray(0, 16), signPub, name: "x".repeat(64) }, + { tls: false, addrs: [{ host: "1:2:3:4:5:6:7:8", port: 2 }], secret: seed("s4").subarray(0, 16), signPub, name: "emoji 😀 & <>" }, +]; +const badLinks = [ + { tls: false, addrs: [], secret: seed("a").subarray(0, 16), signPub, name: "" }, + { tls: false, addrs: [{ host: "256.1.1.1", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" }, + { tls: false, addrs: [{ host: "1::2::3", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" }, + { tls: false, addrs: [{ host: "bad_host", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" }, + { tls: false, addrs: [{ host: "a.b", port: 0 }], secret: seed("a").subarray(0, 16), signPub, name: "" }, + { tls: false, addrs: [{ host: "a.b", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "я".repeat(33) }, + { tls: true, addrs: [{ host: "a.b", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" }, +]; +const good = links.map(encodeSenLink); +const decodeCases = [ + ...good, " " + good[0].toUpperCase().slice(0, 6) + good[0].slice(6) + " \n", good[1].slice(0, 20) + "\n" + good[1].slice(20), + "http://x", "sen://", "sen://AAAA", "sen://a+b", good[0].slice(0, -2), good[0].slice(0, -1) + (good[0].endsWith("A") ? "B" : "A"), +]; +const err = (f: () => unknown) => { try { return { ok: f() }; } catch (e) { return { error: (e as Error).message }; } }; + +process.stdout.write(JSON.stringify({ + sign_seed: hex(seed("sign")), + sign_pub: hex(signPub), + crc32: ["", "a", "hello", "кириллица"].map(s => ({ in: s, out: crc32(Buffer.from(s)) })), + encode: links.map((l, i) => ({ + tls: l.tls, addrs: l.addrs, secret: hex(l.secret), tlsPin: hex(l.tlsPin), name: l.name, out: good[i], + })), + encode_errors: badLinks.map(l => ({ + tls: l.tls, addrs: l.addrs, secret: hex(l.secret), name: l.name, error: err(() => encodeSenLink(l as SenLink)).error, + })), + decode: decodeCases.map(s => { + const r = err(() => decodeSenLink(s)); + if ("error" in r) return { in: s, error: r.error }; + const d = r.ok as SenLink; + return { in: s, tls: d.tls, addrs: d.addrs, secret: hex(d.secret), signPub: hex(d.signPub), tlsPin: hex(d.tlsPin) ?? null, name: d.name }; + }), + sig_string: [ + { method: "post", path: "/sub/v1/register", ts: 1700000000, body: '{"a":1}' }, + { method: "GET", path: "/sub/v1/config", ts: 1, body: "" }, + ].map(c => ({ ...c, out: requestSigString(c.method, c.path, c.ts, c.body), + sig: signRequest(c.method, c.path, c.ts, c.body, signKey) })), + sign_response: [{ config: { rev: "ab", servers: [] }, device: 3, ts: 5 }, { error: "revoked" }].map(p => ({ in: p, out: signResponse(p, signKey) })), +}, null, 1));