The «Перенос» tab moves a panel + VPN server to a panel-only server without users noticing: the new panel issues a one-time awgmove:// code, the old one sends its ui.db, subscription keys and hub certificate encrypted with it, and becomes node #0 of the new panel. Server id 0 is kept, so config servers[] is byte-identical. The old server relays its former ports (awg-relay, PROXY v1 header trusted only from RELAY_FROM), so sen:// links, nodes and the API keep working. The same is available from the CLI: migrate export/import/cancel, rehome. Nodes now learn the panel's address: the hub sends it in ready, the agent stores it in CORE_ENDPOINT, dials it first and confirms with core_ok. The new panel shows when the old server can be turned off: devices that got the new address, nodes that confirmed it, master keys never opened. «Серверы для всех» on the master keys tab adds or removes servers on every live key in one go, with a dry run that warns about API keys that would lose sight of some keys, and sets the servers new keys get.
1061 lines
34 KiB
Go
1061 lines
34 KiB
Go
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"
|
||
"time"
|
||
|
||
"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 — живой (не удаляемый) ключ в пределах области. Ключ, которого нет или который
|
||
// удалён, — 404; ключ, у которого есть сервер вне области API-ключа, — 403: клиент не должен
|
||
// принимать его за удалённый и выдавать вместо него второй (так у пользователя появлялся дубль).
|
||
func (s *Sub) masterFor(id int64, scope []int) (*masterRow, error) {
|
||
m := s.masterByID(s.a.db, id)
|
||
if m == nil {
|
||
return nil, fail(404, "Не найден")
|
||
}
|
||
if !s.visible(m.ID, scope) {
|
||
return nil, fail(403, "Ключ вне области API-ключа")
|
||
}
|
||
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 if def := s.defaultServers(); def != nil {
|
||
list := []any{}
|
||
for _, id := range def {
|
||
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)
|
||
}
|
||
}
|
||
if err := s.a.tx(func(tx *sql.Tx) error { return s.moveServersTx(tx, m.ID, added, removed) }); 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
|
||
}
|
||
|
||
// moveServersTx — ключ получает серверы added и теряет removed: пиры его устройств на снятых
|
||
// серверах — в надгробия (их снимет reconcile), на добавленных — заявки без пира (заведёт он же).
|
||
func (s *Sub) moveServersTx(tx *sql.Tx, masterID int64, added, removed []int) error {
|
||
devices := s.devicesOf(tx, masterID)
|
||
for _, sid := range removed {
|
||
if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", masterID, sid); err != nil {
|
||
return err
|
||
}
|
||
for _, d := range devices {
|
||
for _, l := range s.linksOf(tx, d.ID) {
|
||
if l.ServerID == sid && l.PeerPub.Valid {
|
||
if _, err := tx.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", sid, l.PeerPub.String); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
if _, err := tx.Exec("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?", d.ID, sid); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
for _, sid := range added {
|
||
if _, err := tx.Exec("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)", masterID, sid); err != nil {
|
||
return err
|
||
}
|
||
for _, d := range devices {
|
||
if _, err := tx.Exec("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)", d.ID, sid); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ── Общая настройка серверов ────────────────────────────────────────────────
|
||
|
||
// defaultServers — серверы новых ключей панели (без явного набора), из настройки «Серверы для
|
||
// всех»; nil — не задано или ни одного из них уже нет.
|
||
func (s *Sub) defaultServers() []int {
|
||
var out []int
|
||
for _, v := range arr(s.metaGet("default_servers")) {
|
||
if isInt(v) && s.a.nodes.Exists(int(v.(float64))) {
|
||
out = append(out, int(v.(float64)))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// bulkServers — один набор правок для всех живых ключей: add — у всех, remove — ни у кого,
|
||
// остальные серверы каждого ключа не трогаются. dry — только посчитать. hidden — ключи API,
|
||
// которые потеряют доступ к части ключей: ключ виден API-ключу, только пока все его серверы в
|
||
// области (visible), и добавление сервера вне области его прячет.
|
||
func (s *Sub) bulkServers(body any) (jsjson.Object, error) {
|
||
b, _ := body.(jsjson.Object)
|
||
ids := func(k string) ([]int, error) {
|
||
v, has := b.Get(k)
|
||
if !has || v == nil {
|
||
return nil, nil
|
||
}
|
||
if l, ok := v.([]any); ok && len(l) == 0 {
|
||
return nil, nil
|
||
}
|
||
out, ok := s.a.serverList(v, s.a.nodes.Exists)
|
||
if !ok {
|
||
return nil, fail(400, "Такого сервера нет")
|
||
}
|
||
return out, nil
|
||
}
|
||
add, err := ids("add")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
remove, err := ids("remove")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, x := range add {
|
||
if containsInt(remove, x) {
|
||
return nil, fail(400, "Сервер не может быть и у всех, и ни у кого")
|
||
}
|
||
}
|
||
defaults, hasDefaults := b.Get("defaults")
|
||
var defIDs []int
|
||
if hasDefaults && defaults != nil {
|
||
if defIDs, err = s.parseServers(defaults, nil); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
type change struct {
|
||
id int64
|
||
added, removed []int
|
||
before, after []int
|
||
}
|
||
var changes []change
|
||
var masters []int64
|
||
rows, err := s.a.db.Query("SELECT id FROM master_keys WHERE revoked_at IS NULL ORDER BY id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for rows.Next() {
|
||
var id int64
|
||
if rows.Scan(&id) == nil {
|
||
masters = append(masters, id)
|
||
}
|
||
}
|
||
rows.Close()
|
||
empty := 0
|
||
for _, id := range masters {
|
||
cur := s.serverIDsOf(s.a.db, id)
|
||
c := change{id: id, before: cur}
|
||
for _, x := range cur {
|
||
if containsInt(remove, x) {
|
||
c.removed = append(c.removed, x)
|
||
} else {
|
||
c.after = append(c.after, x)
|
||
}
|
||
}
|
||
for _, x := range add {
|
||
if !containsInt(cur, x) {
|
||
c.added = append(c.added, x)
|
||
c.after = append(c.after, x)
|
||
}
|
||
}
|
||
if len(c.after) == 0 {
|
||
empty++
|
||
}
|
||
if len(c.added)+len(c.removed) > 0 {
|
||
changes = append(changes, c)
|
||
}
|
||
}
|
||
if empty > 0 {
|
||
return nil, fail(409, fmt.Sprintf("У %d ключей не останется ни одного сервера — оставьте им хотя бы один", empty))
|
||
}
|
||
|
||
// Области ключей API: id → серверы.
|
||
type apiKey struct {
|
||
label string
|
||
scope []int
|
||
}
|
||
keys := map[int64]*apiKey{}
|
||
var order []int64
|
||
if r, err := s.a.db.Query("SELECT k.id, k.label, ks.server_id FROM api_keys k LEFT JOIN api_key_servers ks ON ks.key_id = k.id ORDER BY k.id"); err == nil {
|
||
for r.Next() {
|
||
var id int64
|
||
var label string
|
||
var sid sql.NullInt64
|
||
if r.Scan(&id, &label, &sid) != nil {
|
||
continue
|
||
}
|
||
if keys[id] == nil {
|
||
keys[id] = &apiKey{label: label}
|
||
order = append(order, id)
|
||
}
|
||
if sid.Valid {
|
||
keys[id].scope = append(keys[id].scope, int(sid.Int64))
|
||
}
|
||
}
|
||
r.Close()
|
||
}
|
||
within := func(list, scope []int) bool {
|
||
for _, x := range list {
|
||
if !containsInt(scope, x) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
hidden := []any{}
|
||
for _, id := range order {
|
||
k, n := keys[id], 0
|
||
for _, c := range changes {
|
||
if within(c.before, k.scope) && !within(c.after, k.scope) {
|
||
n++
|
||
}
|
||
}
|
||
if n > 0 {
|
||
hidden = append(hidden, jsjson.Object{{Key: "id", Val: id}, {Key: "label", Val: k.label}, {Key: "keys", Val: n}})
|
||
}
|
||
}
|
||
out := jsjson.Object{{Key: "changed", Val: len(changes)}, {Key: "total", Val: len(masters)}, {Key: "hidden", Val: hidden}}
|
||
if b.Get2("dry_run") == true {
|
||
return out, nil
|
||
}
|
||
|
||
var affected []int
|
||
if err := s.a.tx(func(tx *sql.Tx) error {
|
||
for _, c := range changes {
|
||
if err := s.moveServersTx(tx, c.id, c.added, c.removed); err != nil {
|
||
return err
|
||
}
|
||
affected = append(append(affected, c.added...), c.removed...)
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
if hasDefaults {
|
||
if defIDs == nil {
|
||
s.a.db.Exec("DELETE FROM sub_meta WHERE key = 'default_servers'")
|
||
} else {
|
||
s.metaSet("default_servers", intsJSON(defIDs))
|
||
}
|
||
}
|
||
logf("masterkeys: серверы для всех — добавлены %v, сняты %v, ключей изменено: %d", add, remove, len(changes))
|
||
s.reconcileAll(affected)
|
||
return out, 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
|
||
torrent bool
|
||
}
|
||
|
||
// peersTTL — сколько держится ответ GET /api/peers сервера. Вкладка за один проход просит
|
||
// устройства каждого ключа, и без этого каждый запрос заново тянул бы пиров со всех серверов.
|
||
const peersTTL = 3 * time.Second
|
||
|
||
type peersCall struct {
|
||
done chan struct{} // закрывается, когда ответ получен
|
||
at time.Time // нулевое — ответ не годится для повторного использования (ошибка)
|
||
peers []any
|
||
err error
|
||
}
|
||
|
||
// peersOf — пиры сервера с их статистикой. Параллельные запросы к одному серверу делят один
|
||
// вызов; свежий ответ переиспользуется peersTTL, ошибка — нет.
|
||
func (s *Sub) peersOf(id int) ([]any, error) {
|
||
s.peersMu.Lock()
|
||
if c := s.peerCalls[id]; c != nil {
|
||
select {
|
||
case <-c.done:
|
||
if !c.at.IsZero() && time.Since(c.at) < peersTTL {
|
||
s.peersMu.Unlock()
|
||
return c.peers, c.err
|
||
}
|
||
default: // уже идёт — дождаться его же
|
||
s.peersMu.Unlock()
|
||
<-c.done
|
||
return c.peers, c.err
|
||
}
|
||
}
|
||
c := &peersCall{done: make(chan struct{})}
|
||
s.peerCalls[id] = c
|
||
s.peersMu.Unlock()
|
||
|
||
r, err := s.a.nodes.CtrlFor(id)("GET", "/api/peers", nil)
|
||
switch {
|
||
case err != nil:
|
||
c.err = err
|
||
case !ok2xx(r.Status):
|
||
c.err = fmt.Errorf("awg-ctrl ответил %d", r.Status)
|
||
default:
|
||
c.peers = arr(field(r.Data, "peers"))
|
||
c.at = time.Now()
|
||
}
|
||
close(c.done)
|
||
return c.peers, c.err
|
||
}
|
||
|
||
// masterDevices — статистика устройства по всем серверам ключа: онлайн, если есть свежий
|
||
// handshake хоть на одном; трафик суммируется. servers_ok — где уже стоит его текущий ключ.
|
||
// Сервер, который не ответил, не рушит список: его статистики просто нет, а id возвращается
|
||
// вторым значением — панель покажет, что данные неполные.
|
||
func (s *Sub) masterDevices(m *masterRow) ([]any, []int) {
|
||
stats := map[string]*devStat{}
|
||
var mu sync.Mutex
|
||
var wg sync.WaitGroup
|
||
unreachable := []int{}
|
||
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()
|
||
peers, err := s.peersOf(id)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
if err != nil {
|
||
unreachable = append(unreachable, id)
|
||
return
|
||
}
|
||
for _, p := range 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"
|
||
st.torrent = st.torrent || truthy(field(p, "torrent"))
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
sort.Ints(unreachable)
|
||
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
|
||
ips := []any{} // IP устройства на каждом сервере ключа, где он уже выдан
|
||
for _, l := range links {
|
||
if l.PeerPub.Valid && l.PeerPub.String == d.PubKey && l.IP.Valid && l.IP.String != "" {
|
||
okN++
|
||
}
|
||
if l.IP.Valid && l.IP.String != "" {
|
||
ips = append(ips, jsjson.Object{{Key: "server_id", Val: l.ServerID}, {Key: "ip", Val: l.IP.String}})
|
||
}
|
||
}
|
||
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: "torrent", Val: st.torrent},
|
||
{Key: "servers_total", Val: len(links)}, {Key: "servers_ok", Val: okN}, {Key: "ips", Val: ips},
|
||
})
|
||
}
|
||
return out, unreachable
|
||
}
|
||
|
||
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 {
|
||
var defaults any
|
||
if d := s.defaultServers(); d != nil {
|
||
defaults = intsJSON(d)
|
||
}
|
||
return c.JSON(200, jsjson.Object{{Key: "enabled", Val: s.key() != nil}, {Key: "tls", Val: s.tlsPin != nil}, {Key: "keys", Val: s.listMasters(nil)},
|
||
{Key: "defaults", Val: defaults}})
|
||
})
|
||
// «Серверы для всех»: добавить/снять серверы у всех ключей разом и задать набор для новых.
|
||
rt.Post("/servers", aw(func(c *Ctx) (int, any, error) {
|
||
out, err := s.bulkServers(c.Body)
|
||
return 200, out, err
|
||
}))
|
||
// Аудит серверов (только 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, down := s.masterDevices(m)
|
||
return 200, jsjson.Object{{Key: "devices", Val: list}, {Key: "unreachable", Val: intsJSON(down)}}, nil
|
||
}))
|
||
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, _ := s.masterDevices(m)
|
||
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, _ := s.masterDevices(m)
|
||
return 200, jsjson.Object{{Key: "devices", Val: list}}, nil
|
||
}))
|
||
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)")
|
||
}
|