Port awg-ui to Go: awg-control ui
internal/ui reimplements the awg-ui backend with the same HTTP surface; the React frontend is unchanged and served from awg-ui/dist. - web.go: Express-like router (express.json semantics, mounts, :params, HTML 400/404/413 pages, SPA fallback) - server.go: JWT login with rate limit, API keys and their server sets, awg-ctrl client and proxy, /api/v1/users, delete hooks - nodes.go: WSS hub for agents (pin, challenge-response, join strings), RPC with timeouts, health, /ui/nodes - sub.go, audit.go, sub_admin.go: sen:// master keys and devices, per-server reconcile with tombstones and /api/peers/sync, signed /sub/v1 listener, audit with two-pass confirmation and cleaning, panel and /api/v1/masterkeys routers - metrics.go, monitor.go: /metrics exposition with tokens, /ui/monitor - sen.go: link codec and protocol signatures, tested byte for byte against fixtures produced by awg-ui/sen.ts (tools/golden/sen.ts) Differential bench (tools/diffbench/local-ui.sh, scenario-ui.ts, compare-ui.mjs): 158 steps over every route group, including signed device requests and a Go agent joining as a node, plus the resulting ui.db and awg-ctrl state. TS and Go are identical after masking random values, across repeated runs and with the race detector on.
This commit is contained in:
@@ -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()}}
|
||||
}
|
||||
Reference in New Issue
Block a user