Merge origin/main into the 0.5.0 redesign
ci / check (push) Failing after 6m27s

Brings in 0.4.8: connection log, uninstall and update from the awg-ctrl menu,
secret panel path, stealth hub and sen:// port, random I1/MTU on install.
install.sh keeps the new output (spinner, cards, outdated-install wipe) on top
of the reorganised installer; an outdated install can be reinstalled with a
wipe or removed. The connection log tab uses the MD3 segmented button; its
metrics-token scrape snippet moved to Settings.
This commit is contained in:
2026-10-29 23:07:03 +03:00
40 changed files with 3639 additions and 780 deletions
+224
View File
@@ -0,0 +1,224 @@
package ui
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/maeneko/forgetting/internal/agent"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Хаб нод: WebSocket — только агенту с подписью в заголовках; остальным 404 пустого сервера.
// Старых агентов (без подписи) пускают, пока не все ноды подключались по-новому.
func TestHubPreauth(t *testing.T) {
p := newMovePanel(t, "on", "")
pub3, priv3, _ := ed25519.GenerateKey(rand.Reader)
_, stranger, _ := ed25519.GenerateKey(rand.Reader)
if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', ?, 1)", b64url.EncodeToString(pub3)); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
hdr := func(id int64, key ed25519.PrivateKey, ts int64) http.Header {
h := http.Header{}
h.Set("X-Node-Id", strconv.FormatInt(id, 10))
h.Set("X-Node-Ts", strconv.FormatInt(ts, 10))
h.Set("X-Node-Sig", b64url.EncodeToString(ed25519.Sign(key, preauthMsg(id, ts))))
return h
}
// dial — код ответа на апгрейд (101 — WebSocket принят) и первый кадр.
dial := func(h http.Header) (int, *websocket.Conn) {
ws, resp, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h})
if err != nil {
if resp == nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 404 && string(body) != "404 page not found\n" {
t.Errorf("тело 404 не как у пустого сервера: %q", body)
}
return resp.StatusCode, nil
}
return 101, ws
}
read := func(ws *websocket.Conn) jsjson.Object {
_, raw, err := ws.Read(ctx)
if err != nil {
return nil
}
v, _ := jsjson.Decode(raw)
o, _ := v.(jsjson.Object)
return o
}
auth := func(ws *websocket.Conn, id int64, key ed25519.PrivateKey) jsjson.Object {
nonce := read(ws).Get2("nonce").(string)
ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: id},
{Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(key, authMsg(nonce, id)))}, {Key: "version", Val: MinNodeVersion}}))
return read(ws)
}
now := time.Now().Unix()
if code, _ := dial(hdr(3, stranger, now)); code != 404 {
t.Errorf("чужая подпись: %d, ждали 404", code)
}
if code, _ := dial(hdr(99, stranger, now)); code != 404 {
t.Errorf("несуществующая нода: %d, ждали 404", code)
}
if code, _ := dial(hdr(3, priv3, now-2*preauthWindow)); code != 404 {
t.Errorf("подпись вне окна: %d, ждали 404", code)
}
// Переходный режим: нода 3 ещё не подключалась с подписью — старый агент проходит.
code, ws := dial(nil)
if code != 101 {
t.Fatalf("старый агент в переходном режиме: %d", code)
}
if r := auth(ws, 3, priv3); r.Get2("t") != "ready" {
t.Fatalf("старый агент: %v", r)
}
ws.CloseNow()
// Новый агент: подпись в заголовках, затем прежний auth внутри.
code, ws = dial(hdr(3, priv3, now))
if code != 101 {
t.Fatalf("новый агент: %d", code)
}
if r := auth(ws, 3, priv3); r.Get2("t") != "ready" {
t.Fatalf("новый агент: %v", r)
}
ws.CloseNow()
// Все ноды подключались с подписью — без неё больше не пускают.
if code, _ := dial(nil); code != 404 {
t.Errorf("без подписи после перехода: %d, ждали 404", code)
}
resp, err := http.Get(srv.URL + "/node/v1")
if err != nil || resp.StatusCode != 404 {
t.Errorf("обычный GET: %v %v", resp, err)
}
t.Setenv("HUB_LEGACY", "on")
if code, _ := dial(nil); code != 101 {
t.Errorf("HUB_LEGACY=on: %d", code)
}
t.Setenv("HUB_LEGACY", "")
// Подпись одной ноды, auth от имени другой — отказ.
pub5, priv5, _ := ed25519.GenerateKey(rand.Reader)
p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at, preauth) VALUES (5, 'Riga', ?, 1, 1)", b64url.EncodeToString(pub5))
code, ws = dial(hdr(3, priv3, now+1))
if code != 101 {
t.Fatalf("нода 3: %d", code)
}
if r := auth(ws, 5, priv5); r != nil {
t.Errorf("auth чужой ноды после подписи ноды 3 принят: %v", r)
}
ws.CloseNow()
}
// Настоящий агент привязывается к настоящему хабу по строке join и подключается снова уже
// своим ключом — в обоих случаях с подписью до апгрейда.
func TestHubAgentEndToEnd(t *testing.T) {
t.Setenv("HUB_LEGACY", "off")
p := newMovePanel(t, "on", "")
secretRaw := make([]byte, 16)
rand.Read(secretRaw)
secret := b64url.EncodeToString(secretRaw)
h := sha256.Sum256(secretRaw)
if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (7, 'Новая', ?, ?, 1)",
h[:], time.Now().Unix()+600); err != nil {
t.Fatal(err)
}
srv := httptest.NewTLSServer(http.HandlerFunc(p.a.nodes.serveWS))
defer srv.Close()
u, _ := url.Parse(srv.URL)
port, _ := strconv.Atoi(u.Port())
pin := certPin(t, srv.Certificate().Raw)
dir := t.TempDir()
_, ik, _ := ed25519.GenerateKey(rand.Reader)
der, _ := x509.MarshalPKCS8PrivateKey(ik)
os.WriteFile(filepath.Join(dir, "internal.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600)
env := filepath.Join(dir, "cli.env")
os.WriteFile(env, []byte("JOIN_SECRET="+secret+"\nNODE_ID=7\n"), 0o600)
run := func() {
a, err := agent.New(agent.Config{
CoreHost: u.Hostname(), CorePort: port, CorePin: pin, NodeID: 7, Ctrl: "http://127.0.0.1:1",
NodeKeyFile: filepath.Join(dir, "node.key"), InternalKey: filepath.Join(dir, "internal.key"),
CLIEnvFile: env, JoinSecret: envValue(env, "JOIN_SECRET"), Version: MinNodeVersion,
Logf: t.Logf, Errorf: t.Logf,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.Run(ctx)
for range 100 {
if p.a.nodes.IsOnline(7) {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("агент не подключился")
}
run() // привязка по секрету
row := p.a.nodes.row(7)
if !row.Pub.Valid {
t.Fatal("ключ ноды не сохранён после привязки")
}
var pre int
p.a.db.QueryRow("SELECT preauth FROM nodes WHERE id = 7").Scan(&pre)
if pre != 1 {
t.Error("подключение с подписью не отмечено (preauth)")
}
for range 100 { // прежнее соединение снимается при отмене контекста
if !p.a.nodes.IsOnline(7) {
break
}
time.Sleep(20 * time.Millisecond)
}
if strings.Contains(envValue(env, "JOIN_SECRET"), secret) {
t.Error("секрет привязки не стёрт после первого подключения")
}
run() // повторное подключение — уже своим ключом
}
func certPin(t *testing.T, raw []byte) string {
cert, err := x509.ParseCertificate(raw)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return b64url.EncodeToString(sum[:])
}
func envValue(file, key string) string {
raw, _ := os.ReadFile(file)
for _, l := range strings.Split(string(raw), "\n") {
if v, ok := strings.CutPrefix(l, key+"="); ok {
return v
}
}
return ""
}