Files
forgetting/internal/ui/sub_admin.go
T
maeneko 70bb587216 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.
2026-10-14 13:27:24 +00:00

805 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)")
}