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
+54 -18
View File
@@ -15,7 +15,6 @@ import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"database/sql"
"fmt"
@@ -153,17 +152,15 @@ func newNodes(a *App) (*Nodes, error) {
}
return filepath.Join(a.baseDir, name)
}
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же,
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Имя в нём случайное:
// одинаковое у всех серверов было бы сигнатурой для сканера (pin — по ключу, не по имени). Живёт столько же,
// сколько core: пересоздать = переподключить все ноды.
if n.port != 0 {
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
if err := func() error {
if !exists(keyF) && !exists(crtF) {
// CN случайный (12 hex, как имя хоста контейнера): постоянный CN — готовый отпечаток для DPI.
cn := make([]byte, 6)
rand.Read(cn)
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", fmt.Sprintf("/CN=%x", cn))
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN="+randomCN())
if err := cmd.Run(); err != nil {
return err
}
@@ -218,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 — " +
@@ -484,6 +488,13 @@ const closeOutdated = 4026
// cmpVersion сравнивает версии вида 0.5.0 (хвост после «-» не учитывается): <0 — a старше b.
func cmpVersion(a, b string) int {
// «Beta 0.5.0» → «0.5.0»: канал перед номером не сравниваем.
if f := strings.Fields(a); len(f) > 0 {
a = f[len(f)-1]
}
if f := strings.Fields(b); len(f) > 0 {
b = f[len(f)-1]
}
pa, pb := strings.Split(strings.SplitN(a, "-", 2)[0], "."), strings.Split(strings.SplitN(b, "-", 2)[0], ".")
for i := 0; i < max(len(pa), len(pb)); i++ {
var x, y int
@@ -506,15 +517,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)
@@ -532,21 +555,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
}
@@ -574,6 +592,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) {
closeWS(ws, closeOutdated, "outdated "+MinNodeVersion)
return
}
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
@@ -594,6 +615,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))
@@ -963,3 +987,15 @@ func (n *Nodes) router() *Router {
})
return rt
}
// randomCN — имя для самоподписанного сертификата: похожее на обычный хост и своё у каждого
// сервера.
func randomCN() string {
const abc = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 10)
rand.Read(b)
for i := range b {
b[i] = abc[int(b[i])%len(abc)]
}
return string(b) + ".net"
}