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:
@@ -103,9 +103,24 @@ iptables, заголовки ядра) остаются. Ноду после у
|
|||||||
(`SUB_TLS=on`) и хаба нод создаются со случайным именем вместо общих `sen` / `awg-node`:
|
(`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 на ноде («Подключить Prometheus» у панели без своего VPN) от секретного пути не
|
||||||
|
зависит: он забирает метрики у агента на `127.0.0.1`, а тот — у панели внутри TLS-соединения
|
||||||
|
с хабом. Снаружи этот трафик не отличить от обычной связи ноды с панелью.
|
||||||
|
|
||||||
|
Для стороннего сервера мониторинга — вкладка «Монитор» → «Внешний Prometheus»: выдать токен «только метрики» и скопировать готовый
|
||||||
фрагмент `prometheus.yml`. Адрес в нём берётся от страницы панели, поэтому уже содержит
|
фрагмент `prometheus.yml`. Адрес в нём берётся от страницы панели, поэтому уже содержит
|
||||||
секретный путь (`metrics_path: /<путь>/metrics`) или подпапку nginx. Без пути `/metrics`
|
секретный путь (`metrics_path: /<путь>/metrics`) или подпапку nginx. Без пути `/metrics`
|
||||||
снаружи отвечает 404. Панель по голому HTTP передаёт токен и метрики открытым текстом —
|
снаружи отвечает 404. Панель по голому HTTP передаёт токен и метрики открытым текстом —
|
||||||
|
|||||||
+28
-1
@@ -198,6 +198,26 @@ func authMsg(nonce string, nodeID int) []byte {
|
|||||||
return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID))
|
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 ────────────────────────────────────────────────
|
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
|
||||||
|
|
||||||
var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true}
|
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}
|
tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout}
|
||||||
dctx, dcancel := context.WithTimeout(ctx, 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},
|
HTTPClient: &http.Client{Transport: tr},
|
||||||
|
HTTPHeader: a.preauthHeader(),
|
||||||
CompressionMode: websocket.CompressionDisabled,
|
CompressionMode: websocket.CompressionDisabled,
|
||||||
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
|
OnPingReceived: func(context.Context, []byte) bool { armDead(); return true },
|
||||||
})
|
})
|
||||||
dcancel()
|
dcancel()
|
||||||
if err != nil {
|
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) {
|
if errors.Is(err, errPin) {
|
||||||
a.c.Errorf("agent: %v", errPin)
|
a.c.Errorf("agent: %v", errPin)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ func New() *CLI {
|
|||||||
{"SUB_HOST", env("SUB_HOST", "")},
|
{"SUB_HOST", env("SUB_HOST", "")},
|
||||||
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
|
// Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN.
|
||||||
{"NODE_PORT", env("NODE_PORT", "")},
|
{"NODE_PORT", env("NODE_PORT", "")},
|
||||||
|
// Хаб: пускать ли агентов без подписи до апгрейда (on/off; пусто — пока не обновлены все).
|
||||||
|
{"HUB_LEGACY", env("HUB_LEGACY", "")},
|
||||||
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
|
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
|
||||||
{"RELAY_FROM", env("RELAY_FROM", "")},
|
{"RELAY_FROM", env("RELAY_FROM", "")},
|
||||||
// Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP.
|
// Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -15,7 +15,6 @@ import (
|
|||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -216,6 +215,13 @@ func newNodes(a *App) (*Nodes, error) {
|
|||||||
return nil, err
|
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: вместе их не бывает.
|
// Нода №0 и локальный сервер — один и тот же id: вместе их не бывает.
|
||||||
if n.local && n.row(0) != nil {
|
if n.local && n.row(0) != nil {
|
||||||
return nil, fmt.Errorf("nodes: в ui.db есть нода #0 (сервер, с которого переехала панель), а LOCAL_NODE=on — " +
|
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) {
|
func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
||||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
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})
|
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true, CompressionMode: websocket.CompressionDisabled})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ws.SetReadLimit(hubMaxFrame)
|
ws.SetReadLimit(hubMaxFrame)
|
||||||
if n.blocked(ip) {
|
|
||||||
ws.Close(4029, "rate_limited")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nb := make([]byte, 16)
|
nb := make([]byte, 16)
|
||||||
rand.Read(nb)
|
rand.Read(nb)
|
||||||
nonce := b64url.EncodeToString(nb)
|
nonce := b64url.EncodeToString(nb)
|
||||||
@@ -506,21 +524,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
row := n.row(int64(m.Get2("node_id").(float64)))
|
row := n.row(int64(m.Get2("node_id").(float64)))
|
||||||
if row == nil {
|
if row == nil || pre != nil && pre.ID != row.ID {
|
||||||
deny()
|
deny()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pubB64 := row.Pub.String
|
pubB64 := row.Pub.String
|
||||||
joining := !row.Pub.Valid
|
joining := !row.Pub.Valid
|
||||||
if joining {
|
if joining {
|
||||||
secret, sOK := field(m, "join", "secret").(string)
|
secret, _ := field(m, "join", "secret").(string)
|
||||||
pub, pOK := field(m, "join", "pub").(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 {
|
if !pOK || !joinOK(row, secret) {
|
||||||
deny()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h := sha256.Sum256(fromB64url(secret))
|
|
||||||
if len(row.JoinHash) != len(h) || subtle.ConstantTimeCompare(h[:], row.JoinHash) != 1 {
|
|
||||||
deny()
|
deny()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -541,6 +554,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
version = v
|
version = v
|
||||||
}
|
}
|
||||||
n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID)
|
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{}}
|
conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}}
|
||||||
nodeID = row.ID
|
nodeID = row.ID
|
||||||
@@ -561,6 +577,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
if joining {
|
if joining {
|
||||||
first = " (первое подключение)"
|
first = " (первое подключение)"
|
||||||
}
|
}
|
||||||
|
if pre == nil {
|
||||||
|
first += " — без подписи до апгрейда: старый агент, обновите ноду, чтобы хаб скрылся от сканеров"
|
||||||
|
}
|
||||||
logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first)
|
logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first)
|
||||||
go n.refreshHealth(row.ID)
|
go n.refreshHealth(row.ID)
|
||||||
go n.fireOnline(int(row.ID))
|
go n.fireOnline(int(row.ID))
|
||||||
|
|||||||
Reference in New Issue
Block a user