Stealth sen:// port, random cert names, external Prometheus card

The subscription port answered every request with signed JSON: one probe
told a scanner what it had found, and the public key from any sen:// link
pinned the exact server. It now speaks the protocol only to those who prove
a secret — peek and register with the link's master secret, everything else
with a valid device signature — and gives Go's plain "404 page not found"
to the rest. A deleted device still gets its 410, but only when the request
is signed with its key, kept in the new revoked_devices table. The migration
freeze answers only proven clients too.

Self-signed certs for the subscription (SUB_TLS=on) and the node hub got
fixed names (sen, awg-node); new ones get a random host-like name. Clients
and nodes pin the key, so existing certs keep working.

The panel could already issue a metrics-only token, but nothing in the UI
used it. The Monitor tab gets an "External Prometheus" card: issue, reissue
and revoke the token, with a ready prometheus.yml fragment whose address is
taken from the page, so it carries the secret path or the nginx subfolder.

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:52:55 +00:00
parent 619af21085
commit a3d4664214
8 changed files with 331 additions and 53 deletions
+105
View File
@@ -0,0 +1,105 @@
package ui
import (
"crypto/ed25519"
"crypto/rand"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// Публичный листенер подписки: ответ протокола (подписанный JSON) — только тому, кто знает
// секрет ссылки или ключ устройства. Остальным — 404 пустого Go-сервера.
func TestSubStealth(t *testing.T) {
a := testApp(t, t.TempDir(), "on")
_, signKey, _ := ed25519.GenerateKey(rand.Reader)
s := &Sub{a: a, signKey: signKey, seenSigs: map[string]time.Time{}}
s.registerLimit = newLimiter(30, time.Minute, func(string) {})
s.failLimit = newLimiter(30, time.Minute, func(string) {})
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
devPub, devKey, _ := ed25519.GenerateKey(rand.Reader)
oldPub, oldKey, _ := ed25519.GenerateKey(rand.Reader)
_, strangerKey, _ := ed25519.GenerateKey(rand.Reader)
secret := make([]byte, 16)
rand.Read(secret)
for _, q := range []struct {
sql string
args []any
}{
{"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k', ?, 1)", []any{secret}},
{"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', ?, 1)", []any{b64url.EncodeToString(devPub)}},
{"INSERT INTO revoked_devices (id, auth_pub, revoked_at) VALUES (6, ?, 1)", []any{b64url.EncodeToString(oldPub)}},
} {
if _, err := a.db.Exec(q.sql, q.args...); err != nil {
t.Fatalf("%s: %v", q.sql, err)
}
}
rt := s.publicRouter()
do := func(method, path, body string, hdr map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.RemoteAddr = "203.0.113.5:4000"
if body != "" {
r.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
rt.ServeHTTP(w, r)
return w
}
signed := func(key ed25519.PrivateKey, dev int, method, path string, ts int64) map[string]string {
sig := ed25519.Sign(key, []byte(requestSigString(method, path, ts, nil)))
return map[string]string{"X-Sen-Device": strconv.Itoa(dev), "X-Sen-Ts": strconv.FormatInt(ts, 10),
"X-Sen-Sig": b64url.EncodeToString(sig)}
}
stealthy := func(name string, w *httptest.ResponseRecorder) {
t.Helper()
if w.Code != 404 || w.Body.String() != "404 page not found\n" {
t.Errorf("%s: ждали тихий 404, получили %d %q", name, w.Code, w.Body.String())
}
}
protocol := func(name string, w *httptest.ResponseRecorder, code int) {
t.Helper()
if w.Code != code || !strings.Contains(w.Body.String(), `"sig"`) {
t.Errorf("%s: ждали подписанный %d, получили %d %q", name, code, w.Code, w.Body.String())
}
}
sec := `{"sub":"` + b64url.EncodeToString(secret) + `"}`
other := make([]byte, 16)
rand.Read(other)
now := time.Now().Unix()
stealthy("GET /", do("GET", "/", "", nil))
stealthy("чужой путь", do("GET", "/sub/v1/nope", "", nil))
stealthy("чужой метод", do("GET", "/sub/v1/peek", "", nil))
stealthy("битый JSON", do("POST", "/sub/v1/peek", "{", nil))
stealthy("peek без секрета", do("POST", "/sub/v1/peek", `{"sub":"x"}`, nil))
stealthy("peek чужой секрет", do("POST", "/sub/v1/peek", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil))
stealthy("register чужой секрет", do("POST", "/sub/v1/register", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil))
stealthy("config без заголовков", do("GET", "/sub/v1/config", "", nil))
stealthy("config чужой подписью", do("GET", "/sub/v1/config", "", signed(strangerKey, 5, "GET", "/sub/v1/config", now)))
stealthy("config подобранный id", do("GET", "/sub/v1/config", "", signed(strangerKey, 999, "GET", "/sub/v1/config", now)))
stealthy("config удалённого чужой подписью", do("GET", "/sub/v1/config", "", signed(strangerKey, 6, "GET", "/sub/v1/config", now)))
protocol("peek своим секретом", do("POST", "/sub/v1/peek", sec, nil), 200)
protocol("register своим секретом, без полей", do("POST", "/sub/v1/register", sec, nil), 400)
protocol("удалённое устройство своей подписью", do("GET", "/sub/v1/config", "", signed(oldKey, 6, "GET", "/sub/v1/config", now)), 410)
protocol("часы устройства уехали", do("GET", "/sub/v1/config", "", signed(devKey, 5, "GET", "/sub/v1/config", now-3600)), 401)
s.frozen.Store(true)
stealthy("перенос: чужой", do("POST", "/sub/v1/peek", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil))
protocol("перенос: свой", do("POST", "/sub/v1/peek", sec, nil), 502)
protocol("перенос: устройство", do("GET", "/sub/v1/devices", "", signed(devKey, 5, "GET", "/sub/v1/devices", now)), 502)
s.frozen.Store(false)
// Удаление устройства оставляет его ключ в revoked_devices: дальше — 410 только ему.
s.forgetDeviceTx(s.deviceByID(a.db, 5))
protocol("после удаления — своей подписью", do("GET", "/sub/v1/config", "", signed(devKey, 5, "GET", "/sub/v1/config", now+1)), 410)
stealthy("после удаления — чужой", do("GET", "/sub/v1/config", "", signed(strangerKey, 5, "GET", "/sub/v1/config", now)))
}