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
+78
View File
@@ -0,0 +1,78 @@
package ui
// Журнал VPN-подключений (вкладка «Подключения»): события ведёт awg-ctrl каждого сервера
// (internal/ctrl/connlog.go), панель забирает их у выбранного сервера и подставляет имена, которых
// awg-ctrl не знает: пир sen:// там — только владелец «<панель>/m<ключ>/d<устройство>».
import (
"net/url"
"regexp"
"strconv"
"github.com/maeneko/forgetting/internal/jsjson"
)
var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`)
// ownerNames — название мастер-ключа и имя устройства для владельца пира. foreign — пир другой
// панели на том же awg-ctrl (у ноды может быть своя): её имён мы не знаем.
func (s *Sub) ownerNames(owner string, cache map[string][3]any) (key, device any, foreign bool) {
if v, ok := cache[owner]; ok {
return v[0], v[1], v[2].(bool)
}
m := reOwner.FindStringSubmatch(owner)
switch {
case m == nil, m[1] != "" && m[1] != s.panelID: // не наш формат или чужая панель
foreign = true
default:
mid, _ := strconv.ParseInt(m[2], 10, 64)
did, _ := strconv.ParseInt(m[3], 10, 64)
var label, name string
if s.a.db.QueryRow("SELECT label FROM master_keys WHERE id = ?", mid).Scan(&label) == nil {
key = label
}
if s.a.db.QueryRow("SELECT device_name FROM devices WHERE id = ?", did).Scan(&name) == nil {
device = name
}
}
cache[owner] = [3]any{key, device, foreign}
return key, device, foreign
}
func (a *App) connlog(c *Ctx) bool {
q := url.Values{}
for _, k := range []string{"limit", "before", "q"} {
if v := c.R.URL.Query().Get(k); v != "" {
q.Set(k, v)
}
}
r, err := a.nodes.CtrlFor(a.serverIDOf(c))("GET", "/api/connlog?"+q.Encode(), nil)
if err != nil {
return c.Error(502, "awg-ctrl недоступен")
}
if r.Status == 404 {
return c.Error(501, "awg-ctrl на этом сервере старой версии и журнала не ведёт — обновите сервер")
}
data, ok := r.Data.(jsjson.Object)
if r.Status != 200 || !ok {
return c.JSON(r.Status, r.Data)
}
cache := map[string][3]any{}
enrich := func(list any) {
items, _ := list.([]any)
for i, it := range items {
o, ok := it.(jsjson.Object)
if !ok {
continue
}
if owner, _ := o.Get2("owner").(string); owner != "" && a.sub != nil {
key, dev, foreign := a.sub.ownerNames(owner, cache)
o = append(o, jsjson.KV{Key: "key", Val: key}, jsjson.KV{Key: "device", Val: dev}, jsjson.KV{Key: "foreign", Val: foreign})
}
items[i] = o
}
}
enrich(data.Get2("events"))
enrich(data.Get2("peers"))
return c.JSON(200, data)
}
+30
View File
@@ -0,0 +1,30 @@
package ui
import "testing"
// Владелец пира sen:// в журнале → название мастер-ключа и устройства этой панели.
func TestConnLogOwnerNames(t *testing.T) {
a := testApp(t, t.TempDir(), "on")
s := &Sub{a: a}
if err := s.initSchema(); err != nil {
t.Fatal(err)
}
s.panelID = "abcd1234" // initSchema заводит свой
a.db.Exec("INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)")
a.db.Exec("INSERT INTO devices (id, master_id, device_id, device_name, pub_key, auth_pub, created_at) VALUES (2, 1, 'd', 'iPhone Маши', 'W', 'A', 1)")
cache := map[string][3]any{}
for owner, want := range map[string][3]any{
"abcd1234/m1/d2": {"Семья", "iPhone Маши", false},
"m1/d2": {"Семья", "iPhone Маши", false}, // выдан до областей панели
"abcd1234/m1/d9": {"Семья", nil, false}, // устройство удалено
"abcd1234/m7/d9": {nil, nil, false}, // и ключ удалён
"ffff0000/m1/d2": {nil, nil, true}, // другая панель на том же awg-ctrl
"что-то/другое": {nil, nil, true},
} {
k, d, f := s.ownerNames(owner, cache)
if k != want[0] || d != want[1] || f != want[2] {
t.Errorf("%s: %v %v %v, ждали %v", owner, k, d, f, want)
}
}
}
+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)))}, {Key: "version", Val: MinNodeVersion}}))
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: MinNodeVersion,
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 ""
}
+13 -1
View File
@@ -211,6 +211,18 @@ func (m *Migration) newCode(c *Ctx) bool {
return m.status(c)
}
// awaitsImport — запрос на приём архива, пока выдан код: старая панель шлёт его на
// /migrate/v1/import без секретного префикса (в коде переноса его нет), prefixGate пропускает.
// Без кода путь снаружи не виден, как и остальные.
func (m *Migration) awaitsImport(r *http.Request) bool {
if r.Method != "POST" || r.URL.Path != "/migrate/v1/import" {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
return m.key != nil && time.Now().Before(m.expires)
}
// importHandler — POST /migrate/v1/import, без JWT: доступ даёт только ключ кода (тег GCM).
// Ответ с ошибкой — открытым текстом и только пока ничего не тронуто.
func (m *Migration) importHandler(c *Ctx) bool {
@@ -420,7 +432,7 @@ func (m *Migration) exportMeta() jsjson.Object {
{Key: "version", Val: m.a.version}, {Key: "created_at", Val: now()},
{Key: "server_name", Val: name}, {Key: "server_ip", Val: ip},
{Key: "ui_port", Val: m.a.port}, {Key: "sub_port", Val: m.a.sub.port}, {Key: "sub_tls", Val: m.a.sub.useTLS},
{Key: "node_port", Val: m.a.nodes.port},
{Key: "node_port", Val: m.a.nodes.port}, {Key: "ui_prefix", Val: os.Getenv("UI_PREFIX")},
}
}
+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"
}
+1 -1
View File
@@ -79,7 +79,7 @@ func TestCmpVersion(t *testing.T) {
for _, c := range []struct {
a, b string
sign int
}{{"0.5.0", "0.5.0", 0}, {"0.4.9", "0.5.0", -1}, {"0.10.0", "0.9.9", 1}, {"0.5", "0.5.0", 0}, {"0.5.1-beta", "0.5.1", 0}} {
}{{"0.5.0", "0.5.0", 0}, {"0.4.9", "0.5.0", -1}, {"0.10.0", "0.9.9", 1}, {"0.5", "0.5.0", 0}, {"0.5.1-beta", "0.5.1", 0}, {"Beta 0.4.7", "0.5.0", -1}} {
got := cmpVersion(c.a, c.b)
if (got > 0) != (c.sign > 0) || (got < 0) != (c.sign < 0) {
t.Errorf("cmpVersion(%q, %q) = %d", c.a, c.b, got)
+81
View File
@@ -0,0 +1,81 @@
package ui
// Секретный префикс панели (UI_PREFIX): снаружи панель отвечает только на /<префикс>/…, на всё
// остальное — как пустой Go-сервер (`404 page not found`). Порт панели открыт в интернет, и без
// префикса сканер узнавал её по странице логина и JS-бандлу — одним GET.
//
// UI_PREFIX_MOVED — префикс панели, переехавшей сюда (`migrate`): её закладки и внешний API
// приходят через ретранслятор старого сервера со своим путём. Принимаются оба.
//
// С loopback префикс не нужен: CLI, Prometheus этой машины (127.0.0.1:PORT/metrics) и nginx
// на этой же машине ходят по-старому (nginx в подпапке и есть свой секретный путь). Адрес —
// соединения, не X-Forwarded-For: заголовок подделывается.
import (
"net"
"net/http"
"os"
"strings"
"github.com/maeneko/forgetting/internal/migrate"
)
// uiPrefixes — UI_PREFIX и UI_PREFIX_MOVED из окружения; пустые и кривые пропускаются.
func uiPrefixes() []string {
var out []string
for _, k := range []string{"UI_PREFIX", "UI_PREFIX_MOVED"} {
p := strings.Trim(strings.TrimSpace(os.Getenv(k)), "/")
switch {
case p == "":
case !migrate.ValidUIPrefix(p):
warnf("ui: %s «%s» не подходит (8–64 символа A–Z a–z 0–9 _ -) — пропущен", k, p)
default:
out = append(out, p)
}
}
return out
}
func isLoopback(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// prefixGate пропускает в панель только запросы под префиксом и срезает его: дальше роутер
// видит те же пути, что без префикса. allow — исключения вне префикса (приём переноса).
func prefixGate(prefixes []string, next http.Handler, allow func(*http.Request) bool) http.Handler {
if len(prefixes) == 0 {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
for _, px := range prefixes {
root := "/" + px
if p == root {
// Относительный адрес, как у static: за прокси в подпапке абсолютный увёл бы из неё.
w.Header().Set("Location", px+"/")
w.WriteHeader(http.StatusMovedPermanently)
return
}
if strings.HasPrefix(p, root+"/") {
r2 := r.Clone(r.Context())
r2.URL.Path = p[len(root):]
if r.URL.RawPath != "" {
r2.URL.RawPath = strings.TrimPrefix(r.URL.RawPath, root)
}
r2.RequestURI = r2.URL.RequestURI()
next.ServeHTTP(w, r2)
return
}
}
if isLoopback(r.RemoteAddr) || allow != nil && allow(r) {
next.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})
}
+57
View File
@@ -0,0 +1,57 @@
package ui
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestPrefixGate(t *testing.T) {
var got string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.URL.Path
w.WriteHeader(200)
})
allow := func(r *http.Request) bool { return r.Method == "POST" && r.URL.Path == "/migrate/v1/import" }
h := prefixGate([]string{"s3cretPath", "0ldPanel_x"}, next, allow)
cases := []struct {
method, remote, path string
code int
seen, location string
}{
{"GET", "203.0.113.5:4000", "/", 404, "", ""},
{"GET", "203.0.113.5:4000", "/ui/brand", 404, "", ""},
{"GET", "203.0.113.5:4000", "/s3cretPathX/", 404, "", ""},
{"GET", "203.0.113.5:4000", "/s3cretPath", 301, "", "s3cretPath/"},
{"GET", "203.0.113.5:4000", "/s3cretPath/", 200, "/", ""},
{"GET", "203.0.113.5:4000", "/s3cretPath/assets/a.js", 200, "/assets/a.js", ""},
{"GET", "203.0.113.5:4000", "/0ldPanel_x/api/v1/users", 200, "/api/v1/users", ""},
{"POST", "203.0.113.5:4000", "/migrate/v1/import", 200, "/migrate/v1/import", ""},
{"GET", "203.0.113.5:4000", "/migrate/v1/import", 404, "", ""},
{"GET", "127.0.0.1:4000", "/metrics", 200, "/metrics", ""},
{"GET", "[::1]:4000", "/", 200, "/", ""},
}
for _, c := range cases {
got = ""
r := httptest.NewRequest(c.method, c.path, nil)
r.RemoteAddr = c.remote
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != c.code || got != c.seen || w.Header().Get("Location") != c.location {
t.Errorf("%s %s от %s: %d, роутер видит %q, Location %q; ждали %d, %q, %q",
c.method, c.path, c.remote, w.Code, got, w.Header().Get("Location"), c.code, c.seen, c.location)
}
if c.code == 404 && w.Body.String() != "404 page not found\n" {
t.Errorf("%s: тело 404 не как у пустого Go-сервера: %q", c.path, w.Body.String())
}
}
got = ""
r := httptest.NewRequest("GET", "/ui/brand", nil)
r.RemoteAddr = "203.0.113.5:4000"
prefixGate(nil, next, nil).ServeHTTP(httptest.NewRecorder(), r)
if got != "/ui/brand" {
t.Errorf("без префикса запрос должен доходить до роутера как есть, дошёл %q", got)
}
}
+15 -11
View File
@@ -91,6 +91,7 @@ func decodeLikeAxios(raw []byte) any {
type App struct {
port int
prefixes []string // UI_PREFIX, UI_PREFIX_MOVED: секретные пути панели (prefix.go)
uiUser string
uiPass string
jwtSecret []byte
@@ -131,14 +132,15 @@ func New() (*App, error) {
return def
}
a := &App{
port: envNum("PORT"),
uiUser: envOr("UI_USER", "admin"),
uiPass: os.Getenv("UI_PASS"),
brand: pick("BRAND", "Forgetting"),
channel: pick("CHANNEL", "Beta"),
version: pick("VERSION", ""),
ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"),
baseDir: cwd,
port: envNum("PORT"),
prefixes: uiPrefixes(),
uiUser: envOr("UI_USER", "admin"),
uiPass: os.Getenv("UI_PASS"),
brand: pick("BRAND", "Forgetting"),
channel: pick("CHANNEL", "Beta"),
version: pick("VERSION", ""),
ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"),
baseDir: cwd,
// awg-ctrl на loopback: системный прокси сюда не нужен.
httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}},
revoked: map[string]bool{},
@@ -847,8 +849,9 @@ func (a *App) routes() *Router {
app.Post("/login", a.login)
app.Post("/logout", auth, a.logout)
// Единственный роут без авторизации: вордмарк нужен уже на экране логина.
app.Get("/ui/brand", func(c *Ctx) bool {
// За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting.
// Экран логина обходится без вордмарка.
app.Get("/ui/brand", auth, func(c *Ctx) bool {
return c.JSON(200, jsjson.Object{{Key: "brand", Val: a.brand}, {Key: "channel", Val: a.channel}, {Key: "version", Val: a.version}})
})
app.Get("/ui/apikeys", auth, a.apiKeysList)
@@ -876,6 +879,7 @@ func (a *App) routes() *Router {
app.Use("/ui/metrics-token", auth)
app.Mount("/ui/metrics-token", a.metrics.router())
app.Get("/metrics", a.metrics.requireToken, a.metrics.handler)
app.Get("/ui/connlog", auth, a.connlog)
app.Use("/ui/monitor", auth)
app.Mount("/ui/monitor", a.monitor.router())
// Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса.
@@ -949,7 +953,7 @@ func (a *App) Run() error {
if err != nil {
return err
}
srv := &http.Server{Handler: a.routes(), ReadHeaderTimeout: 30 * time.Second}
srv := &http.Server{Handler: prefixGate(a.prefixes, a.routes(), a.migration.awaitsImport), ReadHeaderTimeout: 30 * time.Second}
logf("ui: listening on :%d", a.port)
a.nodes.Start()
a.sub.Start()
+86 -50
View File
@@ -288,6 +288,13 @@ func (s *Sub) initSchema() error {
rekey_requested INTEGER NOT NULL DEFAULT 0,
UNIQUE (master_id, device_id)
);
-- Удалённые устройства: ключ авторизации, чтобы проверить подпись их опроса и ответить
-- 410 только им (stealth). Устройства, удалённые до таблицы, получают тихий 404.
CREATE TABLE IF NOT EXISTS revoked_devices (
id INTEGER PRIMARY KEY,
auth_pub TEXT NOT NULL,
revoked_at INTEGER NOT NULL
);
-- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и
-- дальше не читается (кроме переноса ниже).
CREATE TABLE IF NOT EXISTS master_servers (
@@ -749,6 +756,9 @@ func (s *Sub) forgetDevice(q queryer, dev *deviceRow) []int {
q.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", l.ServerID, l.PeerPub.String)
}
}
// Ключ авторизации удалённого устройства — чтобы на его подписанный опрос ответить 410
// (снять конфиг), а на подобранный id без подписи — тихим 404.
q.Exec("INSERT OR REPLACE INTO revoked_devices (id, auth_pub, revoked_at) VALUES (?, ?, ?)", dev.ID, dev.AuthPub, now())
q.Exec("DELETE FROM devices WHERE id = ?", dev.ID) // device_peers уйдут каскадом
ids := []int{}
for _, l := range links {
@@ -903,21 +913,23 @@ func reqPath(r *http.Request) string {
}
// signFailure — подпись запроса и защита от повторов. "" — всё верно, иначе причина (для лога).
func (s *Sub) signFailure(c *Ctx, authPub []byte) string {
// signed — подпись сама по себе верна (провал только по времени или повтору): запрос сделан
// ключом устройства, ему можно отвечать по протоколу. Без неё — тихий 404 (stealth).
func (s *Sub) signFailure(c *Ctx, authPub []byte) (why string, signed bool) {
tsF := jsToNumber(strings.Join(c.R.Header["X-Sen-Ts"], ", "))
if _, has := c.R.Header["X-Sen-Ts"]; !has {
tsF = jsToNumber("x")
}
sig := c.R.Header.Get("X-Sen-Sig")
if tsF != float64(int64(tsF)) || sig == "" {
return "no_signature"
return "no_signature", false
}
ts := int64(tsF)
if d := ts - now(); d > tsWindow || d < -tsWindow {
return fmt.Sprintf("ts_window (часы клиента расходятся на %d с)", d)
}
if !verifyRequest(c.R.Method, reqPath(c.R), ts, c.RawBody, sig, authPub) {
return "bad_signature"
return "bad_signature", false
}
if d := ts - now(); d > tsWindow || d < -tsWindow {
return fmt.Sprintf("ts_window (часы клиента расходятся на %d с)", d), true
}
s.sigMu.Lock()
defer s.sigMu.Unlock()
@@ -928,10 +940,27 @@ func (s *Sub) signFailure(c *Ctx, authPub []byte) string {
}
}
if _, ok := s.seenSigs[sig]; ok {
return "replay"
return "replay", true
}
s.seenSigs[sig] = t.Add(2 * tsWindow * time.Second)
return ""
return "", true
}
// stealth — ответ тому, кто не доказал знание ни секрета ссылки, ни ключа устройства: как у
// пустого Go-сервера, без подписи и JSON. Иначе сканер одним запросом узнавал бы подписку (а с
// публичным ключом из любой ссылки — и конкретный сервер). Клиенту с верной ссылкой это не мешает.
func stealth(c *Ctx) bool {
http.NotFound(c.W, c.R)
return false
}
// frozenFor — перенос панели идёт: доказавшему клиенту — подписанный 502 (он повторит позже).
func (s *Sub) frozenFor(c *Ctx) bool {
if s.frozen.Load() {
s.sendC(c, 502, subErr("unavailable"))
return true
}
return false
}
func (s *Sub) deviceAuth(c *Ctx) bool {
@@ -942,16 +971,31 @@ func (s *Sub) deviceAuth(c *Ctx) bool {
if valid {
dev = s.deviceByID(s.a.db, int64(f))
}
if !valid {
return stealth(c)
}
// Устройства нет — его удалили (id не переиспользуются). 410, чтобы клиент снял конфиг, и
// не в лимит неудач: такие клиенты опрашивают по таймеру и выбивали бы лимит соседям за NAT.
if dev == nil && valid {
// Только если запрос подписан ключом удалённого устройства (revoked_devices): иначе на
// подобранный id сканер получал бы ответ протокола.
if dev == nil {
var authPub string
if s.a.db.QueryRow("SELECT auth_pub FROM revoked_devices WHERE id = ?", int64(f)).Scan(&authPub) != nil {
return stealth(c)
}
if _, signed := s.signFailure(c, fromB64url(authPub)); !signed {
return stealth(c)
}
return s.sendC(c, 410, subErr("revoked"))
}
why := "no_device"
if dev != nil {
why = s.signFailure(c, fromB64url(dev.AuthPub))
why, signed := s.signFailure(c, fromB64url(dev.AuthPub))
if !signed {
return stealth(c)
}
if why == "" {
if s.frozenFor(c) {
return false
}
c.device = dev
return true
}
@@ -959,11 +1003,7 @@ func (s *Sub) deviceAuth(c *Ctx) bool {
if !s.failLimit.allow(ip) {
return s.sendC(c, 429, subErr("rate_limited"))
}
hdr := c.R.Header.Get("X-Sen-Device")
if !hasHdr {
hdr = "-"
}
warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), hdr, why, ip)
warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), c.R.Header.Get("X-Sen-Device"), why, ip)
return s.sendC(c, 401, subErr("unauthorized"))
}
@@ -986,10 +1026,6 @@ var errLimit = errors.New("limit")
func (s *Sub) register(c *Ctx) error {
ip := c.remoteIP()
if !s.registerLimit.allow(ip) {
s.sendC(c, 429, subErr("rate_limited"))
return nil
}
b := c.Obj()
strRe := func(k string, re *regexp.Regexp) (string, bool) {
v, ok := b.Get2(k).(string)
@@ -1010,7 +1046,23 @@ func (s *Sub) register(c *Ctx) error {
if !ok5 {
version = ""
}
if !ok1 || !ok2 || !ok3 || !ok4 {
// Доказательство — секрет ссылки: без верного секрета ответа протокола нет (stealth).
var master *masterRow
if ok1 {
master = s.masterBySecret(fromB64url(secretB))
}
if master == nil {
stealth(c)
return nil
}
if s.frozenFor(c) {
return nil
}
if !s.registerLimit.allow(ip) {
s.sendC(c, 429, subErr("rate_limited"))
return nil
}
if !ok2 || !ok3 || !ok4 {
s.sendC(c, 400, subErr("bad_request"))
return nil
}
@@ -1019,7 +1071,7 @@ func (s *Sub) register(c *Ctx) error {
authPub := fromB64url(authPubS)
why := "bad_auth_pub"
if len(authPub) == 32 {
why = s.signFailure(c, authPub)
why, _ = s.signFailure(c, authPub)
}
if why != "" {
warnf("sub: 401 register %s ip=%s", why, ip)
@@ -1027,11 +1079,6 @@ func (s *Sub) register(c *Ctx) error {
return nil
}
master := s.masterBySecret(fromB64url(secretB))
if master == nil {
s.sendC(c, 404, subErr("not_found"))
return nil
}
var serverIDs []int
for _, id := range s.serverIDsOf(s.a.db, master.ID) {
if s.a.nodes.Exists(id) {
@@ -1092,23 +1139,18 @@ func (s *Sub) register(c *Ctx) error {
// peek — сколько мест у ключа занято, по секрету из ссылки (устройства ещё нет).
func (s *Sub) peek(c *Ctx) error {
ip := c.remoteIP()
secretB, ok := c.Obj().Get2("sub").(string)
ok = ok && reSubSecret.MatchString(secretB)
var master *masterRow
if ok {
master = s.masterBySecret(fromB64url(secretB))
}
// Секрет не тот — ответа протокола нет (stealth): иначе сканер отличал бы подписку.
if master == nil {
if !s.failLimit.allow(ip) {
s.sendC(c, 429, subErr("rate_limited"))
return nil
}
if ok {
s.sendC(c, 404, subErr("not_found"))
} else {
s.sendC(c, 400, subErr("bad_request"))
}
stealth(c)
return nil
}
if s.frozenFor(c) {
return nil
}
s.sendC(c, 200, jsjson.Object{{Key: "key_uuid", Val: master.UUID}, {Key: "devices", Val: s.deviceCount(s.a.db, master.ID)},
@@ -1199,18 +1241,12 @@ func (s *Sub) publicRouter() *Router {
rt := newRouter()
rt.bodyLimit = 4 * 1024
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) {
s.send(w, r, 400, subErr("bad_request"))
}
rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) }
// Перенос панели: изменения сейчас остались бы только в уезжающей базе. 502 unavailable
// клиент понимает как «подписка недоступна» — туннель не трогает и повторит позже.
rt.Use("/", func(c *Ctx) bool {
if s.frozen.Load() {
return s.sendC(c, 502, subErr("unavailable"))
}
return true
})
// Всё, что не доказало знание секрета ссылки или ключа устройства, получает ответ пустого
// Go-сервера (stealth): битое тело, чужой путь, чужой метод. Подписанные ответы — только
// своим. Перенос панели (frozen) — тоже только им: 502 unavailable клиент понимает как
// «подписка недоступна», туннель не трогает и повторит позже (frozenFor).
rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) { http.NotFound(w, r) }
rt.notFound = func(c *Ctx) { stealth(c) }
rt.Post("/sub/v1/register", s.wrapSub(s.register))
rt.Post("/sub/v1/peek", s.wrapSub(s.peek))
rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config))
+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)))
}