diff --git a/README.md b/README.md index 2de519a..44b99a0 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,24 @@ iptables, заголовки ядра) остаются. Ноду после у (`SUB_TLS=on`) и хаба нод создаются со случайным именем вместо общих `sen` / `awg-node`: клиенты и ноды сверяют ключ, а не имя, так что прежние сертификаты продолжают работать. +### Порт хаба нод + +Агент ноды подписывает своим ключом уже сам запрос на WebSocket (заголовки `X-Node-Id`, +`X-Node-Ts`, `X-Node-Sig`, при первой привязке — ещё секрет из строки `awgjoin://`). Хаб +проверяет подпись до апгрейда и без неё отвечает `404 page not found`, как пустой сервер: +одним подключением к `/node/v1` его больше не узнать. Агенты прежних версий подписи не шлют — +хаб пускает их, пока в панели есть нода, ни разу не подключавшаяся по-новому, и пишет об этом +в лог. Обновили все ноды — путь без подписи закрывается сам. `HUB_LEGACY=on` / `off` в +`cli.env` (и `awg-ctrl restart`) — принудительно. Часы ноды могут расходиться с панелью до +суток. + ### Внешний Prometheus -Вкладка «Монитор» → «Внешний Prometheus»: выдать токен «только метрики» и скопировать готовый +Prometheus на ноде («Подключить Prometheus» у панели без своего VPN) от секретного пути не +зависит: он забирает метрики у агента на `127.0.0.1`, а тот — у панели внутри TLS-соединения +с хабом. Снаружи этот трафик не отличить от обычной связи ноды с панелью. + +Для стороннего сервера мониторинга — вкладка «Монитор» → «Внешний Prometheus»: выдать токен «только метрики» и скопировать готовый фрагмент `prometheus.yml`. Адрес в нём берётся от страницы панели, поэтому уже содержит секретный путь (`metrics_path: /<путь>/metrics`) или подпапку nginx. Без пути `/metrics` снаружи отвечает 404. Панель по голому HTTP передаёт токен и метрики открытым текстом — diff --git a/internal/agent/agent.go b/internal/agent/agent.go index afc679e..541f28c 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -198,6 +198,26 @@ func authMsg(nonce string, nodeID int) []byte { return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID)) } +// preauthMsg — подпись в заголовке запроса на WebSocket: хаб проверяет её до апгрейда и без неё +// отвечает 404, как пустой сервер (internal/ui/hub_preauth.go, там же копия). +func preauthMsg(nodeID int, ts int64) []byte { + return []byte("awg-node-pre-v1\n" + strconv.Itoa(nodeID) + "\n" + strconv.FormatInt(ts, 10)) +} + +// preauthHeader — подпись ноды (и при первой привязке — секрет с ключом) для запроса на WebSocket. +func (a *Agent) preauthHeader() http.Header { + ts := time.Now().Unix() + h := http.Header{} + h.Set("X-Node-Id", strconv.Itoa(a.c.NodeID)) + h.Set("X-Node-Ts", strconv.FormatInt(ts, 10)) + h.Set("X-Node-Sig", b64.EncodeToString(ed25519.Sign(a.nodeKey, preauthMsg(a.c.NodeID, ts)))) + if s := a.secret(); s != "" { + h.Set("X-Node-Join", s) + h.Set("X-Node-Pub", a.nodePubRaw) + } + return h +} + // ── Запросы core → awg-ctrl ──────────────────────────────────────────────── var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true} @@ -476,13 +496,20 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout} dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout) - ws, _, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{ + ws, resp, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{ HTTPClient: &http.Client{Transport: tr}, + HTTPHeader: a.preauthHeader(), CompressionMode: websocket.CompressionDisabled, OnPingReceived: func(context.Context, []byte) bool { armDead(); return true }, }) dcancel() if err != nil { + // 404 до апгрейда — хаб не принял подпись (или IP временно заблокирован за неудачи): + // как отказ в auth, переподключение — не чаще раза в минуту. + if resp != nil && resp.StatusCode == http.StatusNotFound { + a.c.Errorf("agent: core отклонил ноду до подключения (неверный ключ, просроченная или использованная ссылка, часы ноды сильно расходятся)") + return true + } if errors.Is(err, errPin) { a.c.Errorf("agent: %v", errPin) } else { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4349633..1c70246 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -185,6 +185,8 @@ func New() *CLI { {"SUB_HOST", env("SUB_HOST", "")}, // Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN. {"NODE_PORT", env("NODE_PORT", "")}, + // Хаб: пускать ли агентов без подписи до апгрейда (on/off; пусто — пока не обновлены все). + {"HUB_LEGACY", env("HUB_LEGACY", "")}, // IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY. {"RELAY_FROM", env("RELAY_FROM", "")}, // Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP. diff --git a/internal/ui/hub_preauth.go b/internal/ui/hub_preauth.go new file mode 100644 index 0000000..ce3cf4c --- /dev/null +++ b/internal/ui/hub_preauth.go @@ -0,0 +1,89 @@ +package ui + +// Подпись агента ДО апгрейда на WebSocket (stealth хаба нод). Раньше хаб принимал WebSocket у +// любого и сразу слал hello с nonce: путь /node/v1 есть в открытом коде, и сканер, знающий +// протокол, узнавал хаб одним подключением. Теперь агент кладёт подпись своим ключом в заголовки +// запроса, хаб проверяет её до Accept и без неё отвечает 404 пустого Go-сервера. +// +// Переходный режим: агенты до этой версии заголовков не шлют. Их пускают по-старому, пока в +// панели есть нода, ни разу не подключавшаяся с подписью (nodes.preauth = 0): обновили все — +// путь без подписи закрывается сам. HUB_LEGACY=on / off — принудительно. +// +// Внутри WebSocket протокол прежний (hello → auth → ready): кадр auth обязан быть от той же ноды. + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/subtle" + "net/http" + "os" + "strconv" + "strings" +) + +// Часы ноды могут расходиться с панелью: окно широкое. Повтор подписи не страшен — заголовки +// идут внутри TLS с закреплённым сертификатом, подсмотреть их нельзя. +const preauthWindow = 24 * 3600 + +// preauthMsg — что агент подписывает в заголовке X-Node-Sig. Дублируется в internal/agent. +func preauthMsg(nodeID, ts int64) []byte { + return []byte("awg-node-pre-v1\n" + strconv.FormatInt(nodeID, 10) + "\n" + strconv.FormatInt(ts, 10)) +} + +// joinOK — одноразовый секрет первой привязки ноды верен и не просрочен. +func joinOK(row *nodeRow, secret string) bool { + if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || secret == "" { + return false + } + h := sha256.Sum256(fromB64url(secret)) + return len(row.JoinHash) == len(h) && subtle.ConstantTimeCompare(h[:], row.JoinHash) == 1 +} + +// preauth — подпись из заголовков. present — агент её прислал (новая версия); row — нода, если +// подпись верна. +func (n *Nodes) preauth(r *http.Request) (row *nodeRow, present bool) { + idS, tsS, sig := r.Header.Get("X-Node-Id"), r.Header.Get("X-Node-Ts"), r.Header.Get("X-Node-Sig") + if idS == "" && tsS == "" && sig == "" { + return nil, false + } + id, err1 := strconv.ParseInt(idS, 10, 64) + ts, err2 := strconv.ParseInt(tsS, 10, 64) + if err1 != nil || err2 != nil || sig == "" { + return nil, true + } + if d := ts - now(); d > preauthWindow || d < -preauthWindow { + warnf("nodes: подпись ноды #%d вне окна: часы расходятся на %d с", id, d) + return nil, true + } + row = n.row(id) + if row == nil { + return nil, true + } + pub := row.Pub.String + if !row.Pub.Valid { + // Первое подключение: ключа ноды ещё нет, его приносит агент вместе с секретом привязки. + if !joinOK(row, r.Header.Get("X-Node-Join")) { + return nil, true + } + pub = r.Header.Get("X-Node-Pub") + } + key := fromB64url(pub) + if len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, preauthMsg(id, ts), fromB64url(sig)) { + return nil, true + } + return row, true +} + +// legacyOpen — пускать ли подключение без подписи (агент старой версии). +func (n *Nodes) legacyOpen() bool { + switch strings.ToLower(os.Getenv("HUB_LEGACY")) { + case "on": + return true + case "off": + return false + } + var waiting int + n.a.db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE preauth = 0 + AND (pub IS NOT NULL OR (join_hash IS NOT NULL AND join_expires >= ?))`, now()).Scan(&waiting) + return waiting > 0 +} diff --git a/internal/ui/hub_preauth_test.go b/internal/ui/hub_preauth_test.go new file mode 100644 index 0000000..39bb39e --- /dev/null +++ b/internal/ui/hub_preauth_test.go @@ -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)))}})) + 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: "Beta 0.4.7", + 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 "" +} diff --git a/internal/ui/nodes.go b/internal/ui/nodes.go index 3d2a92b..1c17f81 100644 --- a/internal/ui/nodes.go +++ b/internal/ui/nodes.go @@ -15,7 +15,6 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" - "crypto/subtle" "crypto/tls" "database/sql" "fmt" @@ -216,6 +215,13 @@ func newNodes(a *App) (*Nodes, error) { return nil, err } } + // preauth — нода хоть раз подключилась с подписью в заголовках (hub_preauth.go): пока такие + // есть не все, хаб пускает и старых агентов без подписи. + if !hasColumn(a.db, "nodes", "preauth") { + if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN preauth INTEGER NOT NULL DEFAULT 0"); 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 — " + @@ -480,15 +486,27 @@ func closeWS(ws *websocket.Conn, code int, reason string) { func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { ip, _, _ := net.SplitHostPort(r.RemoteAddr) + // До апгрейда: без верной подписи ноды (или старого агента в переходном режиме) — 404, как + // у пустого сервера; WebSocket и hello видит только своя нода (hub_preauth.go). + if n.blocked(ip) { + http.NotFound(w, r) + return + } + pre, present := n.preauth(r) + if present && pre == nil { + n.failed(ip) + http.NotFound(w, r) + return + } + if !present && !n.legacyOpen() { + http.NotFound(w, r) + return + } ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true, CompressionMode: websocket.CompressionDisabled}) if err != nil { return } ws.SetReadLimit(hubMaxFrame) - if n.blocked(ip) { - ws.Close(4029, "rate_limited") - return - } nb := make([]byte, 16) rand.Read(nb) nonce := b64url.EncodeToString(nb) @@ -506,21 +524,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { return } row := n.row(int64(m.Get2("node_id").(float64))) - if row == nil { + if row == nil || pre != nil && pre.ID != row.ID { deny() return } pubB64 := row.Pub.String joining := !row.Pub.Valid if joining { - secret, sOK := field(m, "join", "secret").(string) + secret, _ := field(m, "join", "secret").(string) pub, pOK := field(m, "join", "pub").(string) - if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || !sOK || !pOK { - deny() - return - } - h := sha256.Sum256(fromB64url(secret)) - if len(row.JoinHash) != len(h) || subtle.ConstantTimeCompare(h[:], row.JoinHash) != 1 { + if !pOK || !joinOK(row, secret) { deny() return } @@ -541,6 +554,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { version = v } n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID) + if pre != nil { + n.a.db.Exec("UPDATE nodes SET preauth = 1 WHERE id = ?", row.ID) + } conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}} nodeID = row.ID @@ -561,6 +577,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { if joining { first = " (первое подключение)" } + if pre == nil { + first += " — без подписи до апгрейда: старый агент, обновите ноду, чтобы хаб скрылся от сканеров" + } logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first) go n.refreshHealth(row.ID) go n.fireOnline(int(row.ID))