Move the panel to a server without VPN; servers for all master keys at once

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.
This commit is contained in:
2026-10-21 15:59:33 +03:00
parent 0687650d26
commit eb19cacf93
33 changed files with 3603 additions and 106 deletions
+104
View File
@@ -0,0 +1,104 @@
package ui
import (
"errors"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
// «Серверы для всех»: add — у каждого живого ключа, remove — ни у кого, остальное не трогается;
// пиры снятых серверов — в надгробия, новые ключи получают набор по умолчанию.
func TestBulkServers(t *testing.T) {
p := newMovePanel(t, "off", "40500")
s, db := p.s, p.a.db
if err := p.a.initAPIKeys(); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'A', 'x', 1), (2, 'B', 'x', 1), (3, 'C', 'x', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k1', x'01', 1), (2, 'u2', 'k2', x'02', 1), (3, 'u3', 'k3', x'03', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (4, 'u4', 'k4', x'04', 1, 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 1), (2, 1), (2, 2), (3, 2), (4, 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 2, 'd', 'W', 'A', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 1, 'P1', '10.0.0.2', 'K'), (5, 2, 'P2', '10.0.1.2', 'K')",
"INSERT INTO api_keys (id, label, key_hash, prefix, created_at) VALUES (1, 'Бот', 'h1', 'p1', 1), (2, 'Всё', 'h2', 'p2', 1)",
"INSERT INTO api_key_servers (key_id, server_id) VALUES (1, 1), (1, 2), (2, 1), (2, 2), (2, 3)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(q, err)
}
}
servers := func() string {
var out []string
for _, id := range []int64{1, 2, 3, 4} {
out = append(out, string(jsjson.MustMarshal(intsJSON(s.serverIDsOf(db, id)))))
}
return strings.Join(out, " ")
}
body := func(kv ...any) jsjson.Object {
o := jsjson.Object{}
for i := 0; i < len(kv); i += 2 {
o = append(o, jsjson.KV{Key: kv[i].(string), Val: kv[i+1]})
}
return o
}
f := func(ids ...float64) []any {
out := []any{}
for _, x := range ids {
out = append(out, x)
}
return out
}
status := func(err error) int {
var ae *apiError
if errors.As(err, &ae) {
return ae.status
}
return 0
}
out, err := s.bulkServers(body("add", f(3), "remove", f(2), "dry_run", true))
if err != nil {
t.Fatal(err)
}
if got := string(jsjson.MustMarshal(out)); got != `{"changed":3,"total":3,"hidden":[{"id":1,"label":"Бот","keys":3}]}` {
t.Errorf("предпросмотр: %s", got)
}
if got := servers(); got != "[1] [1,2] [2] [1]" {
t.Errorf("предпросмотр изменил ключи: %s", got)
}
if _, err := s.bulkServers(body("remove", f(1, 2))); status(err) != 409 {
t.Errorf("ключи без серверов: %v", err)
}
if _, err := s.bulkServers(body("add", f(2), "remove", f(2))); status(err) != 400 {
t.Errorf("и у всех, и ни у кого: %v", err)
}
if _, err := s.bulkServers(body("add", f(9))); status(err) != 400 {
t.Errorf("несуществующий сервер: %v", err)
}
if _, err := s.bulkServers(body("add", f(3), "remove", f(2), "defaults", f(3))); err != nil {
t.Fatal(err)
}
if got := servers(); got != "[1,3] [1,3] [3] [1]" {
t.Errorf("после применения: %s (удаляемый ключ трогать нельзя)", got)
}
var n int
db.QueryRow("SELECT COUNT(*) FROM peer_tombstones WHERE server_id = 2 AND pub_key = 'P2'").Scan(&n)
if n != 1 {
t.Error("пир снятого сервера не в надгробиях")
}
db.QueryRow("SELECT COUNT(*) FROM device_peers WHERE device_id = 5 AND server_id = 3").Scan(&n)
if n != 1 {
t.Error("устройство не получило заявку на новом сервере")
}
m, err := s.createMaster(body("label", "Новый"), nil)
if err != nil || string(jsjson.MustMarshal(m.Get2("servers"))) != "[3]" {
t.Errorf("новый ключ: %v %v", m, err)
}
if _, err := s.bulkServers(body("defaults", nil)); err != nil || s.defaultServers() != nil {
t.Errorf("сброс набора по умолчанию: %v %v", s.defaultServers(), err)
}
}
+454
View File
@@ -0,0 +1,454 @@
package ui
// Вкладка «Перенос»: панель переезжает с сервера «панель + VPN» на отдельную панель без VPN
// прямо из интерфейса, без архива в руках. Те же шаги, что `awg-control migrate` (общий код —
// internal/migrate), только архив идёт из панели в панель:
//
// новая: POST /ui/migrate/code → одноразовый код awgmove:// (адрес, порт панели, ключ)
// старая: POST /ui/migrate/export → замораживает /sub/v1, шлёт зашифрованный архив на
// POST /migrate/v1/import новой панели
// новая: раскладывает архив, заводит ноду #0, отвечает join-строкой и перезапускается
// старая: пишет PANEL_MOVED и запускает `awg-control join` — становится нодой #0 с ретранслятором
//
// Перезапуск и join делает CLI в отдельном процессе: панель останавливает сама себя.
import (
"bytes"
"database/sql"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/maeneko/forgetting/internal/envfile"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
const (
moveCodeTTL = time.Hour
moveMaxFails = 5
moveMaxBundle = 1 << 30
moveTimeout = 10 * time.Minute
)
// Состояние переноса на этой панели (в памяти: перезапуск его сбрасывает, и так и надо).
const (
moveIdle = ""
moveSending = "sending" // старая: архив в пути, /sub/v1 заморожен
moveMoved = "moved" // старая: новая приняла, сейчас будет join
moveUncertain = "uncertain" // старая: архив ушёл, а ответа нет — новая могла его принять
moveImported = "imported" // новая: архив принят, сейчас перезапуск
)
type Migration struct {
a *App
envFile string // cli.env: сюда пишутся порты, PANEL_MOVED, RELAY_FROM
run func(args ...string) // `awg-control …` отдельным процессом; в тестах — заглушка
mu sync.Mutex
key []byte // новая: ключ действующего кода
host string
expires time.Time
fails int
state string
errText string
target string // старая: адрес новой панели
}
func newMigration(a *App) *Migration {
m := &Migration{a: a, envFile: os.Getenv("CLI_ENV_FILE")}
m.run = m.spawn
return m
}
func (a *App) filePath(env, name string) string {
if v := os.Getenv(env); v != "" {
return v
}
return filepath.Join(a.baseDir, name)
}
// paths — где awg-ui держит файлы из архива (те же правила, что при их загрузке).
func (m *Migration) paths() migrate.Paths {
return migrate.PathsFrom(func(env string) string {
for _, f := range migrate.Files {
if f.Env == env {
return m.a.filePath(env, f.Name)
}
}
return ""
})
}
// canExport / canAccept — "" или причина, почему нельзя.
func (m *Migration) canExport() string {
switch {
case m.envFile == "":
return "Панель запущена не через awg-control — перенос недоступен"
case os.Getenv("CORE_HOST") != "":
return "Этот сервер уже нода другой панели"
case !m.a.nodes.local:
return "У этой панели нет своего VPN: переносить её как сервер-ноду нечего"
}
return ""
}
func (m *Migration) canAccept() string {
switch {
case m.envFile == "":
return "Панель запущена не через awg-control — перенос недоступен"
case m.a.nodes.local:
return "У этой панели свой VPN. Принять перенос может только панель без VPN: install.sh, роль «только панель»"
case !m.a.nodes.hubReady():
return "Не настроен приём нод (NODE_PORT): старый сервер подключится сюда нодой"
case migrate.Count(m.a.db) > 0:
return "На панели уже есть серверы, мастер-ключи или ключи API — принять перенос можно только на чистую"
}
return ""
}
func (m *Migration) router() *Router {
rt := newRouter()
rt.Get("/", m.status)
rt.Post("/code", m.newCode)
rt.Delete("/code", func(c *Ctx) bool {
m.mu.Lock()
m.key = nil
m.mu.Unlock()
return m.status(c)
})
rt.Post("/export", m.export)
rt.Post("/cancel", m.cancel)
rt.Delete("/retire", func(c *Ctx) bool {
if err := envfile.Set(m.envFile, nil, "MOVED_IN_AT"); err != nil {
return c.Error(500, err.Error())
}
os.Unsetenv("MOVED_IN_AT")
return m.status(c)
})
return rt
}
func (m *Migration) status(c *Ctx) bool {
m.mu.Lock()
state, errText, target := m.state, m.errText, m.target
var code any
if m.key != nil && time.Now().Before(m.expires) {
code = jsjson.Object{{Key: "code", Val: migrate.Code{Host: m.host, Port: m.a.port, Key: m.key}.String()},
{Key: "expires_in", Val: int(time.Until(m.expires).Seconds())}}
}
m.mu.Unlock()
o := jsjson.Object{{Key: "state", Val: state}, {Key: "error", Val: orNil(errText)}, {Key: "target", Val: orNil(target)}, {Key: "code", Val: code}}
// После приёма база закрыта до перезапуска — что можно, уже не считаем.
if state != moveImported {
o = append(o, jsjson.KV{Key: "export", Val: orNil(m.canExport())}, jsjson.KV{Key: "accept", Val: orNil(m.canAccept())},
jsjson.KV{Key: "retire", Val: m.retire()})
}
return c.JSON(200, o)
}
// ── Новая панель ────────────────────────────────────────────────────────────
// retire — можно ли выключить старый сервер (ноду #0 с ретранслятором): кто из клиентов и нод
// уже знает новый адрес панели. Клиент узнаёт его из endpoints любого конфига после переноса,
// нода — из ready (core_ok). Ссылки sen:// без устройств ещё никем не открыты: в них старый
// адрес, их выдают заново. nil — панель ничего не принимала (или отметку убрали).
func (m *Migration) retire() any {
since, _ := strconv.ParseInt(os.Getenv("MOVED_IN_AT"), 10, 64)
if since == 0 || m.a.nodes.local {
return nil
}
home := m.a.nodes.home()
var total, fresh, unused int
m.a.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(d.last_seen >= ?), 0) FROM devices d
JOIN master_keys k ON k.id = d.master_id WHERE k.revoked_at IS NULL`, since).Scan(&total, &fresh)
m.a.db.QueryRow(`SELECT COUNT(*) FROM master_keys k WHERE k.revoked_at IS NULL
AND NOT EXISTS (SELECT 1 FROM devices d WHERE d.master_id = k.id)`).Scan(&unused)
nodes := []any{}
if rows, err := m.a.db.Query("SELECT id, name, core_ack FROM nodes WHERE id != 0 AND pub IS NOT NULL ORDER BY id"); err == nil {
for rows.Next() {
var id int64
var name string
var ack sql.NullString
if rows.Scan(&id, &name, &ack) == nil {
nodes = append(nodes, jsjson.Object{{Key: "id", Val: id}, {Key: "name", Val: name},
{Key: "online", Val: m.a.nodes.IsOnline(int(id))}, {Key: "ok", Val: home != "" && ack.String == home}})
}
}
rows.Close()
}
return jsjson.Object{{Key: "since", Val: since}, {Key: "home", Val: orNil(home)},
{Key: "old", Val: jsjson.Object{{Key: "exists", Val: m.a.nodes.Exists(0)}, {Key: "online", Val: m.a.nodes.IsOnline(0)}}},
{Key: "devices", Val: jsjson.Object{{Key: "total", Val: total}, {Key: "fresh", Val: fresh}}},
{Key: "unused_links", Val: unused}, {Key: "nodes", Val: nodes}}
}
func (m *Migration) newCode(c *Ctx) bool {
if why := m.canAccept(); why != "" {
return c.Error(409, why)
}
host, _ := c.Obj().Get2("host").(string)
if h := strings.TrimSpace(os.Getenv("SUB_HOST")); h != "" {
host = h
}
if !reHost.MatchString(host) {
return c.Error(400, "Не удалось определить адрес панели — задайте SUB_HOST")
}
m.mu.Lock()
m.key, m.host, m.expires, m.fails = migrate.NewKey(), host, time.Now().Add(moveCodeTTL), 0
m.mu.Unlock()
logf("migrate: выдан код переноса (действует час)")
return m.status(c)
}
// importHandler — POST /migrate/v1/import, без JWT: доступ даёт только ключ кода (тег GCM).
// Ответ с ошибкой — открытым текстом и только пока ничего не тронуто.
func (m *Migration) importHandler(c *Ctx) bool {
m.mu.Lock()
defer m.mu.Unlock()
if m.key == nil || time.Now().After(m.expires) {
return c.Error(404, "Новая панель не ждёт переноса: выдайте код заново")
}
box, err := io.ReadAll(http.MaxBytesReader(c.W, c.R.Body, moveMaxBundle))
if err != nil {
return c.Error(413, "Архив слишком большой")
}
plain, err := migrate.OpenRequest(m.key, box)
if err != nil {
if m.fails++; m.fails >= moveMaxFails {
m.key = nil
}
warnf("migrate: архив не расшифрован ip=%s", c.remoteIP())
return c.Error(403, "Код не подходит")
}
key := m.key
m.key = nil // код одноразовый
if why := m.canAccept(); why != "" {
return c.Error(409, why)
}
b, err := migrate.Read(bytes.NewReader(plain))
if err != nil {
return c.Error(400, err.Error())
}
defer b.Close()
host := m.host
plan, err := migrate.NewPlan(b, func(k string) int {
switch k {
case "SUB_PORT":
return m.a.sub.port
case "NODE_PORT":
return m.a.nodes.port
}
return 0
}, m.a.port, host)
if err != nil {
return c.Error(409, err.Error())
}
if ip := net.ParseIP(c.remoteIP()); ip != nil {
plan.TrustRelay(ip.String())
}
// Дальше файлы меняются: база закрывается (WAL сливается в файл до того, как его отложат).
paths := m.paths()
fail := func(err error) bool {
m.state, m.errText = moveImported, "Перенос прерван: "+err.Error()+". Прежние файлы — в *.bak-<время>."
warnf("migrate: %v", err)
return c.Error(500, m.errText)
}
m.a.db.Close()
secret := migrate.NewKey()[:16]
if err := migrate.Install(b, paths); err != nil {
return fail(err)
}
if err := migrate.AddNodeZero(paths["ui.db"], b.Str("server_name"), secret); err != nil {
return fail(err)
}
pin, err := migrate.CertPin(paths["node_tls.crt"])
if err != nil {
return fail(fmt.Errorf("сертификат хаба: %w", err))
}
if err := envfile.Set(m.envFile, plan.Env); err != nil {
return fail(err)
}
join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort)
m.state = moveImported
logf("migrate: панель «%s» принята с %s — перезапуск", b.Str("server_name"), c.remoteIP())
m.run("restart")
resp := jsjson.MustMarshal(jsjson.Object{{Key: "join", Val: join}})
c.W.Header().Set("Content-Type", "application/octet-stream")
c.W.WriteHeader(200)
c.W.Write(migrate.SealResponse(key, resp))
return false
}
// ── Старая панель ───────────────────────────────────────────────────────────
func (m *Migration) export(c *Ctx) bool {
if why := m.canExport(); why != "" {
return c.Error(409, why)
}
codeStr, _ := c.Obj().Get2("code").(string)
code, err := migrate.ParseCode(codeStr)
if err != nil {
return c.Error(400, err.Error())
}
m.mu.Lock()
if m.state != moveIdle {
m.mu.Unlock()
return c.Error(409, "Перенос уже идёт")
}
m.state, m.errText = moveSending, ""
m.mu.Unlock()
end := func(state, errText string) {
m.mu.Lock()
m.state, m.errText = state, errText
m.mu.Unlock()
m.a.sub.frozen.Store(state != moveIdle)
}
// Пока архив в пути, подписка отвечает «недоступна» (клиенты держат туннель и повторят):
// регистрация или смена ключа сейчас остались бы только в старой базе.
m.a.sub.frozen.Store(true)
var buf bytes.Buffer
if err := migrate.Write(&buf, m.a.db, m.paths(), m.exportMeta()); err != nil {
end(moveIdle, "")
return c.Error(500, "Архив не собран: "+err.Error())
}
logf("migrate: архив %d КБ → %s", buf.Len()/1024, code.URL())
cl := &http.Client{Timeout: moveTimeout, Transport: &http.Transport{Proxy: nil}}
resp, err := cl.Post(code.URL(), "application/octet-stream", bytes.NewReader(migrate.SealRequest(code.Key, buf.Bytes())))
if err != nil {
// Не дозвонились — новая точно ничего не получила. Иначе архив мог дойти.
var op *net.OpError
if errors.As(err, &op) && op.Op == "dial" {
end(moveIdle, "")
return c.Error(502, "Новая панель не отвечает: "+err.Error())
}
return m.uncertain(c, end, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != 200 {
end(moveIdle, "")
msg := "Новая панель отказала (" + strconv.Itoa(resp.StatusCode) + ")"
if v, derr := jsjson.Decode(raw); derr == nil {
if e, ok := field(v, "error").(string); ok {
msg = "Новая панель: " + e
}
}
return c.Error(502, msg)
}
var plain []byte
if err == nil {
plain, err = migrate.OpenResponse(code.Key, raw)
}
var join string
if err == nil {
v, derr := jsjson.Decode(plain)
join, _ = field(v, "join").(string)
if derr != nil || !strings.HasPrefix(join, "awgjoin://") {
err = errors.New("ответ без join-строки")
}
}
if err != nil {
return m.uncertain(c, end, err)
}
if err := envfile.Set(m.envFile, [][2]string{{"PANEL_MOVED", "on"}}); err != nil {
return m.uncertain(c, end, err)
}
target := strings.TrimSuffix(code.URL(), "/migrate/v1/import")
m.mu.Lock()
m.target = target
m.mu.Unlock()
end(moveMoved, "")
logf("migrate: новая панель приняла перенос — join, этот сервер станет нодой #0")
m.run("join", join)
return c.JSON(200, jsjson.Object{{Key: "ok", Val: true}, {Key: "target", Val: target}})
}
// uncertain — архив ушёл, а подтверждения нет. Подписка остаётся замороженной: если новая
// панель его приняла, вторая живая копия базы разошлась бы с ней.
func (m *Migration) uncertain(c *Ctx, end func(string, string), err error) bool {
msg := "Неизвестно, принят ли перенос (" + err.Error() + "). Откройте новую панель: если там есть сервер #0 — " +
"выполните здесь `awg-ctrl join` со строкой из его карточки; если нет — «Отменить перенос»."
end(moveUncertain, msg)
warnf("migrate: %s", msg)
return c.Error(502, msg)
}
func (m *Migration) cancel(c *Ctx) bool {
m.mu.Lock()
ok := m.state == moveUncertain
if ok {
m.state, m.errText = moveIdle, ""
}
m.mu.Unlock()
if !ok {
return c.Error(409, "Отменять нечего")
}
m.a.sub.frozen.Store(false)
logf("migrate: перенос отменён — подписка снова работает")
return m.status(c)
}
func (m *Migration) exportMeta() jsjson.Object {
name, ip := "VPN", os.Getenv("SERVER_IP")
if r, err := m.a.ctrlLocal("GET", "/health", nil); err == nil {
if s, ok := field(r.Data, "server").(string); ok && s != "" {
name = s
}
if s, ok := field(r.Data, "ip").(string); ok && ip == "" {
ip = s
}
}
return jsjson.Object{
{Key: "version", Val: m.a.version}, {Key: "created_at", Val: now()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: m.a.port}, {Key: "sub_port", Val: m.a.sub.port}, {Key: "sub_tls", Val: m.a.sub.useTLS},
{Key: "node_port", Val: m.a.nodes.port},
}
}
// spawn — `awg-control <args>` отдельным процессом через секунду (ответ успеет уйти). Окружение —
// чистое: CLI читает cli.env заново, а унаследованные значения перекрыли бы новые.
func (m *Migration) spawn(args ...string) {
exe, err := os.Executable()
if err != nil {
warnf("migrate: %v", err)
return
}
var env []string
for _, kv := range os.Environ() {
k, _, _ := strings.Cut(kv, "=")
if k == "PATH" || k == "HOME" || k == "LANG" || k == "TZ" || k == "AWG_ROOT" || strings.HasPrefix(k, "LC_") {
env = append(env, kv)
}
}
logFile := filepath.Join(filepath.Dir(m.a.baseDir), "logs", "awg-migrate.log")
go func() {
time.Sleep(time.Second)
cmd := exec.Command(exe, args...)
cmd.Env, cmd.Dir = env, "/"
if f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
cmd.Stdout, cmd.Stderr = f, f
defer f.Close()
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // переживёт эту панель
if err := cmd.Start(); err != nil {
warnf("migrate: awg-control %s: %v", args[0], err)
return
}
cmd.Wait()
}()
}
+174
View File
@@ -0,0 +1,174 @@
package ui
import (
"bufio"
"io"
"net"
"path/filepath"
"strings"
"testing"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testApp(t *testing.T, dir, local string) *App {
t.Helper()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
return a
}
// Перенос панели (`awg-control migrate`): та же ui.db на панели без VPN, старый сервер — нода
// #0. Клиент должен получить тот же конфиг байт в байт, вместе с rev, иначе он пересоздал бы
// туннель, а сервис на macOS — ещё и ключ.
func TestMigratedConfigIsByteIdentical(t *testing.T) {
oldDir, newDir := t.TempDir(), t.TempDir()
old := testApp(t, oldDir, "on")
s := &Sub{a: old, host: "203.0.113.7", port: 40123}
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0), (1, 1)",
"INSERT INTO nodes (id, name, pub, created_at) VALUES (1, 'Нода', 'pub', 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0'), (5, 1, 'WG', '10.9.0.9', 'PSK1')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES
(0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0","dns":["1.1.1.1"],"mtu":1376,"gen":"3.1","awg":{"Jc":"4"}}', 1),
(1, '{"name":"Нода","endpoint":"192.0.2.2:47619","server_pub":"S1","gen":"2"}', 1)`,
} {
if _, err := old.db.Exec(q); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
dev := s.deviceByID(old.db, 5)
before := jsjson.MustMarshal(s.buildConfig(dev, nil))
if !strings.Contains(string(before), `"id":0`) || !strings.Contains(string(before), `"id":1`) {
t.Fatalf("в конфиге до переноса нет обоих серверов: %s", before)
}
if _, err := old.db.Exec("VACUUM INTO ?", filepath.Join(newDir, "ui.db")); err != nil {
t.Fatal(err)
}
// Как migrate import: нода #0 ещё не подключалась.
pre := testApp(t, newDir, "off")
if _, err := pre.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (0, 'VPN', x'00', 9999999999, 1)"); err != nil {
t.Fatal(err)
}
moved := testApp(t, newDir, "off")
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if s2.panelID != s.panelID {
t.Errorf("panel_id: %s → %s — пиры на старом сервере остались бы без хозяина", s.panelID, s2.panelID)
}
after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(moved.db, 5), nil))
if string(before) != string(after) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) || moved.nodes.IsOnline(0) || moved.nodes.DefaultID() != 0 {
t.Errorf("нода #0: exists=%v online=%v default=%d", moved.nodes.Exists(0), moved.nodes.IsOnline(0), moved.nodes.DefaultID())
}
if ids := moved.nodes.IDs(); len(ids) != 2 || ids[0] != 0 || ids[1] != 1 {
t.Errorf("IDs = %v", ids)
}
if r, _ := moved.nodes.CtrlFor(0)("GET", "/health", nil); r.Status != 502 {
t.Errorf("нода #0 не на связи: %d, ожидали 502", r.Status)
}
if l := moved.nodes.List(); len(l) != 2 || l[0].Local || !l[0].Pending {
t.Errorf("List: %+v", l)
}
// Без SUB_HOST адрес подписки — не endpoint ноды #0: это другая машина.
s2.host = ""
if h := s2.subHost(nil); h != "" {
t.Errorf("subHost = %q, ожидали пусто", h)
}
// Свой VPN и нода #0 — один id: такую базу с LOCAL_NODE=on не открываем.
t.Setenv("LOCAL_NODE", "on")
if _, err := newNodes(&App{db: moved.db, baseDir: newDir}); err == nil {
t.Error("LOCAL_NODE=on при ноде #0: ожидали ошибку")
}
}
func TestParseProxyV1(t *testing.T) {
for line, want := range map[string]string{
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "192.0.2.1:51000",
"PROXY TCP6 2001:db8::1 2001:db8::2 51000 40123": "[2001:db8::1]:51000",
"PROXY UNKNOWN": "unknown",
"PROXY UNKNOWN ffff::1 ffff::2 1 2": "unknown",
"PROXY TCP4 2001:db8::1 198.51.100.2 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 70000 1": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 1": "",
"PROXY TCP5 192.0.2.1 198.51.100.2 1 2": "",
"GET / HTTP/1.1": "",
"PROXY TCP4 192.0.2.1 not-an-ip 1 2": "",
"PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123": "",
} {
addr, ok := parseProxyV1(line)
got := ""
switch {
case ok && addr == nil:
got = "unknown"
case ok:
got = addr.String()
}
if got != want {
t.Errorf("%q → %q, ожидали %q", line, got, want)
}
}
}
// Заголовок PROXY — только от доверенного адреса; соединение с него без заголовка проходит как
// есть, а от остальных заголовок остаётся байтами потока.
func TestProxyListener(t *testing.T) {
try := func(trusted bool, send string) (remote, data string) {
t.Helper()
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer raw.Close()
ln := newProxyListener(raw, map[string]bool{"127.0.0.1": trusted})
cl, err := net.Dial("tcp", raw.Addr().String())
if err != nil {
t.Fatal(err)
}
defer cl.Close()
io.WriteString(cl, send)
cl.(*net.TCPConn).CloseWrite()
done := make(chan net.Conn, 1)
go func() { c, _ := ln.Accept(); done <- c }()
select {
case c := <-done:
defer c.Close()
b, _ := io.ReadAll(bufio.NewReader(c))
return c.RemoteAddr().String(), string(b)
case <-time.After(3 * time.Second):
t.Fatal("Accept не вернулся")
}
return "", ""
}
if r, d := try(true, "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"); r != "192.0.2.1:51000" || d != "hello" {
t.Errorf("доверенный с заголовком: %s %q", r, d)
}
if r, d := try(true, "GET / HTTP/1.1\r\n"); !strings.HasPrefix(r, "127.0.0.1:") || d != "GET / HTTP/1.1\r\n" {
t.Errorf("доверенный без заголовка: %s %q", r, d)
}
hdr := "PROXY TCP4 192.0.2.1 198.51.100.2 51000 40123\r\nhello"
if r, d := try(false, hdr); !strings.HasPrefix(r, "127.0.0.1:") || d != hdr {
t.Errorf("чужой с заголовком: %s %q", r, d)
}
}
+168
View File
@@ -0,0 +1,168 @@
package ui
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
"github.com/maeneko/forgetting/internal/migrate"
)
type movePanel struct {
a *App
s *Sub
env string
calls [][]string
}
func newMovePanel(t *testing.T, local, nodePort string) *movePanel {
t.Helper()
dir := t.TempDir()
t.Setenv("LOCAL_NODE", local)
t.Setenv("NODE_PORT", nodePort)
t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key"))
t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt"))
db, err := openUIDB(filepath.Join(dir, "ui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
a := &App{db: db, baseDir: dir, port: 34001}
if a.nodes, err = newNodes(a); err != nil {
t.Fatal(err)
}
a.sub = &Sub{a: a, host: "203.0.113.7", port: 40123, locks: map[int]*sync.Mutex{}}
if err := a.sub.initSchema(); err != nil {
t.Fatal(err)
}
p := &movePanel{a: a, s: a.sub, env: filepath.Join(dir, "cli.env")}
a.migration = newMigration(a)
a.migration.envFile = p.env
a.migration.run = func(args ...string) { p.calls = append(p.calls, args) }
return p
}
func (p *movePanel) call(t *testing.T, h handler, body jsjson.Object) (int, jsjson.Object) {
t.Helper()
w := httptest.NewRecorder()
h(&Ctx{W: w, R: httptest.NewRequest("POST", "/", nil), Body: body})
v, _ := jsjson.Decode(w.Body.Bytes())
o, _ := v.(jsjson.Object)
return w.Code, o
}
// Вкладка «Перенос» целиком: код новой панели → архив из старой по HTTP → нода #0, join и
// перезапуск. У клиента конфиг тот же, у старой панели подписка заморожена до join.
func TestMigrateBetweenPanels(t *testing.T) {
old := newMovePanel(t, "on", "")
os.WriteFile(filepath.Join(old.a.baseDir, "sub_sign.key"), []byte("ключ подписи"), 0o600)
for _, q := range []string{
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_servers (master_id, server_id) VALUES (1, 0)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', 'AUTH', 1)",
"INSERT INTO device_peers (device_id, server_id, peer_pub, ip, psk) VALUES (5, 0, 'WG', '10.9.0.5', 'PSK0')",
`INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (0, '{"name":"VPN","endpoint":"198.51.100.1:47619","server_pub":"S0"}', 1)`,
} {
if _, err := old.a.db.Exec(q); err != nil {
t.Fatal(err)
}
}
before := jsjson.MustMarshal(old.s.buildConfig(old.s.deviceByID(old.a.db, 5), nil))
fresh := newMovePanel(t, "off", "40500")
m := fresh.a.migration
if why := m.canAccept(); why != "" {
t.Fatalf("чистая панель без VPN не принимает: %s", why)
}
if why := old.a.migration.canAccept(); why == "" {
t.Error("панель со своим VPN принимает перенос")
}
if why := fresh.a.migration.canExport(); why == "" {
t.Error("панель без VPN отдаёт перенос")
}
rt := newRouter()
rt.Post("/migrate/v1/import", m.importHandler)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, raw, _, _ := readBody(r, rt.bodyLimit)
rt.routes[0].hs[0](&Ctx{W: w, R: r, Body: body, RawBody: raw})
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port, _ := strconv.Atoi(u.Port())
m.key, m.host, m.expires = migrate.NewKey(), "127.0.0.1", nowFn().Add(moveCodeTTL)
// Чужой ключ: архив не принят, код живёт до пятой неудачи.
wrong := migrate.Code{Host: "127.0.0.1", Port: port, Key: migrate.NewKey()}.String()
if st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: wrong}}); st != 502 || !strings.Contains(o.Get2("error").(string), "Код не подходит") {
t.Fatalf("чужой код: %d %v", st, o)
}
if old.s.frozen.Load() || m.key == nil {
t.Fatal("после отказа подписка заморожена или код сгорел")
}
code := migrate.Code{Host: "127.0.0.1", Port: port, Key: m.key}.String()
st, o := old.call(t, old.a.migration.export, jsjson.Object{{Key: "code", Val: code}})
if st != 200 || o.Get2("target") != "http://127.0.0.1:"+u.Port() {
t.Fatalf("export: %d %v", st, o)
}
if !old.s.frozen.Load() {
t.Error("старая подписка не заморожена до join")
}
if len(old.calls) != 1 || old.calls[0][0] != "join" {
t.Fatalf("старая панель: %v", old.calls)
}
j, _ := jsjson.Decode(fromB64url(strings.TrimPrefix(old.calls[0][1], "awgjoin://")))
if field(j, "n") != 0.0 || field(j, "p") != 40500.0 || field(j, "sub") != 40123.0 || field(j, "ui") != 34001.0 {
t.Errorf("join: %s", jsjson.MustMarshal(j))
}
if raw, _ := os.ReadFile(old.env); !strings.Contains(string(raw), "PANEL_MOVED=on") {
t.Errorf("старый cli.env: %q", raw)
}
if len(fresh.calls) != 1 || fresh.calls[0][0] != "restart" || m.state != moveImported || m.key != nil {
t.Errorf("новая панель: %v, state %q", fresh.calls, m.state)
}
raw, _ := os.ReadFile(fresh.env)
for _, want := range []string{"SUB_PORT=40123", "NODE_PORT=40500", "SUB_HOST=127.0.0.1", "RELAY_FROM=127.0.0.1"} {
if !strings.Contains(string(raw), want) {
t.Errorf("новый cli.env без %s: %q", want, raw)
}
}
if k, _ := os.ReadFile(filepath.Join(fresh.a.baseDir, "sub_sign.key")); string(k) != "ключ подписи" {
t.Errorf("ключ подписи: %q", k)
}
// Новая панель после перезапуска: та же база + нода #0 → тот же конфиг.
t.Setenv("NODE_PORT", "")
db, err := openUIDB(filepath.Join(fresh.a.baseDir, "ui.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
moved := &App{db: db, baseDir: fresh.a.baseDir}
if moved.nodes, err = newNodes(moved); err != nil {
t.Fatal(err)
}
s2 := &Sub{a: moved, host: "203.0.113.7", port: 40123}
if err := s2.initSchema(); err != nil {
t.Fatal(err)
}
if after := jsjson.MustMarshal(s2.buildConfig(s2.deviceByID(db, 5), nil)); string(after) != string(before) {
t.Errorf("конфиг изменился:\n было %s\nстало %s", before, after)
}
if !moved.nodes.Exists(0) {
t.Error("нет ноды #0")
}
// Повторный приём: кода уже нет.
if st, _ := old.call(t, func(c *Ctx) bool { old.a.migration.state = moveIdle; return old.a.migration.export(c) },
jsjson.Object{{Key: "code", Val: code}}); st != 502 {
t.Errorf("второй перенос тем же кодом: %d", st)
}
}
+48 -14
View File
@@ -6,7 +6,9 @@ package ui
// своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
//
// server_id 0 — локальный awg-ctrl этой же машины; при LOCAL_NODE=off его нет, core работает
// как чистая панель. Протокол и формат join-строки — docs/node-protocol.md.
// как чистая панель. Номер 0 тогда может занять нода — сервер, с которого панель переехала
// (`awg-control migrate`): так у его пиров, ключей API и устройств не меняется id сервера.
// Протокол и формат join-строки — docs/node-protocol.md.
import (
"context"
@@ -201,6 +203,18 @@ func newNodes(a *App) (*Nodes, error) {
return nil, err
}
}
// core_ack — адрес панели, который агент записал к себе (кадр core_ok): нода переживёт
// выключение сервера, через который подключена сейчас.
if !hasColumn(a.db, "nodes", "core_ack") {
if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN core_ack TEXT"); err != nil {
return nil, err
}
}
// Нода №0 и локальный сервер — один и тот же id: вместе их не бывает.
if n.local && n.row(0) != nil {
return nil, fmt.Errorf("nodes: в ui.db есть нода #0 (сервер, с которого переехала панель), а LOCAL_NODE=on — " +
"у этой панели свой VPN не включают")
}
return n, nil
}
@@ -294,15 +308,15 @@ func (n *Nodes) rpc(id int64, method, path string, body any) *CtrlResp {
// CtrlFor — контроллер нужного сервера: 0 — локальный awg-ctrl, остальное — нода по WSS.
func (n *Nodes) CtrlFor(id int) Ctrl {
if id == 0 {
if !n.local {
if id == 0 && n.local {
return n.a.ctrlLocal
}
if n.row(int64(id)) == nil {
if id == 0 {
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
}
@@ -311,16 +325,16 @@ func (n *Nodes) CtrlFor(id int) Ctrl {
}
func (n *Nodes) Exists(id int) bool {
if id == 0 {
return n.local
if id == 0 && n.local {
return true
}
return n.row(int64(id)) != nil
}
// IsOnline — есть ли сейчас связь (локальный считаем доступным всегда).
func (n *Nodes) IsOnline(id int) bool {
if id == 0 {
return n.local
if id == 0 && n.local {
return true
}
n.mu.Lock()
defer n.mu.Unlock()
@@ -385,7 +399,7 @@ func (n *Nodes) DefaultID() int {
func (n *Nodes) refreshHealth(id int64) {
var r *CtrlResp
if id == 0 {
if id == 0 && n.local {
r, _ = n.a.ctrlLocal("GET", "/health", nil)
} else {
r = n.rpc(id, "GET", "/health", nil)
@@ -532,7 +546,11 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
n.dropConn(row.ID, old)
closeWS(old.ws, 4000, "replaced")
}
ws.Write(context.Background(), websocket.MessageText, []byte(`{"t":"ready"}`))
ready := jsjson.Object{{Key: "t", Val: "ready"}}
if home := n.home(); home != "" {
ready = append(ready, jsjson.KV{Key: "core", Val: home})
}
ws.Write(context.Background(), websocket.MessageText, jsjson.MustMarshal(ready))
first := ""
if joining {
first = " (первое подключение)"
@@ -561,6 +579,12 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
authenticate(m)
continue
}
if m.Get2("t") == "core_ok" {
if home, ok := m.Get2("core").(string); ok && home == n.home() {
n.a.db.Exec("UPDATE nodes SET core_ack = ? WHERE id = ?", home, nodeID)
}
continue
}
if m.Get2("t") == "res" && isInt(m.Get2("id")) {
rid := int64(m.Get2("id").(float64))
conn.mu.Lock()
@@ -598,7 +622,7 @@ func (n *Nodes) Start() {
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)
ln, err := listen(n.port)
if err != nil {
warnf("nodes: хаб не запущен: %v", err)
return
@@ -656,6 +680,16 @@ func (n *Nodes) Start() {
func (n *Nodes) hubReady() bool { return n.port > 0 && n.tls != nil && n.pin != nil }
// home — публичный адрес хаба, который он сообщает нодам в ready (агент звонит туда первым).
// Из SUB_HOST: у панели один публичный адрес; не задан — нодам хватает адреса из join.
func (n *Nodes) home() string {
host := strings.TrimSpace(os.Getenv("SUB_HOST"))
if host == "" || !n.hubReady() {
return ""
}
return net.JoinHostPort(host, strconv.Itoa(n.port))
}
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)}})
@@ -732,7 +766,7 @@ func (s serverInfo) json() jsjson.Object {
func (n *Nodes) idOf(c *Ctx) (int64, bool) {
f := jsToNumber(c.Params["id"])
return int64(f), f == float64(int64(f)) && f >= 1
return int64(f), f == float64(int64(f)) && (f >= 1 || f == 0 && !n.local)
}
func (n *Nodes) router() *Router {
+149
View File
@@ -0,0 +1,149 @@
package ui
// PROXY protocol v1 от ретранслятора (`awg-control relay`): после переноса панели старый
// сервер пересылает на неё подписку, хаб нод и панель. Без заголовка все клиенты пришли бы с
// его IP и делили бы на всех лимиты по адресу (неудачные запросы, регистрации, вход). Заголовку
// верим только от адресов из RELAY_FROM; от остальных он — просто байты соединения.
import (
"bufio"
"net"
"os"
"strconv"
"strings"
"time"
)
const proxyHeaderTimeout = 5 * time.Second
// relayFrom — RELAY_FROM: IP ретрансляторов через запятую.
func relayFrom() map[string]bool {
out := map[string]bool{}
for _, s := range strings.Split(os.Getenv("RELAY_FROM"), ",") {
if ip := net.ParseIP(strings.TrimSpace(s)); ip != nil {
out[ip.String()] = true
}
}
return out
}
// listen — net.Listen("tcp", :port), а при заданном RELAY_FROM — с разбором PROXY v1.
func listen(port int) (net.Listener, error) {
ln, err := net.Listen("tcp", ":"+strconv.Itoa(port))
if err != nil {
return nil, err
}
if trusted := relayFrom(); len(trusted) > 0 {
return newProxyListener(ln, trusted), nil
}
return ln, nil
}
type accepted struct {
c net.Conn
err error
}
// proxyListener читает заголовок в своей горутине на соединение: медленный ретранслятор не
// держит Accept для остальных.
type proxyListener struct {
net.Listener
trusted map[string]bool
ch chan accepted
}
func newProxyListener(ln net.Listener, trusted map[string]bool) *proxyListener {
l := &proxyListener{Listener: ln, trusted: trusted, ch: make(chan accepted)}
go l.loop()
return l
}
func (l *proxyListener) loop() {
for {
c, err := l.Listener.Accept()
if err != nil {
l.ch <- accepted{nil, err}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
continue
}
return
}
host, _, _ := net.SplitHostPort(c.RemoteAddr().String())
if ip := net.ParseIP(host); ip == nil || !l.trusted[ip.String()] {
l.ch <- accepted{c, nil}
continue
}
go func() {
if pc := readProxyHeader(c); pc != nil {
l.ch <- accepted{pc, nil}
}
}()
}
}
func (l *proxyListener) Accept() (net.Conn, error) {
a := <-l.ch
return a.c, a.err
}
type proxyConn struct {
net.Conn
r *bufio.Reader
remote net.Addr
}
func (c *proxyConn) Read(b []byte) (int, error) { return c.r.Read(b) }
func (c *proxyConn) RemoteAddr() net.Addr { return c.remote }
// readProxyHeader — соединение без заголовка остаётся как есть (с ретранслятора может прийти и
// обычный клиент); битый заголовок — закрываем. nil — соединение закрыто.
func readProxyHeader(c net.Conn) net.Conn {
c.SetReadDeadline(time.Now().Add(proxyHeaderTimeout))
defer c.SetReadDeadline(time.Time{})
r := bufio.NewReaderSize(c, 256)
pc := &proxyConn{Conn: c, r: r, remote: c.RemoteAddr()}
if sig, err := r.Peek(6); err != nil || string(sig) != "PROXY " {
if err != nil && len(sig) == 0 {
c.Close()
return nil
}
return pc
}
// Строка v1 — не длиннее 107 байт вместе с CRLF.
line, err := r.ReadSlice('\n')
if err != nil || len(line) > 107 || !strings.HasSuffix(string(line), "\r\n") {
c.Close()
return nil
}
if addr, ok := parseProxyV1(strings.TrimSuffix(string(line), "\r\n")); ok {
if addr != nil {
pc.remote = addr
}
return pc
}
c.Close()
return nil
}
// parseProxyV1 — «PROXY TCP4|TCP6 src dst sport dport» или «PROXY UNKNOWN …» (addr == nil).
func parseProxyV1(line string) (*net.TCPAddr, bool) {
f := strings.Split(line, " ")
if len(f) >= 2 && f[0] == "PROXY" && f[1] == "UNKNOWN" {
return nil, true
}
if len(f) != 6 || f[0] != "PROXY" || f[1] != "TCP4" && f[1] != "TCP6" {
return nil, false
}
ip := net.ParseIP(f[2])
if ip == nil || net.ParseIP(f[3]) == nil || (ip.To4() != nil) != (f[1] == "TCP4") {
return nil, false
}
port, err := strconv.Atoi(f[4])
if err != nil || port < 0 || port > 65535 {
return nil, false
}
if p, err := strconv.Atoi(f[5]); err != nil || p < 0 || p > 65535 {
return nil, false
}
return &net.TCPAddr{IP: ip, Port: port}, true
}
+102
View File
@@ -0,0 +1,102 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/jsjson"
)
// После переноса старый сервер выключают, когда новый адрес знают все: хаб сообщает его
// нодам в ready и запоминает подтверждение (core_ok), клиенты получают его в endpoints.
// Вкладка «Перенос» показывает, сколько осталось.
func TestRetireReadiness(t *testing.T) {
t.Setenv("SUB_HOST", "198.51.100.20")
p := newMovePanel(t, "off", "40500")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
for _, q := range []string{
"INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'Старый', 'x', 1)",
"INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', '" + b64url.EncodeToString(pub) + "', 1)",
"INSERT INTO nodes (id, name, created_at) VALUES (4, 'Ещё не подключена', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (2, 'u2', 'Не открыта', x'02', 1)",
"INSERT INTO master_keys (id, uuid, label, secret, created_at, revoked_at) VALUES (3, 'u3', 'Отозвана', x'03', 1, 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (5, 1, 'a', 'W5', 'A5', 1, 2000)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (6, 1, 'b', 'W6', 'A6', 1, 500)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (7, 1, 'c', 'W7', 'A7', 1)",
"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at, last_seen) VALUES (8, 3, 'd', 'W8', 'A8', 1, 2000)",
} {
if _, err := p.a.db.Exec(q); err != nil {
t.Fatal(q, err)
}
}
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ws, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer ws.CloseNow()
read := func() jsjson.Object {
_, raw, err := ws.Read(ctx)
if err != nil {
t.Fatal(err)
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
return o
}
write := func(o jsjson.Object) { ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(o)) }
nonce := read().Get2("nonce").(string)
write(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: 3},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(priv, authMsg(nonce, 3)))}})
if got := string(jsjson.MustMarshal(read())); got != `{"t":"ready","core":"198.51.100.20:40500"}` {
t.Fatalf("ready: %s", got)
}
retire := func() jsjson.Object {
_, o := p.call(t, p.a.migration.status, nil)
r, _ := o.Get2("retire").(jsjson.Object)
return r
}
if retire() != nil {
t.Error("готовность показана панели, которая ничего не принимала")
}
t.Setenv("MOVED_IN_AT", "1000")
if n := string(jsjson.MustMarshal(retire().Get2("nodes"))); n != `[{"id":3,"name":"Frankfurt","online":true,"ok":false}]` {
t.Errorf("до core_ok: %s", n)
}
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "203.0.113.66:1"}}) // не наш адрес — мимо
write(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: "198.51.100.20:40500"}})
var r jsjson.Object
for range 50 {
if r = retire(); strings.Contains(string(jsjson.MustMarshal(r.Get2("nodes"))), `"ok":true`) {
break
}
time.Sleep(20 * time.Millisecond)
}
want := `{"since":1000,"home":"198.51.100.20:40500","old":{"exists":true,"online":false},` +
`"devices":{"total":3,"fresh":1},"unused_links":1,"nodes":[{"id":3,"name":"Frankfurt","online":true,"ok":true}]}`
if got := string(jsjson.MustMarshal(r)); got != want {
t.Errorf("готовность:\n got %s\nwant %s", got, want)
}
os.WriteFile(p.env, []byte("UI_PASS=x\nMOVED_IN_AT=1000\n"), 0o600)
w := httptest.NewRecorder()
p.a.migration.router().ServeHTTP(w, httptest.NewRequest("DELETE", "/retire", nil))
if raw, _ := os.ReadFile(p.env); strings.Contains(string(raw), "MOVED_IN_AT") || retire() != nil {
t.Errorf("отметка не убрана: %q", raw)
}
}
+11 -6
View File
@@ -11,7 +11,6 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"net"
"net/http"
"net/url"
"os"
@@ -109,10 +108,11 @@ type App struct {
revoked map[string]bool
loginAttempts map[string]*rateEntry
nodes *Nodes
sub *Sub
metrics *Metrics
monitor *Monitor
nodes *Nodes
sub *Sub
metrics *Metrics
monitor *Monitor
migration *Migration
}
// New готовит панель: база, ключи, модули. Каталог панели — рабочий каталог процесса
@@ -185,6 +185,7 @@ func New() (*App, error) {
return nil, err
}
a.monitor = newMonitor(a)
a.migration = newMigration(a)
a.nodes.BeforeDelete(a.apiKeysBeforeDelete)
a.nodes.AfterDelete(a.apiKeysAfterDelete)
return a, nil
@@ -876,6 +877,10 @@ func (a *App) routes() *Router {
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
app.Use("/ui/monitor", auth)
app.Mount("/ui/monitor", a.monitor.router())
// Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса.
app.Post("/migrate/v1/import", a.migration.importHandler)
app.Use("/ui/migrate", auth)
app.Mount("/ui/migrate", a.migration.router())
for _, p := range []string{"/api", "/health", "/awg"} {
app.Use(p, auth, a.proxy)
@@ -937,7 +942,7 @@ func serveFile(w http.ResponseWriter, r *http.Request, f string) {
// Run слушает панель на :PORT и запускает фоновые части (хаб нод, подписку).
func (a *App) Run() error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port))
ln, err := listen(a.port)
if err != nil {
return err
}
+14 -3
View File
@@ -29,13 +29,13 @@ import (
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
@@ -194,6 +194,8 @@ type Sub struct {
registerLimit, failLimit *limiter
frozen atomic.Bool // идёт перенос панели (migrate.go): /sub/v1 отвечает «недоступна»
auditMu sync.Mutex
lastAudit *auditReport
auditSettings auditSettings
@@ -780,7 +782,8 @@ func (s *Sub) subHost(c *Ctx) string {
if s.host != "" {
return s.host
}
if s.a.nodes.Exists(0) {
// Только свой сервер: у ноды №0 (переехавшей панели) endpoint — другая машина.
if s.a.nodes.local {
if p := s.storedProfile(0); p != nil {
if ep, ok := p.Get2("endpoint").(string); ok {
return hostOfEndpoint(ep)
@@ -1200,6 +1203,14 @@ func (s *Sub) publicRouter() *Router {
s.send(w, r, 400, subErr("bad_request"))
}
rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) }
// Перенос панели: изменения сейчас остались бы только в уезжающей базе. 502 unavailable
// клиент понимает как «подписка недоступна» — туннель не трогает и повторит позже.
rt.Use("/", func(c *Ctx) bool {
if s.frozen.Load() {
return s.sendC(c, 502, subErr("unavailable"))
}
return true
})
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))
@@ -1248,7 +1259,7 @@ func (s *Sub) Start() {
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)
ln, err := listen(s.port)
if err != nil {
warnf("sub: листенер не запущен: %v", err)
return
+227 -32
View File
@@ -210,6 +210,12 @@ func (s *Sub) createMaster(body any, scope []int) (jsjson.Object, error) {
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())}
}
@@ -281,37 +287,7 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
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 {
if _, err := tx.Exec("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?", m.ID, 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 (?, ?)", m.ID, 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
}); err != nil {
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...)
@@ -339,6 +315,215 @@ func (s *Sub) updateMaster(m *masterRow, body any, scope []int) (jsjson.Object,
{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)
@@ -571,8 +756,18 @@ func (s *Sub) masterKeysRouter() *Router {
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)}})
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) {