Hub: node signature before the WebSocket upgrade

The hub accepted a WebSocket from anyone on /node/v1, a path in the open
code, and replied with a hello nonce: one connection was enough to tell a
scanner what it had found. The agent now signs the upgrade request itself
(X-Node-Id, X-Node-Ts, X-Node-Sig; on first binding also the join secret
and its key), and the hub checks it before Accept. Without a valid
signature it answers Go's plain "404 page not found". Inside the socket
the protocol is unchanged, and the auth frame must come from the node that
signed the request.

Older agents send no signature. The hub lets them in while some node has
never connected the new way (nodes.preauth), logs them as outdated, and
closes that door by itself once every node is updated; HUB_LEGACY=on/off
overrides. The timestamp window is a day: node clocks drift, and the
headers travel inside pinned TLS. The agent treats a 404 at upgrade as a
rejection and backs off for a minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
This commit is contained in:
Claude
2026-10-28 11:58:06 +00:00
parent a3d4664214
commit fd5e2cc761
6 changed files with 391 additions and 15 deletions
+28 -1
View File
@@ -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 {
+2
View File
@@ -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.
+89
View File
@@ -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
}
+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)))}}))
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 ""
}
+32 -13
View File
@@ -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))