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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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