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:
@@ -92,6 +92,25 @@ iptables, заголовки ядра) остаются. Ноду после у
|
|||||||
путь, и путь старой (`UI_PREFIX_MOVED`) — закладки и API через ретранслятор продолжают
|
путь, и путь старой (`UI_PREFIX_MOVED`) — закладки и API через ретранслятор продолжают
|
||||||
работать. Пока выдан код переноса, `POST /migrate/v1/import` принимается и без пути.
|
работать. Пока выдан код переноса, `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
|
### Панель за nginx
|
||||||
|
|
||||||
Панель работает и в корне домена, и в любой подпапке — пути в ней относительные,
|
Панель работает и в корне домена, и в любой подпапке — пути в ней относительные,
|
||||||
|
|||||||
@@ -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<View | null>(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 (
|
||||||
|
<section className="mon-card" aria-labelledby="mon-external">
|
||||||
|
<div className="mon-head">
|
||||||
|
<h3 className="mon-title" id="mon-external">Внешний Prometheus</h3>
|
||||||
|
<span className={`chip chip--${view.exists ? 'online' : 'offline'}`}>
|
||||||
|
<span className="chip-dot" />{view.exists ? 'токен выдан' : 'не подключён'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mon-hint">
|
||||||
|
Свой Prometheus может забирать метрики панели и всех серверов по отдельному токену — он открывает только
|
||||||
|
/metrics, не панель и не API. Адрес в конфиге — с секретным путём панели, без него сервер отвечает 404.
|
||||||
|
{view.exists && <> Токен <b>{view.prefix}</b>, последний скрейп — {view.last_used ? timeAgo(view.last_used) + ' назад' : 'ещё не было'}.</>}
|
||||||
|
</p>
|
||||||
|
{plain && (
|
||||||
|
<p className="mon-hint">
|
||||||
|
Панель открыта по HTTP: токен и метрики пойдут по сети открытым текстом и видны DPI. Скрейпьте через
|
||||||
|
nginx с TLS или по VPN-туннелю.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fresh && (
|
||||||
|
<>
|
||||||
|
<p className="mon-hint">Токен показывается один раз. Готовый фрагмент для prometheus.yml:</p>
|
||||||
|
<pre className="mon-code">{scrapeConfig(fresh)}</pre>
|
||||||
|
<div className="mon-actions">
|
||||||
|
<button className="btn btn--tonal" onClick={() => copyText(scrapeConfig(fresh)).then(() => showMsg('Конфиг скопирован'))}>
|
||||||
|
<IcoCopy /> Скопировать конфиг
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mon-actions">
|
||||||
|
<button className={`btn ${view.exists ? 'btn--tonal' : 'btn--primary'}`} onClick={issue} disabled={busy}>
|
||||||
|
{view.exists ? 'Перевыпустить токен' : 'Выдать токен'}
|
||||||
|
</button>
|
||||||
|
{view.exists && (
|
||||||
|
<button className="btn btn--danger" onClick={revoke} disabled={busy}>
|
||||||
|
<IcoTrash /> Отозвать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared';
|
import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||||
import { IcoCopy, IcoTrash } from '../../components/icons';
|
import { IcoCopy, IcoTrash } from '../../components/icons';
|
||||||
import LineChart from '../../components/LineChart';
|
import LineChart from '../../components/LineChart';
|
||||||
|
import ExternalPrometheus from './ExternalPrometheus';
|
||||||
import './monitor.css';
|
import './monitor.css';
|
||||||
|
|
||||||
// Вкладка «Монитор»: встроенный Prometheus и основные графики.
|
// Вкладка «Монитор»: встроенный Prometheus и основные графики.
|
||||||
@@ -360,6 +361,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
|
|||||||
{setup}
|
{setup}
|
||||||
{dashboard}
|
{dashboard}
|
||||||
{manage}
|
{manage}
|
||||||
|
<ExternalPrometheus token={token} showMsg={showMsg} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ LICENSE file in the root directory of this source tree.
|
|||||||
border-radius: var(--r-table);
|
border-radius: var(--r-table);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
/* Многострочный фрагмент (конфиг): переносы строк сохраняются, длинные — переносятся */
|
||||||
|
pre.mon-code { white-space: pre-wrap; margin: 0; }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.mon-actions .btn { flex: 1; }
|
.mon-actions .btn { flex: 1; }
|
||||||
|
|||||||
+4
-1
@@ -1389,13 +1389,16 @@ if [[ "$ROLE" != "node" ]]; then
|
|||||||
chmod 600 "$SUB_SIGN_PRIV"
|
chmod 600 "$SUB_SIGN_PRIV"
|
||||||
ok "Ключ подписи sen:// создан"
|
ok "Ключ подписи sen:// создан"
|
||||||
fi
|
fi
|
||||||
|
# Имя в сертификате случайное: одинаковое у всех серверов было бы сигнатурой для сканера.
|
||||||
|
# Клиент сверяет pin ключа, а не имя.
|
||||||
if [[ "$SUB_TLS" == "on" ]]; then
|
if [[ "$SUB_TLS" == "on" ]]; then
|
||||||
if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then
|
if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then
|
||||||
ok "Сертификат подписки sen:// сохранён"
|
ok "Сертификат подписки sen:// сохранён"
|
||||||
else
|
else
|
||||||
( umask 077
|
( umask 077
|
||||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
|
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"
|
chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT"
|
||||||
ok "Самоподписанный сертификат подписки sen:// создан (10 лет)"
|
ok "Самоподписанный сертификат подписки sen:// создан (10 лет)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+15
-2
@@ -153,14 +153,15 @@ func newNodes(a *App) (*Nodes, error) {
|
|||||||
}
|
}
|
||||||
return filepath.Join(a.baseDir, name)
|
return filepath.Join(a.baseDir, name)
|
||||||
}
|
}
|
||||||
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же,
|
// TLS: самоподписанный сертификат, pin которого зашит в join-строки. Имя в нём случайное:
|
||||||
|
// одинаковое у всех серверов было бы сигнатурой для сканера (pin — по ключу, не по имени). Живёт столько же,
|
||||||
// сколько core: пересоздать = переподключить все ноды.
|
// сколько core: пересоздать = переподключить все ноды.
|
||||||
if n.port != 0 {
|
if n.port != 0 {
|
||||||
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
|
keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt")
|
||||||
if err := func() error {
|
if err := func() error {
|
||||||
if !exists(keyF) && !exists(crtF) {
|
if !exists(keyF) && !exists(crtF) {
|
||||||
cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
|
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 {
|
if err := cmd.Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -927,3 +928,15 @@ func (n *Nodes) router() *Router {
|
|||||||
})
|
})
|
||||||
return rt
|
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"
|
||||||
|
}
|
||||||
|
|||||||
+84
-48
@@ -288,6 +288,13 @@ func (s *Sub) initSchema() error {
|
|||||||
rekey_requested INTEGER NOT NULL DEFAULT 0,
|
rekey_requested INTEGER NOT NULL DEFAULT 0,
|
||||||
UNIQUE (master_id, device_id)
|
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 остался от времени одного сервера и
|
-- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и
|
||||||
-- дальше не читается (кроме переноса ниже).
|
-- дальше не читается (кроме переноса ниже).
|
||||||
CREATE TABLE IF NOT EXISTS master_servers (
|
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)
|
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 уйдут каскадом
|
q.Exec("DELETE FROM devices WHERE id = ?", dev.ID) // device_peers уйдут каскадом
|
||||||
ids := []int{}
|
ids := []int{}
|
||||||
for _, l := range links {
|
for _, l := range links {
|
||||||
@@ -903,21 +913,23 @@ func reqPath(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// signFailure — подпись запроса и защита от повторов. "" — всё верно, иначе причина (для лога).
|
// 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"], ", "))
|
tsF := jsToNumber(strings.Join(c.R.Header["X-Sen-Ts"], ", "))
|
||||||
if _, has := c.R.Header["X-Sen-Ts"]; !has {
|
if _, has := c.R.Header["X-Sen-Ts"]; !has {
|
||||||
tsF = jsToNumber("x")
|
tsF = jsToNumber("x")
|
||||||
}
|
}
|
||||||
sig := c.R.Header.Get("X-Sen-Sig")
|
sig := c.R.Header.Get("X-Sen-Sig")
|
||||||
if tsF != float64(int64(tsF)) || sig == "" {
|
if tsF != float64(int64(tsF)) || sig == "" {
|
||||||
return "no_signature"
|
return "no_signature", false
|
||||||
}
|
}
|
||||||
ts := int64(tsF)
|
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) {
|
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()
|
s.sigMu.Lock()
|
||||||
defer s.sigMu.Unlock()
|
defer s.sigMu.Unlock()
|
||||||
@@ -928,10 +940,27 @@ func (s *Sub) signFailure(c *Ctx, authPub []byte) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, ok := s.seenSigs[sig]; ok {
|
if _, ok := s.seenSigs[sig]; ok {
|
||||||
return "replay"
|
return "replay", true
|
||||||
}
|
}
|
||||||
s.seenSigs[sig] = t.Add(2 * tsWindow * time.Second)
|
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 {
|
func (s *Sub) deviceAuth(c *Ctx) bool {
|
||||||
@@ -942,16 +971,31 @@ func (s *Sub) deviceAuth(c *Ctx) bool {
|
|||||||
if valid {
|
if valid {
|
||||||
dev = s.deviceByID(s.a.db, int64(f))
|
dev = s.deviceByID(s.a.db, int64(f))
|
||||||
}
|
}
|
||||||
|
if !valid {
|
||||||
|
return stealth(c)
|
||||||
|
}
|
||||||
// Устройства нет — его удалили (id не переиспользуются). 410, чтобы клиент снял конфиг, и
|
// Устройства нет — его удалили (id не переиспользуются). 410, чтобы клиент снял конфиг, и
|
||||||
// не в лимит неудач: такие клиенты опрашивают по таймеру и выбивали бы лимит соседям за NAT.
|
// не в лимит неудач: такие клиенты опрашивают по таймеру и выбивали бы лимит соседям за 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"))
|
return s.sendC(c, 410, subErr("revoked"))
|
||||||
}
|
}
|
||||||
why := "no_device"
|
why, signed := s.signFailure(c, fromB64url(dev.AuthPub))
|
||||||
if dev != nil {
|
if !signed {
|
||||||
why = s.signFailure(c, fromB64url(dev.AuthPub))
|
return stealth(c)
|
||||||
}
|
}
|
||||||
if why == "" {
|
if why == "" {
|
||||||
|
if s.frozenFor(c) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
c.device = dev
|
c.device = dev
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -959,11 +1003,7 @@ func (s *Sub) deviceAuth(c *Ctx) bool {
|
|||||||
if !s.failLimit.allow(ip) {
|
if !s.failLimit.allow(ip) {
|
||||||
return s.sendC(c, 429, subErr("rate_limited"))
|
return s.sendC(c, 429, subErr("rate_limited"))
|
||||||
}
|
}
|
||||||
hdr := c.R.Header.Get("X-Sen-Device")
|
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)
|
||||||
if !hasHdr {
|
|
||||||
hdr = "-"
|
|
||||||
}
|
|
||||||
warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), hdr, why, ip)
|
|
||||||
return s.sendC(c, 401, subErr("unauthorized"))
|
return s.sendC(c, 401, subErr("unauthorized"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,10 +1026,6 @@ var errLimit = errors.New("limit")
|
|||||||
|
|
||||||
func (s *Sub) register(c *Ctx) error {
|
func (s *Sub) register(c *Ctx) error {
|
||||||
ip := c.remoteIP()
|
ip := c.remoteIP()
|
||||||
if !s.registerLimit.allow(ip) {
|
|
||||||
s.sendC(c, 429, subErr("rate_limited"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b := c.Obj()
|
b := c.Obj()
|
||||||
strRe := func(k string, re *regexp.Regexp) (string, bool) {
|
strRe := func(k string, re *regexp.Regexp) (string, bool) {
|
||||||
v, ok := b.Get2(k).(string)
|
v, ok := b.Get2(k).(string)
|
||||||
@@ -1010,7 +1046,23 @@ func (s *Sub) register(c *Ctx) error {
|
|||||||
if !ok5 {
|
if !ok5 {
|
||||||
version = ""
|
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"))
|
s.sendC(c, 400, subErr("bad_request"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1019,7 +1071,7 @@ func (s *Sub) register(c *Ctx) error {
|
|||||||
authPub := fromB64url(authPubS)
|
authPub := fromB64url(authPubS)
|
||||||
why := "bad_auth_pub"
|
why := "bad_auth_pub"
|
||||||
if len(authPub) == 32 {
|
if len(authPub) == 32 {
|
||||||
why = s.signFailure(c, authPub)
|
why, _ = s.signFailure(c, authPub)
|
||||||
}
|
}
|
||||||
if why != "" {
|
if why != "" {
|
||||||
warnf("sub: 401 register %s ip=%s", why, ip)
|
warnf("sub: 401 register %s ip=%s", why, ip)
|
||||||
@@ -1027,11 +1079,6 @@ func (s *Sub) register(c *Ctx) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
master := s.masterBySecret(fromB64url(secretB))
|
|
||||||
if master == nil {
|
|
||||||
s.sendC(c, 404, subErr("not_found"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var serverIDs []int
|
var serverIDs []int
|
||||||
for _, id := range s.serverIDsOf(s.a.db, master.ID) {
|
for _, id := range s.serverIDsOf(s.a.db, master.ID) {
|
||||||
if s.a.nodes.Exists(id) {
|
if s.a.nodes.Exists(id) {
|
||||||
@@ -1092,23 +1139,18 @@ func (s *Sub) register(c *Ctx) error {
|
|||||||
|
|
||||||
// peek — сколько мест у ключа занято, по секрету из ссылки (устройства ещё нет).
|
// peek — сколько мест у ключа занято, по секрету из ссылки (устройства ещё нет).
|
||||||
func (s *Sub) peek(c *Ctx) error {
|
func (s *Sub) peek(c *Ctx) error {
|
||||||
ip := c.remoteIP()
|
|
||||||
secretB, ok := c.Obj().Get2("sub").(string)
|
secretB, ok := c.Obj().Get2("sub").(string)
|
||||||
ok = ok && reSubSecret.MatchString(secretB)
|
ok = ok && reSubSecret.MatchString(secretB)
|
||||||
var master *masterRow
|
var master *masterRow
|
||||||
if ok {
|
if ok {
|
||||||
master = s.masterBySecret(fromB64url(secretB))
|
master = s.masterBySecret(fromB64url(secretB))
|
||||||
}
|
}
|
||||||
|
// Секрет не тот — ответа протокола нет (stealth): иначе сканер отличал бы подписку.
|
||||||
if master == nil {
|
if master == nil {
|
||||||
if !s.failLimit.allow(ip) {
|
stealth(c)
|
||||||
s.sendC(c, 429, subErr("rate_limited"))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if ok {
|
if s.frozenFor(c) {
|
||||||
s.sendC(c, 404, subErr("not_found"))
|
|
||||||
} else {
|
|
||||||
s.sendC(c, 400, subErr("bad_request"))
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.sendC(c, 200, jsjson.Object{{Key: "key_uuid", Val: master.UUID}, {Key: "devices", Val: s.deviceCount(s.a.db, master.ID)},
|
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 := newRouter()
|
||||||
rt.bodyLimit = 4 * 1024
|
rt.bodyLimit = 4 * 1024
|
||||||
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
|
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
|
||||||
rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) {
|
// Всё, что не доказало знание секрета ссылки или ключа устройства, получает ответ пустого
|
||||||
s.send(w, r, 400, subErr("bad_request"))
|
// Go-сервера (stealth): битое тело, чужой путь, чужой метод. Подписанные ответы — только
|
||||||
}
|
// своим. Перенос панели (frozen) — тоже только им: 502 unavailable клиент понимает как
|
||||||
rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) }
|
// «подписка недоступна», туннель не трогает и повторит позже (frozenFor).
|
||||||
// Перенос панели: изменения сейчас остались бы только в уезжающей базе. 502 unavailable
|
rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) { http.NotFound(w, r) }
|
||||||
// клиент понимает как «подписка недоступна» — туннель не трогает и повторит позже.
|
rt.notFound = func(c *Ctx) { stealth(c) }
|
||||||
rt.Use("/", func(c *Ctx) bool {
|
|
||||||
if s.frozen.Load() {
|
|
||||||
return s.sendC(c, 502, subErr("unavailable"))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
rt.Post("/sub/v1/register", s.wrapSub(s.register))
|
rt.Post("/sub/v1/register", s.wrapSub(s.register))
|
||||||
rt.Post("/sub/v1/peek", s.wrapSub(s.peek))
|
rt.Post("/sub/v1/peek", s.wrapSub(s.peek))
|
||||||
rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config))
|
rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config))
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user