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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user