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.
103 lines
4.4 KiB
Go
103 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|