From a3d46642148b885407beaff7d98807a9589db424 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:52:55 +0000 Subject: [PATCH] Stealth sen:// port, random cert names, external Prometheus card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 19 +++ .../src/tabs/monitor/ExternalPrometheus.tsx | 98 +++++++++++++ awg-ui/src/tabs/monitor/index.tsx | 2 + awg-ui/src/tabs/monitor/monitor.css | 2 + install.sh | 5 +- internal/ui/nodes.go | 17 ++- internal/ui/sub.go | 136 +++++++++++------- internal/ui/sub_stealth_test.go | 105 ++++++++++++++ 8 files changed, 331 insertions(+), 53 deletions(-) create mode 100644 awg-ui/src/tabs/monitor/ExternalPrometheus.tsx create mode 100644 internal/ui/sub_stealth_test.go diff --git a/README.md b/README.md index 7299735..2de519a 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,25 @@ iptables, заголовки ядра) остаются. Ноду после у путь, и путь старой (`UI_PREFIX_MOVED`) — закладки и API через ретранслятор продолжают работать. Пока выдан код переноса, `POST /migrate/v1/import` принимается и без пути. +### Порт подписки sen:// + +Подписка отвечает по протоколу только тем, кто доказал знание секрета: `peek`/`register` — +с секретом мастер-ключа из ссылки, остальные запросы — с подписью ключом устройства. На всё +прочее (другие пути и методы, битое тело, чужой секрет, подобранный id устройства, неверная +подпись) — `404 page not found`, как пустой веб-сервер. Удалённому устройству `410` уходит, +только если запрос подписан его ключом (они хранятся в `revoked_devices`); устройства, +удалённые до этой версии, получают тот же тихий 404. Самоподписанные сертификаты подписки +(`SUB_TLS=on`) и хаба нод создаются со случайным именем вместо общих `sen` / `awg-node`: +клиенты и ноды сверяют ключ, а не имя, так что прежние сертификаты продолжают работать. + +### Внешний Prometheus + +Вкладка «Монитор» → «Внешний Prometheus»: выдать токен «только метрики» и скопировать готовый +фрагмент `prometheus.yml`. Адрес в нём берётся от страницы панели, поэтому уже содержит +секретный путь (`metrics_path: /<путь>/metrics`) или подпапку nginx. Без пути `/metrics` +снаружи отвечает 404. Панель по голому HTTP передаёт токен и метрики открытым текстом — +внешний скрейп лучше вести через nginx с TLS или по VPN. + ### Панель за nginx Панель работает и в корне домена, и в любой подпапке — пути в ней относительные, diff --git a/awg-ui/src/tabs/monitor/ExternalPrometheus.tsx b/awg-ui/src/tabs/monitor/ExternalPrometheus.tsx new file mode 100644 index 0000000..06daf2d --- /dev/null +++ b/awg-ui/src/tabs/monitor/ExternalPrometheus.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Ivan Vasilev +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import { useState, useEffect, useCallback } from 'react'; +import { apiFetch, copyText, timeAgo } from '../../lib/shared'; +import { IcoCopy, IcoTrash } from '../../components/icons'; + +// Внешний Prometheus: свой сервер мониторинга скрейпит /metrics панели по токену «только метрики» +// (/ui/metrics-token; он не открывает ни панель, ни /api/v1). Адрес берём от страницы: в нём уже +// секретный путь панели (UI_PREFIX) или подпапка nginx, поэтому конфиг верен в обоих случаях. +interface View { exists: boolean; prefix?: string; created_at?: number; last_used?: number | null; per_peer: boolean } + +function scrapeConfig(tok: string): string { + const u = new URL('metrics', window.location.href); + return [ + 'scrape_configs:', + ' - job_name: vpn-panel', + ` scheme: ${u.protocol.replace(':', '')}`, + ` metrics_path: ${u.pathname}`, + ' authorization:', + ` credentials: ${tok}`, + ' static_configs:', + ` - targets: ['${u.host}']`, + ].join('\n'); +} + +export default function ExternalPrometheus({ token, showMsg }: { token: string; showMsg: (t: string) => void }) { + const [view, setView] = useState(null); + const [fresh, setFresh] = useState(''); // токен показываем один раз — сразу после выпуска + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { setView(await apiFetch('GET', '/ui/metrics-token', token) as View); } catch { /* карточка необязательна */ } + }, [token]); + useEffect(() => { void load(); }, [load]); + + const issue = async () => { + if (view?.exists && !confirm('Выпустить новый токен? Prometheus со старым перестанет получать метрики.')) return; + setBusy(true); + try { const r = await apiFetch('POST', '/ui/metrics-token', token); setFresh(r.token); setView(r); } + catch (e: any) { showMsg(e?.response?.data?.error ?? 'Не удалось выпустить токен'); } + finally { setBusy(false); } + }; + const revoke = async () => { + if (!confirm('Отозвать токен? Внешний Prometheus перестанет получать метрики.')) return; + setBusy(true); + try { setView(await apiFetch('DELETE', '/ui/metrics-token', token)); setFresh(''); } + catch (e: any) { showMsg(e?.response?.data?.error ?? 'Не удалось отозвать токен'); } + finally { setBusy(false); } + }; + + if (!view) return null; + const plain = window.location.protocol === 'http:'; + return ( +
+
+

