Files
forgetting/internal/migrate/migrate_test.go
T
maeneko 96c21408e6 Prometheus on a node; panel under a URL prefix; open ports on migrate import
A panel without VPN (e.g. after the move) can use a Prometheus that lives on
a node, such as the one with history left on the old server (node #0). Both
directions go through the agent's WSS: the node's Prometheus scrapes
127.0.0.1:AGENT_METRICS_PORT/metrics, the agent checks its token and asks
core (metrics → metrics_res, the same exposition as /metrics, at most once
per 5 s per connection); the monitor tab reads it back through the agent,
which forwards only GET /-/ready and /api/v1/query[_range]. The panel finds
it by itself (local first, then the lowest-id node), and the install/remove
buttons work on a panel without VPN. `monitor install` on a node without its
own panel picks AGENT_METRICS_PORT, writes it to cli.env and restarts the
agent; on the old server it keeps the collected data.

The panel works behind nginx in a subfolder: relative asset and API paths,
relative directory redirects, and the client address from X-Forwarded-For /
X-Real-IP only from trusted proxies (loopback, TRUST_PROXY).

migrate import opens the moved SUB_PORT/NODE_PORT in UFW when it is active.
2026-10-21 19:25:54 +03:00

206 lines
7.2 KiB
Go

package migrate
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/maeneko/forgetting/internal/jsjson"
)
func testDB(t *testing.T, file string) {
t.Helper()
db, err := OpenDB(file)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, q := range []string{
"CREATE TABLE sub_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
"INSERT INTO sub_meta VALUES ('panel_id', 'abcdef123456')",
"CREATE TABLE nodes (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, pub TEXT, join_hash BLOB, join_expires INTEGER, created_at INTEGER NOT NULL, last_seen INTEGER, version TEXT)",
"CREATE TABLE master_keys (id INTEGER PRIMARY KEY)",
"INSERT INTO master_keys VALUES (1)",
} {
if _, err := db.Exec(q); err != nil {
t.Fatal(err)
}
}
}
// Архив → раскладка: всё, что зашито в ссылки и у нод, едет как есть; база — целостным
// снимком, в котором новая панель заводит ноду #0.
func TestBundleRoundTrip(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
paths := func(dir string) Paths {
return PathsFrom(func(env string) string { return filepath.Join(dir, strings.ToLower(env)) })
}
from, to := paths(src), paths(dst)
testDB(t, from["ui.db"])
for name, v := range map[string]string{"sub_sign.key": "sign", "sub_tls.key": "tlskey", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
os.WriteFile(from[name], []byte(v), 0o600)
}
os.WriteFile(to["sub_sign.key"], []byte("прежний"), 0o600)
db, _ := OpenDB(from["ui.db"])
var buf bytes.Buffer
err := Write(&buf, db, from, jsjson.Object{{Key: "server_ip", Val: "198.51.100.1"}, {Key: "sub_port", Val: 40123},
{Key: "sub_tls", Val: true}, {Key: "node_port", Val: 40500}})
db.Close()
if err != nil {
t.Fatal(err)
}
b, err := Read(&buf)
if err != nil {
t.Fatal(err)
}
defer b.Close()
if b.Str("panel_id") != "abcdef123456" || b.Num("sub_port") != 40123 || b.Has["node_tls.crt"] {
t.Errorf("meta %s, has %v", jsjson.MustMarshal(b.Meta), b.Has)
}
if n, err := PanelData(to["ui.db"]); err != nil || n != 0 {
t.Errorf("PanelData(нет файла) = %d, %v", n, err)
}
plan, err := NewPlan(b, func(string) int { return 0 }, 33000, "203.0.113.7")
if err != nil {
t.Fatal(err)
}
got := regexp.MustCompile(`"MOVED_IN_AT","\d+"`).ReplaceAllString(string(jsjson.MustMarshal(plan.Env)), `"MOVED_IN_AT","…"`)
if got != `[["SUB_PORT","40123"],["NODE_PORT","40500"],["SUB_HOST","203.0.113.7"],["SUB_TLS","on"],["MOVED_IN_AT","…"],["RELAY_FROM","198.51.100.1"]]` {
t.Errorf("env: %s", got)
}
if _, err := NewPlan(b, func(string) int { return 0 }, 40500, "h"); err == nil {
t.Error("UI_PORT = порт хаба: ожидали ошибку")
}
if err := Install(b, to); err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{"sub_sign.key": "sign", "sub_tls.crt": "tlscrt", "node_tls.key": "nodekey"} {
if raw, _ := os.ReadFile(to[name]); string(raw) != want {
t.Errorf("%s: %q", name, raw)
}
}
if baks, _ := filepath.Glob(to["sub_sign.key"] + ".bak-*"); len(baks) != 1 {
t.Errorf("прежний ключ не отложен: %v", baks)
}
if n, _ := PanelData(to["ui.db"]); n != 1 {
t.Errorf("PanelData(снимок) = %d", n)
}
if err := AddNodeZero(to["ui.db"], "VPN\n<script>", []byte("0123456789abcdef")); err != nil {
t.Fatal(err)
}
db, _ = OpenDB(to["ui.db"])
var name string
db.QueryRow("SELECT name FROM nodes WHERE id = 0").Scan(&name)
db.Close()
if name != "VPN" {
t.Errorf("нода #0: %q", name)
}
if err := AddNodeZero(to["ui.db"], "VPN", []byte("x")); err == nil {
t.Error("вторая нода #0: ожидали ошибку")
}
}
func TestReadRejectsForeignEntries(t *testing.T) {
for name, entries := range map[string][]string{
"лишний файл": {"meta.json", "ui.db", "../../etc/passwd"},
"без базы": {"meta.json"},
"повтор": {"meta.json", "ui.db", "ui.db"},
} {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, e := range entries {
data := []byte("x")
if e == "meta.json" {
data = []byte(`{"v":1}`)
}
tw.WriteHeader(&tar.Header{Name: e, Mode: 0o600, Size: int64(len(data))})
tw.Write(data)
}
tw.Close()
gz.Close()
if b, err := Read(&buf); err == nil {
b.Close()
t.Errorf("%s: архив принят", name)
}
}
}
func TestJoinString(t *testing.T) {
s := JoinString("203.0.113.7", 40500, strings.Repeat("A", 43), 0, []byte("0123456789abcdef"), 33100, 40123)
raw, _ := b64url.DecodeString(strings.TrimPrefix(s, "awgjoin://"))
// Поля сверх v1 — в конце: install.sh и старые CLI их не замечают.
if want := `{"v":1,"h":"203.0.113.7","p":40500,"pin":"` + strings.Repeat("A", 43) + `","n":0,"s":"MDEyMzQ1Njc4OWFiY2RlZg","ui":33100,"sub":40123}`; string(raw) != want {
t.Errorf("join: %s", raw)
}
}
func TestCodeAndSeal(t *testing.T) {
c := Code{Host: "2001:db8::7", Port: 33000, Key: NewKey()}
got, err := ParseCode(" " + c.String() + "\n")
if err != nil || got.Host != c.Host || got.Port != c.Port || !bytes.Equal(got.Key, c.Key) {
t.Fatalf("ParseCode: %+v %v", got, err)
}
if u := got.URL(); u != "http://[2001:db8::7]:33000/migrate/v1/import" {
t.Errorf("URL: %s", u)
}
for _, bad := range []string{"", "awgmove://", "awgjoin://e30", CodePrefix + "e30",
Code{Host: "h/../x", Port: 1, Key: c.Key}.String(), Code{Host: "h", Port: 0, Key: c.Key}.String(), Code{Host: "h", Port: 1, Key: []byte("short")}.String()} {
if _, err := ParseCode(bad); err == nil {
t.Errorf("ParseCode(%q): ожидали ошибку", bad)
}
}
box := SealRequest(c.Key, []byte("архив"))
if plain, err := OpenRequest(c.Key, box); err != nil || string(plain) != "архив" {
t.Errorf("OpenRequest: %q %v", plain, err)
}
if _, err := OpenResponse(c.Key, box); err == nil {
t.Error("запрос открылся как ответ")
}
if _, err := OpenRequest(NewKey(), box); err == nil {
t.Error("открылся чужим ключом")
}
box[len(box)-1] ^= 1
if _, err := OpenRequest(c.Key, box); err == nil {
t.Error("открылся повреждённый")
}
}
// Приём переноса открывает новые порты в UFW, только если он активен, — как install.sh.
func TestOpenFirewall(t *testing.T) {
var calls []string
status := "Status: active"
defer func(f func(...string) ([]byte, error)) { ufw = f }(ufw)
ufw = func(args ...string) ([]byte, error) {
calls = append(calls, strings.Join(args, " "))
if args[0] == "status" {
return []byte(status), nil
}
if len(args) > 1 && args[1] == "45000/tcp" {
return nil, errors.New("exit 1")
}
return nil, nil
}
opened, failed := OpenFirewall(40222, 45000, 0)
if strings.Join(opened, ",") != "40222/tcp" || strings.Join(failed, ",") != "45000/tcp" {
t.Errorf("opened %v failed %v", opened, failed)
}
if strings.Join(calls, "; ") != "status; allow 40222/tcp; allow 45000/tcp; reload" {
t.Errorf("вызовы: %v", calls)
}
calls, status = nil, "Status: inactive"
if opened, _ := OpenFirewall(40222); opened != nil || len(calls) != 1 {
t.Errorf("UFW выключен, а правила трогаются: %v", calls)
}
}