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:
+32
-13
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user