Внешний Prometheus

+ + {view.exists ? 'токен выдан' : 'не подключён'} + +
+

+ Свой Prometheus может забирать метрики панели и всех серверов по отдельному токену — он открывает только + /metrics, не панель и не API. Адрес в конфиге — с секретным путём панели, без него сервер отвечает 404. + {view.exists && <> Токен {view.prefix}, последний скрейп — {view.last_used ? timeAgo(view.last_used) + ' назад' : 'ещё не было'}.} +

+ {plain && ( +

+ Панель открыта по HTTP: токен и метрики пойдут по сети открытым текстом и видны DPI. Скрейпьте через + nginx с TLS или по VPN-туннелю. +

+ )} + + {fresh && ( + <> +

Токен показывается один раз. Готовый фрагмент для prometheus.yml:

+
{scrapeConfig(fresh)}
+
+ +
+ + )} + +
+ + {view.exists && ( + + )} +
+
+ ); +} diff --git a/awg-ui/src/tabs/monitor/index.tsx b/awg-ui/src/tabs/monitor/index.tsx index c485efc..b6045b7 100644 --- a/awg-ui/src/tabs/monitor/index.tsx +++ b/awg-ui/src/tabs/monitor/index.tsx @@ -5,6 +5,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared'; import { IcoCopy, IcoTrash } from '../../components/icons'; import LineChart from '../../components/LineChart'; +import ExternalPrometheus from './ExternalPrometheus'; import './monitor.css'; // Вкладка «Монитор»: встроенный Prometheus и основные графики. @@ -360,6 +361,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) { {setup} {dashboard} {manage} + ); } diff --git a/awg-ui/src/tabs/monitor/monitor.css b/awg-ui/src/tabs/monitor/monitor.css index 6d643bc..78cc8b4 100644 --- a/awg-ui/src/tabs/monitor/monitor.css +++ b/awg-ui/src/tabs/monitor/monitor.css @@ -24,6 +24,8 @@ LICENSE file in the root directory of this source tree. border-radius: var(--r-table); padding: 12px; } +/* Многострочный фрагмент (конфиг): переносы строк сохраняются, длинные — переносятся */ +pre.mon-code { white-space: pre-wrap; margin: 0; } @media (max-width: 640px) { .mon-actions .btn { flex: 1; } diff --git a/install.sh b/install.sh index cbcca46..87b7ca3 100644 --- a/install.sh +++ b/install.sh @@ -1389,13 +1389,16 @@ if [[ "$ROLE" != "node" ]]; then chmod 600 "$SUB_SIGN_PRIV" ok "Ключ подписи sen:// создан" fi + # Имя в сертификате случайное: одинаковое у всех серверов было бы сигнатурой для сканера. + # Клиент сверяет pin ключа, а не имя. if [[ "$SUB_TLS" == "on" ]]; then if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then ok "Сертификат подписки sen:// сохранён" else ( umask 077 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ - -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=sen" 2>/dev/null ) + -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 \ + -subj "/CN=$(rand_alnum 10 | tr 'A-Z' 'a-z').net" 2>/dev/null ) chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT" ok "Самоподписанный сертификат подписки sen:// создан (10 лет)" fi diff --git a/internal/ui/nodes.go b/internal/ui/nodes.go index 50f3d28..3d2a92b 100644 --- a/internal/ui/nodes.go +++ b/internal/ui/nodes.go @@ -153,14 +153,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) { cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", - "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node") + "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN="+randomCN()) if err := cmd.Run(); err != nil { return err } @@ -927,3 +928,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" +} diff --git a/internal/ui/sub.go b/internal/ui/sub.go index 79aada4..3ed65ef 100644 --- a/internal/ui/sub.go +++ b/internal/ui/sub.go @@ -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)) diff --git a/internal/ui/sub_stealth_test.go b/internal/ui/sub_stealth_test.go new file mode 100644 index 0000000..de07d4b --- /dev/null +++ b/internal/ui/sub_stealth_test.go @@ -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))) +}