From 96c21408e6b938dfb79b454d4976128f37a2afc2 Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Wed, 21 Oct 2026 19:25:54 +0300 Subject: [PATCH] Prometheus on a node; panel under a URL prefix; open ports on migrate import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A panel without VPN (e.g. after the move) can use a Prometheus that lives on a node, such as the one with history left on the old server (node #0). Both directions go through the agent's WSS: the node's Prometheus scrapes 127.0.0.1:AGENT_METRICS_PORT/metrics, the agent checks its token and asks core (metrics → metrics_res, the same exposition as /metrics, at most once per 5 s per connection); the monitor tab reads it back through the agent, which forwards only GET /-/ready and /api/v1/query[_range]. The panel finds it by itself (local first, then the lowest-id node), and the install/remove buttons work on a panel without VPN. `monitor install` on a node without its own panel picks AGENT_METRICS_PORT, writes it to cli.env and restarts the agent; on the old server it keeps the collected data. The panel works behind nginx in a subfolder: relative asset and API paths, relative directory redirects, and the client address from X-Forwarded-For / X-Real-IP only from trusted proxies (loopback, TRUST_PROXY). migrate import opens the moved SUB_PORT/NODE_PORT in UFW when it is active. --- README.md | 28 +++++ awg-ui/mock-server.ts | 3 +- awg-ui/src/main.tsx | 9 ++ awg-ui/src/tabs/monitor/index.tsx | 24 ++-- awg-ui/vite.config.ts | 4 + internal/agent/agent.go | 182 +++++++++++++++++++++++++++- internal/agent/agent_test.go | 118 ++++++++++++++++++ internal/cli/cli.go | 16 +++ internal/cli/migrate.go | 8 ++ internal/cli/monitor.go | 53 +++++++- internal/migrate/firewall.go | 41 +++++++ internal/migrate/migrate_test.go | 29 +++++ internal/ui/migrate.go | 4 + internal/ui/monitor.go | 194 ++++++++++++++++++++++++------ internal/ui/monitor_test.go | 143 ++++++++++++++++++++++ internal/ui/nodes.go | 36 +++++- internal/ui/server.go | 5 +- internal/ui/trustproxy.go | 88 ++++++++++++++ internal/ui/trustproxy_test.go | 57 +++++++++ internal/ui/web.go | 3 +- 20 files changed, 980 insertions(+), 65 deletions(-) create mode 100644 internal/migrate/firewall.go create mode 100644 internal/ui/monitor_test.go create mode 100644 internal/ui/trustproxy.go create mode 100644 internal/ui/trustproxy_test.go diff --git a/README.md b/README.md index 6ba5c2a..faefefe 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,34 @@ AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgettin `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика (подробности — в комментариях `install.sh` и в `.env`). +### Панель за nginx + +Панель работает и в корне домена, и в любой подпапке — пути в ней относительные, +настраивать сам префикс не нужно. Пример для `https://example.com/forgetting/` +(`39708` — `PORT` панели из `/opt/awg-control/cli/cli.env`): + +```nginx +location = /forgetting { return 301 /forgetting/; } +location ^~ /forgetting/ { + proxy_pass http://127.0.0.1:39708/; # слэш в конце срезает префикс + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Адрес клиента панель берёт из `X-Forwarded-For` / `X-Real-IP` только от доверенного +прокси: с этой же машины (loopback) — всегда, с других адресов — если они перечислены +в `TRUST_PROXY` в `cli.env` (IP или сети через запятую, например +`TRUST_PROXY=10.0.0.5,192.168.1.0/24`), после чего `awg-ctrl restart`. От остальных +заголовки игнорируются. Без этого все входы шли бы с адреса прокси и делили один +лимит неудачных попыток. + +Сам порт панели после этого можно закрыть снаружи (`ufw deny /tcp`) — порты +подписки (`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты +и ноды ходят на них напрямую, не через nginx. + ## API Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` — diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 385a674..595810f 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -118,7 +118,8 @@ app.get("/ui/monitor", (_q, r) => { const n = monRunning() ? Math.max(1, Math.floor(((Date.now() - monJob.start) / MON_MS) * lines.length)) : lines.length; job = { action: monJob.action, running: monRunning(), exit: monRunning() ? null : 0, log: lines.slice(0, n).join("\n") }; } - r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", job }); + // MOCK_ROLE=core/retire — панель без VPN: Prometheus на ноде #0 (сервер, с которого она переехала) + r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", host: MOCK_CORE ? { id: 0, name: "VPN" } : null, job }); }); for (const action of ["install", "remove"] as const) { app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); }); diff --git a/awg-ui/src/main.tsx b/awg-ui/src/main.tsx index 3d0bd24..7bf59ba 100644 --- a/awg-ui/src/main.tsx +++ b/awg-ui/src/main.tsx @@ -3,8 +3,17 @@ // LICENSE file in the root directory of this source tree. import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import axios from 'axios'; import App from './App'; +// Пути к API в коде пишутся от корня («/ui/nodes»), а уходят от адреса страницы («ui/nodes»): +// так панель за reverse-proxy в подпапке (example.com/forgetting/) ходит в свой префикс, а в корне +// всё как раньше. Одно место на все запросы, включая будущие. +axios.interceptors.request.use(c => { + if (c.url?.startsWith('/') && !c.url.startsWith('//')) c.url = c.url.slice(1); + return c; +}); + createRoot(document.getElementById('root')!).render( diff --git a/awg-ui/src/tabs/monitor/index.tsx b/awg-ui/src/tabs/monitor/index.tsx index 8357f80..c485efc 100644 --- a/awg-ui/src/tabs/monitor/index.tsx +++ b/awg-ui/src/tabs/monitor/index.tsx @@ -8,11 +8,12 @@ import LineChart from '../../components/LineChart'; import './monitor.css'; // Вкладка «Монитор»: встроенный Prometheus и основные графики. -// Кнопка «Подключить Prometheus» просит локальный awg-ctrl поставить его (`awg-ctrl monitor install`, root; -// сама панель пакетов не ставит). Графики читают Prometheus через фиксированные запросы панели -// (awg-ui/monitor.ts). +// Кнопка «Подключить Prometheus» просит awg-ctrl сервера поставить его (`awg-ctrl monitor install`, root; +// сама панель пакетов не ставит). У панели без VPN он ставится на ноду, и панель читает его через её агента. +// Графики читают Prometheus через фиксированные запросы панели (internal/ui/monitor.go). interface Status { ready: boolean; can_install: boolean; command: string; + host: { id: number; name: string } | null; // Prometheus на ноде (или будет поставлен туда); null — на этом сервере job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция } type Pts = [number, number][]; @@ -93,7 +94,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) { useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); const loadStatus = useCallback(async () => { - try { const s = await apiFetch('GET', '/ui/monitor', token) as Status; if (alive.current) setStatus(s); return s; } + try { const s = await apiFetch('GET', `/ui/monitor?server=${getServerId() ?? 0}`, token) as Status; if (alive.current) setStatus(s); return s; } catch { return null; } }, [token]); @@ -139,7 +140,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) { const run = async (action: 'install' | 'remove') => { if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 31 дня) сотрутся. Панель продолжит отдавать /metrics.')) return; setPending(action); - try { await apiFetch('POST', `/ui/monitor/${action}`, token); await loadStatus(); } + try { await apiFetch('POST', `/ui/monitor/${action}?server=${getServerId() ?? 0}`, token); await loadStatus(); } catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); } }; const working = !!pending || !!status?.job?.running; @@ -174,7 +175,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) { не подключён

- Панель поставит Prometheus на этот сервер и сама подключит его к своим метрикам: основные графики — + Панель поставит Prometheus {status.host ? <>на сервер {status.host.name} : 'на этот сервер'} и сама подключит его к своим метрикам: основные графики — скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает только 127.0.0.1, наружу он не открывается.

@@ -188,7 +189,10 @@ export default function MonitorPage({ token, showMsg }: PageProps) { ) : ( <> -

Выполните на сервере от root (у этой панели нет своего awg-ctrl, который мог бы сделать это сам):

+

+ У этой панели нет своего VPN-сервера: выберите ноду в сервер-баре, чтобы поставить Prometheus на неё + кнопкой, или выполните на ней от root: +

{status.command}

- Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет - пакет и сотрёт накопленные данные. + Стоит {status.host ? <>на сервере {status.host.name} (панель читает его через агента ноды) : 'на этом сервере'} и + слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет пакет и сотрёт накопленные данные.

{status.can_install ? (
@@ -222,7 +226,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
) : ( <> -

Выполните на сервере от root:

+

Выполните на сервере с Prometheus от root:

awg-ctrl monitor remove )} diff --git a/awg-ui/vite.config.ts b/awg-ui/vite.config.ts index 58c0753..d797931 100644 --- a/awg-ui/vite.config.ts +++ b/awg-ui/vite.config.ts @@ -5,6 +5,10 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ + // Ресурсы — от адреса страницы, а не от корня домена: собранная панель работает и в корне, и в + // подпапке за reverse-proxy (example.com/forgetting/) без пересборки. Запросы к API — так же + // (main.tsx). + base: './', plugins: [react()], server: { proxy: { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 3391c78..afc679e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1,7 +1,9 @@ // Package agent — агент ноды (порт awg-agent/index.ts). Сам открывает исходящее // WSS-соединение к core (awg-ui), доказывает свою личность подписью Ed25519 и исполняет // присланные запросы на локальном awg-ctrl (127.0.0.1). Входящих портов у ноды нет, -// awg-ctrl остаётся закрытым от сети. Протокол и формат join-строки — docs/node-protocol.md. +// awg-ctrl остаётся закрытым от сети. Если на ноде стоит Prometheus (`awg-ctrl monitor install`), +// агент отдаёт ему метрики панели, взятые у core, и пропускает к нему запросы вкладки «Монитор». +// Протокол и формат join-строки — docs/node-protocol.md. package agent import ( @@ -10,9 +12,11 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" + "crypto/subtle" "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/hex" "encoding/pem" "errors" "fmt" @@ -44,6 +48,8 @@ const ( ctrlTimeout = 110 * time.Second maxBackoff = 60 * time.Second closeAuthFailed = 4003 + promTimeout = 8 * time.Second // как у панели к своему Prometheus + metricsTimeout = 9 * time.Second // меньше scrape_timeout Prometheus (10 с по умолчанию) ) type Config struct { @@ -58,6 +64,9 @@ type Config struct { CLIEnvFile string JoinSecret string // одноразовый, живёт до первого успешного подключения Version string + PromURL string // Prometheus этой машины: к нему агент пропускает запросы панели (/prom/…) + MetricsPort int // 127.0.0.1:порт, где Prometheus ноды скрейпит метрики панели; 0 — мониторинга на ноде нет + MonitorHash string // хэш токена этого Prometheus (его пишет `awg-ctrl monitor install`) Logf, Errorf func(format string, a ...any) } @@ -100,6 +109,9 @@ func ConfigFromEnv() (Config, error) { CLIEnvFile: os.Getenv("CLI_ENV_FILE"), JoinSecret: os.Getenv("JOIN_SECRET"), Version: os.Getenv("VERSION"), + PromURL: or("PROM_URL", "http://127.0.0.1:9090"), + MetricsPort: atoi("AGENT_METRICS_PORT"), + MonitorHash: or("MONITOR_HASH_FILE", "/etc/amnezia/amneziawg/monitor_token.sha256"), } if c.Version == "" { c.Version = envFromFile(filepath.Join(cwd, "..", ".env"), "VERSION") @@ -120,8 +132,19 @@ type Agent struct { joinSecret string addrs []string // куда звонить: адрес от core, затем из join (CORE_HOST:CORE_PORT) cur int + link *coreLink // соединение после ready; nil — core сейчас недоступен http *http.Client + prom *http.Client +} + +// coreLink — запросы ноды к core (пока один: метрики панели для Prometheus ноды). +type coreLink struct { + send func(v any) + done <-chan struct{} + mu sync.Mutex + seq int64 + pending map[int64]chan reply } // loadNodeKey — личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит. @@ -159,6 +182,7 @@ func New(c Config) (*Agent, error) { nodePubRaw: b64.EncodeToString(key.Public().(ed25519.PublicKey)), // awg-ctrl на loopback: системный прокси (HTTPS_PROXY и т. п.) сюда не нужен. http: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: ctrlTimeout}, + prom: &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: promTimeout}, } a.setAddrs(c.CoreEndpoint) if ik, err := iauth.LoadPrivate(c.InternalKey); err == nil { @@ -213,11 +237,13 @@ func errReply(status int, msg string) reply { return reply{status, jsjson.Object{{Key: "error", Val: msg}}} } -// execute выполняет запрос core на локальном awg-ctrl. Ответ возвращается как его разобрал -// бы axios: JSON — значением, иначе строкой (пустое тело — ""). +// execute выполняет запрос core на локальном awg-ctrl (или, для /prom/…, на Prometheus). func (a *Agent) execute(m jsjson.Object) reply { method, _ := m.Get2("method").(string) method = strings.ToUpper(method) + if s, _ := m.Get2("path").(string); strings.HasPrefix(s, "/prom/") { + return a.execProm(method, s) + } p, ok := pathAllowed(m.Get2("path")) if !methods[method] || !ok { return errReply(400, "Запрос не разрешён агентом") @@ -243,14 +269,19 @@ func (a *Agent) execute(m jsjson.Object) reply { if a.internalKey != nil { req.Header.Set("Authorization", "Bearer "+iauth.Mint(a.internalKey, "awg-agent", time.Now())) } - resp, err := a.http.Do(req) + return do(a.http, req, "awg-ctrl недоступен") +} + +// do — ответ как его разобрал бы axios: JSON — значением, иначе строкой (пустое тело — ""). +func do(c *http.Client, req *http.Request, down string) reply { + resp, err := c.Do(req) if err != nil { - return errReply(502, "awg-ctrl недоступен") + return errReply(502, down) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { - return errReply(502, "awg-ctrl недоступен") + return errReply(502, down) } if len(raw) == 0 { return reply{resp.StatusCode, ""} @@ -261,6 +292,40 @@ func (a *Agent) execute(m jsjson.Object) reply { return reply{resp.StatusCode, string(raw)} } +// promPaths — всё, что панель читает у Prometheus: готовность и фиксированные запросы вкладки. +var promPaths = map[string]bool{"/prom/-/ready": true, "/prom/api/v1/query": true, "/prom/api/v1/query_range": true} + +// promAllowed — запрос панели к Prometheus этой машины: только GET и только пути из promPaths. +func promAllowed(method string, p any) (string, bool) { + s, ok := p.(string) + if method != "GET" || !ok || len(s) > 8192 || !rePath.MatchString(s) { + return "", false + } + pathname, query, _ := strings.Cut(s, "?") + if !promPaths[pathname] || strings.Contains(query, "#") { + return "", false + } + return strings.TrimPrefix(s, "/prom"), true +} + +// execProm — Prometheus ноды для вкладки «Монитор» панели. Пропускаем, только если он +// скрейпит панель через этого агента (AGENT_METRICS_PORT): чужой Prometheus на машине +// панели не отдаём, да и метрик панели в нём нет. +func (a *Agent) execProm(method string, p any) reply { + if a.c.MetricsPort == 0 { + return errReply(404, "Prometheus на этой ноде не подключён к панели") + } + rest, ok := promAllowed(method, p) + if !ok { + return errReply(400, "Запрос не разрешён агентом") + } + req, err := http.NewRequest("GET", strings.TrimSuffix(a.c.PromURL, "/")+rest, nil) + if err != nil { + return errReply(400, "Запрос не разрешён агентом") + } + return do(a.prom, req, "Prometheus не отвечает") +} + // ── Соединение с core ────────────────────────────────────────────────────── func spkiPin(rawCert []byte) (string, error) { @@ -426,6 +491,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo return false } defer ws.CloseNow() + defer a.setLink(nil) ws.SetReadLimit(maxPayload) send := func(v any) { @@ -472,6 +538,7 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo case t == "ready": ready = true armDead() + a.setLink(&coreLink{send: send, done: ctx.Done(), pending: map[int64]chan reply{}}) if onReady != nil { onReady() } @@ -485,6 +552,8 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo if core, ok := m.Get2("core").(string); ok && a.learnCore(core) { send(jsjson.Object{{Key: "t", Val: "core_ok"}, {Key: "core", Val: core}}) } + case t == "metrics_res" && ready: + a.deliver(m) case t == "req" && ready: id, ok := m.Get2("id").(float64) if !ok || id != float64(int64(id)) { @@ -499,6 +568,104 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo } } +// ── Метрики панели для Prometheus ноды ───────────────────────────────────── + +func (a *Agent) setLink(l *coreLink) { + a.mu.Lock() + a.link = l + a.mu.Unlock() +} + +// deliver — ответ core на запрос метрик. +func (a *Agent) deliver(m jsjson.Object) { + id, ok := m.Get2("id").(float64) + a.mu.Lock() + l := a.link + a.mu.Unlock() + if !ok || l == nil { + return + } + l.mu.Lock() + ch := l.pending[int64(id)] + delete(l.pending, int64(id)) + l.mu.Unlock() + if ch == nil { + return + } + st, _ := m.Get2("status").(float64) + text, _ := m.Get2("text").(string) + ch <- reply{int(st), text} +} + +// coreMetrics — экспозиция /metrics панели, как её отдал бы сам core. +func (a *Agent) coreMetrics(ctx context.Context) reply { + a.mu.Lock() + l := a.link + a.mu.Unlock() + if l == nil { + return reply{503, "core недоступен\n"} + } + ch := make(chan reply, 1) + l.mu.Lock() + l.seq++ + id := l.seq + l.pending[id] = ch + l.mu.Unlock() + defer func() { + l.mu.Lock() + delete(l.pending, id) + l.mu.Unlock() + }() + l.send(jsjson.Object{{Key: "t", Val: "metrics"}, {Key: "id", Val: id}}) + select { + case r := <-ch: + return r + case <-l.done: + return reply{503, "core недоступен\n"} + case <-ctx.Done(): + return reply{504, "core не ответил\n"} + } +} + +// tokenOK — тот же токен, что встроенный Prometheus предъявил бы самой панели: хэш в файле, +// перечитываем на каждый скрейп (раз в 15 с), чтобы переустановка не требовала перезапуска. +func (a *Agent) tokenOK(r *http.Request) bool { + given, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + raw, err := os.ReadFile(a.c.MonitorHash) + if !ok || !strings.HasPrefix(given, "awgm_") || err != nil { + return false + } + want, err := hex.DecodeString(strings.TrimSpace(string(raw))) + sum := sha256.Sum256([]byte(given)) + return err == nil && len(want) == len(sum) && subtle.ConstantTimeCompare(sum[:], want) == 1 +} + +// serveMetrics — 127.0.0.1:AGENT_METRICS_PORT/metrics для Prometheus этой ноды: метрики всех +// серверов панели, полученные у core по уже открытому WSS. Наружу порт не открывается. +func (a *Agent) serveMetrics(ctx context.Context) { + mux := http.NewServeMux() + mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { + if !a.tokenOK(r) { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", 401) + return + } + cctx, cancel := context.WithTimeout(r.Context(), metricsTimeout) + defer cancel() + res := a.coreMetrics(cctx) + text, _ := res.data.(string) + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + w.WriteHeader(res.status) + io.WriteString(w, text) + }) + srv := &http.Server{Addr: "127.0.0.1:" + strconv.Itoa(a.c.MetricsPort), Handler: mux, ReadHeaderTimeout: 10 * time.Second} + go func() { <-ctx.Done(); srv.Close() }() + a.c.Logf("agent: метрики панели для Prometheus — http://%s/metrics", srv.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + a.c.Errorf("agent: метрики для Prometheus не подняты: %v", err) + } +} + func hostPort(host string, port int) string { if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") { host = "[" + host + "]" @@ -509,6 +676,9 @@ func hostPort(host string, port int) string { // Run переподключается вечно: экспоненциальная пауза до минуты, после отказа core — минута. func (a *Agent) Run(ctx context.Context) error { a.c.Logf("agent: нода #%d → wss://%s", a.c.NodeID, a.addr()) + if a.c.MetricsPort > 0 { + go a.serveMetrics(ctx) + } backoff := time.Second for { ready := false diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 9bd780b..47b4fbf 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -4,9 +4,12 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "crypto/sha256" "crypto/x509" + "encoding/hex" "encoding/pem" "io" + "net" "net/http" "net/http/httptest" "net/url" @@ -351,3 +354,118 @@ func TestFallsBackToJoinAddress(t *testing.T) { t.Fatal("агент не перешёл на адрес из join") } } + +func TestPromAllowed(t *testing.T) { + for p, want := range map[string]string{ + "/prom/-/ready": "/-/ready", + "/prom/api/v1/query?query=up&time=1": "/api/v1/query?query=up&time=1", + "/prom/api/v1/query_range?query=up": "/api/v1/query_range?query=up", + "/prom/api/v1/admin/tsdb/delete_series": "", + "/prom/api/v1/query/../admin": "", + "/prom//api/v1/query": "", + "/prom/api/v1/query?query=up#x": "", + "/prom/api/v1/query?query=up\nHost: x": "", + "/prom/api/v1/labels": "", + } { + got, ok := promAllowed("GET", p) + if want == "" && ok || want != "" && got != want { + t.Errorf("promAllowed(%q) = %q, %v", p, got, ok) + } + } + if _, ok := promAllowed("POST", "/prom/api/v1/query"); ok { + t.Error("POST к Prometheus пропущен") + } + a := &Agent{c: Config{PromURL: "http://127.0.0.1:1"}} + if r := a.execProm("GET", "/prom/-/ready"); r.status != 404 { + t.Errorf("без AGENT_METRICS_PORT: %d", r.status) + } +} + +// Prometheus на ноде: запросы панели доходят до него, а его скрейп получает метрики core. +func TestPrometheusOnNode(t *testing.T) { + prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/query": + io.WriteString(w, `{"status":"success","q":"`+r.URL.Query().Get("query")+`"}`) + case "/-/ready": + io.WriteString(w, "Prometheus Server is Ready.\n") + default: + w.WriteHeader(500) + } + })) + defer prom.Close() + + results := make(chan jsjson.Object, 8) + c := newCore(t, func(ctx context.Context, ws *websocket.Conn, read func() jsjson.Object) { + send(ctx, ws, jsjson.Object{{Key: "t", Val: "hello"}, {Key: "nonce", Val: "n"}}) + if read() == nil { + return + } + send(ctx, ws, jsjson.Object{{Key: "t", Val: "ready"}}) + send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 1}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/api/v1/query?query=up"}}) + send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 2}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/api/v1/admin/tsdb/snapshot"}}) + send(ctx, ws, jsjson.Object{{Key: "t", Val: "req"}, {Key: "id", Val: 3}, {Key: "method", Val: "GET"}, {Key: "path", Val: "/prom/-/ready"}}) + for { + m := read() + if m == nil { + return + } + switch m.Get2("t") { + case "res": + results <- m + case "metrics": + send(ctx, ws, jsjson.Object{{Key: "t", Val: "metrics_res"}, {Key: "id", Val: m.Get2("id")}, {Key: "status", Val: 200}, {Key: "text", Val: "awg_up 1\n"}}) + } + } + }) + + l, _ := net.Listen("tcp", "127.0.0.1:0") + port := l.Addr().(*net.TCPAddr).Port + l.Close() + a, _ := newAgent(t, c, c.pin(t), "http://127.0.0.1:1") + a.c.PromURL, a.c.MetricsPort = prom.URL, port + a.c.MonitorHash = filepath.Join(t.TempDir(), "monitor_token.sha256") + sum := sha256.Sum256([]byte("awgm_good")) + os.WriteFile(a.c.MonitorHash, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + go a.Run(ctx) + + byID := map[float64]string{} + for range 3 { + select { + case r := <-results: + byID[r.Get2("id").(float64)] = string(jsjson.MustMarshal(r)) + case <-ctx.Done(): + t.Fatal("нет ответов агента") + } + } + for id, want := range map[float64]string{ + 1: `{"t":"res","id":1,"status":200,"data":{"status":"success","q":"up"}}`, + 2: `{"t":"res","id":2,"status":400,"data":{"error":"Запрос не разрешён агентом"}}`, + 3: `{"t":"res","id":3,"status":200,"data":"Prometheus Server is Ready.\n"}`, + } { + if byID[id] != want { + t.Errorf("res %v:\n got %s\nwant %s", id, byID[id], want) + } + } + + scrape := func(tok string) (int, string) { + req, _ := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(port)+"/metrics", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) + } + if st, _ := scrape("awgm_bad"); st != 401 { + t.Errorf("чужой токен: %d", st) + } + if st, body := scrape("awgm_good"); st != 200 || body != "awg_up 1\n" { + t.Errorf("скрейп: %d %q", st, body) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8b4d125..f32d1ec 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -184,6 +184,8 @@ func New() *CLI { {"NODE_PORT", env("NODE_PORT", "")}, // IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY. {"RELAY_FROM", env("RELAY_FROM", "")}, + // Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP. + {"TRUST_PROXY", env("TRUST_PROXY", "")}, // Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес. {"MOVED_IN_AT", env("MOVED_IN_AT", "")}, // Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM. @@ -211,6 +213,8 @@ func New() *CLI { {"CLI_ENV_FILE", cliEnv}, {"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")}, {"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")}, + // Prometheus на ноде (`monitor install`): здесь он скрейпит метрики панели. + {"AGENT_METRICS_PORT", env("AGENT_METRICS_PORT", "")}, }, pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"), }, @@ -249,6 +253,18 @@ func New() *CLI { return c } +// setSvcEnv — значение, записанное в cli.env уже после запуска CLI, для следующего start. +func (c *CLI) setSvcEnv(name, key, value string) { + s := c.svc[name] + for i := range s.env { + if s.env[i][0] == key { + s.env[i][1] = value + return + } + } + s.env = append(s.env, [2]string{key, value}) +} + func (c *CLI) panelMoved() bool { return strings.ToLower(os.Getenv("PANEL_MOVED")) == "on" } func without(s []string, v string) []string { diff --git a/internal/cli/migrate.go b/internal/cli/migrate.go index b3dd34a..e86cbb3 100644 --- a/internal/cli/migrate.go +++ b/internal/cli/migrate.go @@ -219,6 +219,14 @@ func (c *CLI) migrateImport(file string) { errorf(" Не удалось записать %s: %v", c.envFile, err) os.Exit(1) } + // Подписка и хаб переезжают на порты старого сервера — их надо открыть, как install.sh. + opened, failed := migrate.OpenFirewall(plan.SubPort, plan.NodePort) + if len(opened) > 0 { + fmt.Printf(" %s UFW: открыты %s\n", green("✓"), strings.Join(opened, " ")) + } + if len(failed) > 0 { + errorf(" UFW: не удалось открыть %s — откройте вручную: ufw allow <порт>/tcp", strings.Join(failed, " ")) + } c.restartFresh() // Сертификат хаба приехал в архиве, а нет — awg-ui создаёт его при старте. diff --git a/internal/cli/monitor.go b/internal/cli/monitor.go index 2ff3b0d..61d388f 100644 --- a/internal/cli/monitor.go +++ b/internal/cli/monitor.go @@ -4,6 +4,8 @@ package cli // cli/src/monitor.ts). Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не // ставит, она только просит (через awg-ctrl) или показывает эту команду. Всё слушает // 127.0.0.1: наружу Prometheus не торчит, а панель читает его по HTTP на той же машине. +// На ноде без своей панели и скрейп, и чтение идут через агента: метрики панели он берёт у +// core по WSS (AGENT_METRICS_PORT), а запросы вкладки «Монитор» пропускает к Prometheus. import ( "crypto/rand" @@ -11,6 +13,8 @@ import ( "encoding/hex" "fmt" "io" + "math/big" + "net" "net/http" "os" "os/exec" @@ -121,7 +125,8 @@ func copyFile(src, dst string) { } } -func writePromConfig(uiPort string) { +// writePromConfig — target: порт панели на этой машине или агента ноды. +func writePromConfig(target string) { if exists(promConf) { copyFile(promConf, promConf+".bak-"+backupStamp()) } @@ -135,7 +140,7 @@ scrape_configs: scheme: http metrics_path: /metrics `+authBlock()+` static_configs: - - targets: ['127.0.0.1:`+uiPort+`'] + - targets: ['127.0.0.1:`+target+`'] `), 0o644) } @@ -172,8 +177,22 @@ func (c *CLI) monitorInstall() { if !reDebian.MatchString(osFamily()) { mfail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).") } - uiPort := os.Getenv("UI_PORT") - if uiPort == "" { + // Нода без своей панели (в том числе сервер, с которого панель переехала): метрики панели + // Prometheus получает от агента, а тот — от core. + viaAgent := c.isNode && !contains(c.all, "ui") + target, newPort := os.Getenv("UI_PORT"), false + if viaAgent { + if target = os.Getenv("AGENT_METRICS_PORT"); target == "" { + if target = freeLocalPort(); target == "" { + mfail("Не нашлось свободного порта для агента") + } + if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "AGENT_METRICS_PORT", target)); err != nil { + mfail("Не удалось записать " + c.envFile + ": " + err.Error()) + } + c.setSvcEnv("agent", "AGENT_METRICS_PORT", target) + newPort = true + } + } else if target == "" { mfail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.") } // Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг. @@ -195,7 +214,12 @@ func (c *CLI) monitorInstall() { mlog("2/4 токен и конфиг") writePromToken() - writePromConfig(uiPort) + writePromConfig(target) + if newPort { + mlog("агент отдаёт метрики панели на 127.0.0.1:" + target + " — перезапуск агента") + c.stop("agent") + c.start("agent") + } mlog("3/4 слушаем только " + promListen + ", хранение " + promRetention) setPromArgs() @@ -208,6 +232,9 @@ func (c *CLI) monitorInstall() { for range 30 { if promReady() { fmt.Printf("\n ✓ Prometheus работает на %s, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n\n", promListen) + if viaAgent { + fmt.Print(" Панель читает его через агента этой ноды.\n\n") + } return } time.Sleep(time.Second) @@ -215,6 +242,22 @@ func (c *CLI) monitorInstall() { mfail("Prometheus не ответил за 30 с: journalctl -u prometheus") } +// freeLocalPort — свободный порт из того же диапазона, что у install.sh (32768–55535). +func freeLocalPort() string { + for range 50 { + n, err := rand.Int(rand.Reader, big.NewInt(55535-32768+1)) + if err != nil { + return "" + } + p := strconv.Itoa(32768 + int(n.Int64())) + if l, err := net.Listen("tcp", "127.0.0.1:"+p); err == nil { + l.Close() + return p + } + } + return "" +} + func yesNo(b bool, yes, no string) string { if b { return yes diff --git a/internal/migrate/firewall.go b/internal/migrate/firewall.go new file mode 100644 index 0000000..27f642f --- /dev/null +++ b/internal/migrate/firewall.go @@ -0,0 +1,41 @@ +package migrate + +import ( + "os/exec" + "strconv" + "strings" +) + +// ufw — вызов ufw (в тестах подменяется). Нет ufw — ошибка, и правила не трогаются. +var ufw = func(args ...string) ([]byte, error) { + if _, err := exec.LookPath("ufw"); err != nil { + return nil, err + } + return exec.Command("ufw", args...).CombinedOutput() +} + +// OpenFirewall — открыть в UFW порты, на которые панель переезжает при приёме: подписка и хаб +// берут порты старого сервера, а install.sh открыл только те, что были при установке. Без этого +// нода #0 не дозвонится до хаба, а ретранслятор — до подписки. UFW не установлен или не активен — +// ничего не делаем (как install.sh). opened — что открыто, failed — что не удалось. +func OpenFirewall(ports ...int) (opened, failed []string) { + out, err := ufw("status") + if err != nil || !strings.Contains(strings.ToLower(string(out)), "status: active") { + return nil, nil + } + for _, p := range ports { + if p <= 0 { + continue + } + rule := strconv.Itoa(p) + "/tcp" + if _, err := ufw("allow", rule); err != nil { + failed = append(failed, rule) + } else { + opened = append(opened, rule) + } + } + if len(opened) > 0 { + ufw("reload") + } + return opened, failed +} diff --git a/internal/migrate/migrate_test.go b/internal/migrate/migrate_test.go index c530242..1a02c94 100644 --- a/internal/migrate/migrate_test.go +++ b/internal/migrate/migrate_test.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "errors" "os" "path/filepath" "regexp" @@ -174,3 +175,31 @@ func TestCodeAndSeal(t *testing.T) { t.Error("открылся повреждённый") } } + +// Приём переноса открывает новые порты в UFW, только если он активен, — как install.sh. +func TestOpenFirewall(t *testing.T) { + var calls []string + status := "Status: active" + defer func(f func(...string) ([]byte, error)) { ufw = f }(ufw) + ufw = func(args ...string) ([]byte, error) { + calls = append(calls, strings.Join(args, " ")) + if args[0] == "status" { + return []byte(status), nil + } + if len(args) > 1 && args[1] == "45000/tcp" { + return nil, errors.New("exit 1") + } + return nil, nil + } + opened, failed := OpenFirewall(40222, 45000, 0) + if strings.Join(opened, ",") != "40222/tcp" || strings.Join(failed, ",") != "45000/tcp" { + t.Errorf("opened %v failed %v", opened, failed) + } + if strings.Join(calls, "; ") != "status; allow 40222/tcp; allow 45000/tcp; reload" { + t.Errorf("вызовы: %v", calls) + } + calls, status = nil, "Status: inactive" + if opened, _ := OpenFirewall(40222); opened != nil || len(calls) != 1 { + t.Errorf("UFW выключен, а правила трогаются: %v", calls) + } +} diff --git a/internal/ui/migrate.go b/internal/ui/migrate.go index 406b870..c033987 100644 --- a/internal/ui/migrate.go +++ b/internal/ui/migrate.go @@ -280,6 +280,10 @@ func (m *Migration) importHandler(c *Ctx) bool { if err := envfile.Set(m.envFile, plan.Env); err != nil { return fail(err) } + // Подписка и хаб переезжают на порты старого сервера — их надо открыть, как install.sh. + if opened, failed := migrate.OpenFirewall(plan.SubPort, plan.NodePort); len(opened)+len(failed) > 0 { + logf("migrate: UFW открыт %v, не открылись %v", opened, failed) + } join := migrate.JoinString(host, plan.NodePort, pin, 0, secret, plan.UIPort, plan.SubPort) m.state = moveImported logf("migrate: панель «%s» принята с %s — перезапуск", b.Str("server_name"), c.remoteIP()) diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index 011a7f2..29d51c0 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -2,10 +2,12 @@ package ui // Вкладка «Монитор» (порт awg-ui/monitor.ts): встроенный Prometheus и основные графики. // GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки -// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root) +// POST /ui/monitor/install — попросить awg-ctrl выполнить `awg-ctrl monitor install` (root) // POST /ui/monitor/remove — то же для `awg-ctrl monitor remove` // GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч / 7 дн / 30 дн // Панель сама пакетов не ставит и в Prometheus ходит только фиксированными запросами. +// Prometheus — на этой машине или на ноде (панель без VPN, например после переноса): тогда и +// запросы к нему, и его скрейп метрик панели идут через агента ноды по её WSS. import ( "errors" @@ -80,8 +82,22 @@ type Monitor struct { promURL string localEnabled bool http *http.Client + + srcMu sync.Mutex + src *promSource // найденный Prometheus; nil — ещё не искали или не нашли + srcAt time.Time } +// promSource — где Prometheus: local — на этой машине, иначе на ноде node (через её агента). +type promSource struct { + local bool + node int +} + +// Найденный Prometheus помним недолго; ненайденный ищем при каждом запросе (вкладка +// запрашивает статус редко, а во время установки должна увидеть его сразу). +const promSourceTTL = 30 * time.Second + func newMonitor(a *App) *Monitor { return &Monitor{ a: a, promURL: envOr("PROM_URL", "http://127.0.0.1:9090"), @@ -92,14 +108,72 @@ func newMonitor(a *App) *Monitor { var errProm = errors.New("Prometheus не отвечает") -func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, error) { - q := url.Values{} - q.Set("query", query) - q.Set("start", strconv.FormatInt(start, 10)) - q.Set("end", strconv.FormatInt(end, 10)) - q.Set("step", strconv.FormatInt(step, 10)) +// source — свой Prometheus, если он есть; иначе первый по id сервер, чей агент отдаёт свой. +func (m *Monitor) source() *promSource { + m.srcMu.Lock() + if m.src != nil && time.Since(m.srcAt) < promSourceTTL { + s := m.src + m.srcMu.Unlock() + return s + } + m.srcMu.Unlock() + s := m.findSource() + m.srcMu.Lock() + m.src, m.srcAt = s, time.Now() + m.srcMu.Unlock() + return s +} + +// forgetSource — Prometheus поставили, удалили или он перестал отвечать: искать заново. +func (m *Monitor) forgetSource() { + m.srcMu.Lock() + m.src = nil + m.srcMu.Unlock() +} + +func (m *Monitor) findSource() *promSource { + if m.promReady() { + return &promSource{local: true} + } + ids := m.a.nodes.IDs() + ready := make([]bool, len(ids)) + var wg sync.WaitGroup + for i, id := range ids { + // Сервер 0 со своим awg-ctrl — эта же машина: её Prometheus уже проверен выше. + if id == 0 && m.localEnabled || !m.a.nodes.IsOnline(id) { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + r, err := m.a.nodes.CtrlFor(id)("GET", "/prom/-/ready", nil) + ready[i] = err == nil && r.Status == 200 + }() + } + wg.Wait() + for i, id := range ids { + if ready[i] { + return &promSource{node: id} + } + } + return nil +} + +// promGet — JSON-ответ Prometheus (path с запросом): напрямую или через агента ноды. +func (m *Monitor) promGet(src *promSource, path string, q url.Values) (any, error) { + if src == nil { + return nil, errProm + } + path += "?" + q.Encode() + if !src.local { + r, err := m.a.nodes.CtrlFor(src.node)("GET", "/prom"+path, nil) + if err != nil || r.Status < 200 || r.Status >= 300 { + return nil, errProm + } + return r.Data, nil + } c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second} - resp, err := c.Get(m.promURL + "/api/v1/query_range?" + q.Encode()) + resp, err := c.Get(m.promURL + path) if err != nil { return nil, errProm } @@ -108,8 +182,21 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, errProm } + return decodeLikeAxios(raw), nil +} + +func (m *Monitor) rangeQuery(src *promSource, query string, start, end, step int64) (*series, error) { + q := url.Values{} + q.Set("query", query) + q.Set("start", strconv.FormatInt(start, 10)) + q.Set("end", strconv.FormatInt(end, 10)) + q.Set("step", strconv.FormatInt(step, 10)) + data, err := m.promGet(src, "/api/v1/query_range", q) + if err != nil { + return nil, err + } out := &series{m: map[int]*seriesOne{}} - for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) { + for _, r := range arr(field(data, "data", "result")) { metric := field(r, "metric") idf := jsToNumberAny(field(metric, "server_id")) if field(metric, "server_id") == nil { @@ -146,22 +233,16 @@ func (m *Monitor) rangeQuery(query string, start, end, step int64) (*series, err } // instantQuery — значение на момент time по каждому server_id. -func (m *Monitor) instantQuery(query string, time_ int64) (map[int]float64, error) { +func (m *Monitor) instantQuery(src *promSource, query string, time_ int64) (map[int]float64, error) { q := url.Values{} q.Set("query", query) q.Set("time", strconv.FormatInt(time_, 10)) - c := &http.Client{Transport: m.http.Transport, Timeout: 8 * time.Second} - resp, err := c.Get(m.promURL + "/api/v1/query?" + q.Encode()) + data, err := m.promGet(src, "/api/v1/query", q) if err != nil { - return nil, errProm - } - defer resp.Body.Close() - raw, err := io.ReadAll(resp.Body) - if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, errProm + return nil, err } out := map[int]float64{} - for _, r := range arr(field(decodeLikeAxios(raw), "data", "result")) { + for _, r := range arr(field(data, "data", "result")) { metric := field(r, "metric") if field(metric, "server_id") == nil { continue @@ -228,12 +309,43 @@ type monitorJob struct { log string } -// jobState — установка возможна, только если рядом свой awg-ctrl и он знает маршрут. -func (m *Monitor) jobState() monitorJob { - if !m.localEnabled { +// target — чей awg-ctrl ставит и удаляет Prometheus: того сервера, где он уже стоит; если его +// нет — свой (панель + VPN) или выбранный в сервер-баре (панель без VPN). -1 — некому: например, +// Prometheus на машине панели без VPN поставлен руками. +func (m *Monitor) target(src *promSource, qs url.Values) int { + switch { + case src != nil && !src.local: + return src.node + case m.localEnabled: + return 0 + case src != nil: + return -1 + } + if f := jsToNumber(strings.Join(qs["server"], ",")); f == float64(int(f)) && f >= 0 && m.a.nodes.Exists(int(f)) { + return int(f) + } + return -1 +} + +// host — сервер target, если это не машина панели (её вкладка так и называет: «этот сервер»). +func (m *Monitor) host(target int) any { + if target < 0 || target == 0 && m.localEnabled { + return nil + } + for _, s := range m.a.nodes.List() { + if s.ID == target { + return jsjson.Object{{Key: "id", Val: s.ID}, {Key: "name", Val: s.Name}} + } + } + return nil +} + +// jobState — установка возможна, только если у target есть awg-ctrl и он знает маршрут. +func (m *Monitor) jobState(target int) monitorJob { + if target < 0 { return monitorJob{} } - r, err := m.a.ctrlLocal("GET", "/api/monitor/job", nil) + r, err := m.a.nodes.CtrlFor(target)("GET", "/api/monitor/job", nil) if err != nil || r.Status != 200 { return monitorJob{} } @@ -251,27 +363,26 @@ func (m *Monitor) jobState() monitorJob { func (m *Monitor) router() *Router { rt := newRouter() rt.Get("/", func(c *Ctx) bool { - var ready bool - var job monitorJob - var wg sync.WaitGroup - wg.Add(2) - go func() { defer wg.Done(); ready = m.promReady() }() - go func() { defer wg.Done(); job = m.jobState() }() - wg.Wait() + src := m.source() + ready := src != nil + target := m.target(src, c.R.URL.Query()) + job := m.jobState(target) var jobOut any if job.can && truthy(job.action) { jobOut = jsjson.Object{{Key: "action", Val: job.action}, {Key: "running", Val: job.running}, {Key: "exit", Val: job.exit}, {Key: "log", Val: job.log}} } return c.JSON(200, jsjson.Object{{Key: "ready", Val: ready}, {Key: "can_install", Val: job.can}, {Key: "job", Val: jobOut}, - {Key: "command", Val: "awg-ctrl monitor install"}}) + {Key: "command", Val: "awg-ctrl monitor install"}, {Key: "host", Val: m.host(target)}}) }) - // Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root. + // Установка и удаление — одно и то же: просим awg-ctrl сервера, он запускает CLI от root. ask := func(action string) handler { return func(c *Ctx) bool { - if !m.localEnabled { - return c.Error(409, "У этой панели нет своего VPN-сервера: выполните команду на машине") + target := m.target(m.source(), c.R.URL.Query()) + if target < 0 { + return c.Error(409, "Некому поставить Prometheus: выберите VPN-сервер или выполните команду на машине") } - r, err := m.a.ctrlLocal("POST", "/api/monitor/"+action, nil) + m.forgetSource() + r, err := m.a.nodes.CtrlFor(target)("POST", "/api/monitor/"+action, nil) if err != nil { return c.Error(502, "awg-ctrl недоступен") } @@ -294,6 +405,7 @@ func (m *Monitor) router() *Router { if !ok { return c.Error(400, "range: 1h, 6h, 24h, 7d или 30d") } + src := m.source() end := now() start := end - rg.seconds window := promRateWindow @@ -310,18 +422,21 @@ func (m *Monitor) router() *Router { var wg sync.WaitGroup for i, pq := range promQueries { wg.Add(1) - go func() { defer wg.Done(); got[i], errs[i] = m.rangeQuery(withWindow(pq.q, window), start, end, rg.step) }() + go func() { + defer wg.Done() + got[i], errs[i] = m.rangeQuery(src, withWindow(pq.q, window), start, end, rg.step) + }() } span := strconv.FormatInt(rg.seconds, 10) + "s" n := len(promQueries) wg.Add(2) go func() { defer wg.Done() - usedDown, errs[n] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end) + usedDown, errs[n] = m.instantQuery(src, `sum by (server_id, server) (increase(awg_link_transmit_bytes_total[`+span+`]))`, end) }() go func() { defer wg.Done() - usedUp, errs[n+1] = m.instantQuery(`sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end) + usedUp, errs[n+1] = m.instantQuery(src, `sum by (server_id, server) (increase(awg_link_receive_bytes_total[`+span+`]))`, end) }() if window != promRateWindow { for j, k := range rated { @@ -332,7 +447,7 @@ func (m *Monitor) router() *Router { wg.Add(1) go func() { defer wg.Done() - v, err := m.instantQuery(pq.q, end) + v, err := m.instantQuery(src, pq.q, end) errs[n+2+j] = err mu.Lock() nowVals[k] = v @@ -344,6 +459,7 @@ func (m *Monitor) router() *Router { wg.Wait() for _, err := range errs { if err != nil { + m.forgetSource() return c.Error(502, err.Error()) } } diff --git a/internal/ui/monitor_test.go b/internal/ui/monitor_test.go new file mode 100644 index 0000000..f02986c --- /dev/null +++ b/internal/ui/monitor_test.go @@ -0,0 +1,143 @@ +package ui + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/maeneko/forgetting/internal/agent" +) + +func freePort(t *testing.T) int { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// Панель без VPN, Prometheus — на ноде #0 (сервер, с которого панель переехала): вкладка +// читает его через агента, а сам он скрейпит метрики панели у агента, тот — у core. +func TestPrometheusOnNodeZero(t *testing.T) { + if _, err := exec.LookPath("openssl"); err != nil { + t.Skip("нет openssl для сертификата хаба") + } + dir := t.TempDir() + hubPort := freePort(t) + t.Setenv("NODE_TLS_KEY_FILE", filepath.Join(dir, "node_tls.key")) + t.Setenv("NODE_TLS_CERT_FILE", filepath.Join(dir, "node_tls.crt")) + a := testApp(t, dir, "off") + t.Setenv("NODE_PORT", strconv.Itoa(hubPort)) + var err error + if a.nodes, err = newNodes(a); err != nil { + t.Fatal(err) + } + if a.metrics, err = newMetrics(a); err != nil { + t.Fatal(err) + } + + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + der, _ := x509.MarshalPKCS8PrivateKey(priv) + keyFile := filepath.Join(dir, "node.key") + os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600) + if _, err := a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (0, 'VPN', ?, 1)", b64url.EncodeToString(pub)); err != nil { + t.Fatal(err) + } + + ctrl := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/metrics": + io.WriteString(w, `{"up":true,"peers":{"total":2,"online":1}}`) + case "/health": + io.WriteString(w, `{"status":"ok","server":"VPN"}`) + default: + w.WriteHeader(404) + } + })) + defer ctrl.Close() + prom := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/-/ready": + io.WriteString(w, "Prometheus Server is Ready.\n") + case "/api/v1/query_range": + io.WriteString(w, `{"status":"success","data":{"resultType":"matrix","result":[{"metric":{"server":"VPN","server_id":"0"},"values":[[100,"3"],[115,"4"]]}]}}`) + default: + w.WriteHeader(400) + } + })) + defer prom.Close() + + a.nodes.Start() + metricsPort := freePort(t) + hashFile := filepath.Join(dir, "monitor_token.sha256") + sum := sha256.Sum256([]byte("awgm_node")) + os.WriteFile(hashFile, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644) + ag, err := agent.New(agent.Config{ + CoreHost: "127.0.0.1", CorePort: hubPort, CorePin: b64url.EncodeToString(a.nodes.pin), NodeID: 0, + Ctrl: ctrl.URL, NodeKeyFile: keyFile, InternalKey: filepath.Join(dir, "none"), + PromURL: prom.URL, MetricsPort: metricsPort, MonitorHash: hashFile, + Logf: t.Logf, Errorf: t.Logf, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + go ag.Run(ctx) + for !a.nodes.IsOnline(0) { + if ctx.Err() != nil { + t.Fatal("нода #0 не подключилась") + } + time.Sleep(20 * time.Millisecond) + } + + m := newMonitor(a) + m.promURL = "http://127.0.0.1:1" // своего Prometheus у панели нет + src := m.source() + if src == nil || src.local || src.node != 0 { + t.Fatalf("Prometheus не найден на ноде #0: %+v", src) + } + s, err := m.rangeQuery(src, "awg_peers_online", 100, 115, 15) + if err != nil || len(s.ids) != 1 || s.m[0].name != "VPN" || len(s.m[0].points) != 2 || s.m[0].points[1][1] != 4 { + t.Fatalf("query_range через агента: %+v %v", s, err) + } + if h := m.host(m.target(src, nil)); h == nil { + t.Error("вкладка не знает, на каком сервере Prometheus") + } + + req, _ := http.NewRequest("GET", "http://127.0.0.1:"+strconv.Itoa(metricsPort)+"/metrics", nil) + req.Header.Set("Authorization", "Bearer awgm_node") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 || !strings.Contains(string(body), `awg_peers_online{server="VPN",server_id="0"} 1`) { + t.Errorf("скрейп через агента: %d\n%s", resp.StatusCode, body) + } + // Второй скрейп сразу же — отказ: каждый опрашивает все серверы. + resp, err = http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode != 429 { + t.Errorf("частый скрейп: %d", resp.StatusCode) + } + } +} diff --git a/internal/ui/nodes.go b/internal/ui/nodes.go index 1cf9c15..50f3d28 100644 --- a/internal/ui/nodes.go +++ b/internal/ui/nodes.go @@ -43,8 +43,10 @@ const ( rpcTimeout = 15 * time.Second rpcSlow = 120 * time.Second // /awg/*: рестарт и upgrade идут долго hubMaxFrame = 4 * 1024 * 1024 - failWindow = 15 * time.Minute - failMaxHits = 20 + // Метрики для Prometheus на ноде: каждый ответ опрашивает все серверы — не чаще этого. + metricsEvery = 5 * time.Second + failWindow = 15 * time.Minute + failMaxHits = 20 ) var ( @@ -110,6 +112,9 @@ type nodeConn struct { alive bool pending map[int64]*pending closed bool + // Запрос метрик от ноды (Prometheus на ней): идёт ли сейчас и когда начат последний. + metricsBusy bool + metricsAt time.Time } type deleteVeto struct { @@ -585,6 +590,10 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { } continue } + if m.Get2("t") == "metrics" && isInt(m.Get2("id")) { + go n.nodeMetrics(conn, m.Get2("id")) + continue + } if m.Get2("t") == "res" && isInt(m.Get2("id")) { rid := int64(m.Get2("id").(float64)) conn.mu.Lock() @@ -608,6 +617,29 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { } } +// nodeMetrics — Prometheus на ноде скрейпит метрики панели через её агента: та же экспозиция, +// что /metrics ядра, со всеми серверами (docs/node-protocol.md, «Мониторинг на ноде»). +func (n *Nodes) nodeMetrics(c *nodeConn, id any) { + c.mu.Lock() + t := time.Now() + busy := c.metricsBusy || t.Sub(c.metricsAt) < metricsEvery + if !busy { + c.metricsBusy, c.metricsAt = true, t + } + c.mu.Unlock() + res := jsjson.Object{{Key: "t", Val: "metrics_res"}, {Key: "id", Val: id}} + if busy { + res = append(res, jsjson.KV{Key: "status", Val: 429}, jsjson.KV{Key: "text", Val: "слишком часто\n"}) + } else { + text := n.a.metrics.render() + c.mu.Lock() + c.metricsBusy = false + c.mu.Unlock() + res = append(res, jsjson.KV{Key: "status", Val: 200}, jsjson.KV{Key: "text", Val: text}) + } + c.ws.Write(context.Background(), websocket.MessageText, jsjson.MustMarshal(res)) +} + // Start поднимает хаб (если настроен) и периодические ping/health. func (n *Nodes) Start() { if n.local { diff --git a/internal/ui/server.go b/internal/ui/server.go index 55fea2e..ef8e754 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" "os" + "path" "path/filepath" "regexp" "sort" @@ -908,7 +909,9 @@ func (a *App) static(c *Ctx) bool { if st, err := os.Stat(f); err == nil && !hidden { if st.IsDir() { if !strings.HasSuffix(c.R.URL.Path, "/") { - http.Redirect(c.W, c.R, c.R.URL.Path+"/", http.StatusMovedPermanently) + // Относительный адрес: за прокси в подпапке абсолютный увёл бы из неё. + c.W.Header().Set("Location", path.Base(c.R.URL.Path)+"/") + c.W.WriteHeader(http.StatusMovedPermanently) return false } f = filepath.Join(f, "index.html") diff --git a/internal/ui/trustproxy.go b/internal/ui/trustproxy.go new file mode 100644 index 0000000..f3ca673 --- /dev/null +++ b/internal/ui/trustproxy.go @@ -0,0 +1,88 @@ +package ui + +// Панель за reverse-proxy (nginx и т. п., в корне домена или в подпапке): соединение приходит от +// прокси, а адрес клиента — в X-Forwarded-For / X-Real-IP. Иначе все клиенты были бы одним IP и +// делили бы лимиты по адресу (неудачные входы, регистрации). Заголовкам верим только от прокси: +// loopback (прокси на этой же машине) всегда, остальные адреса и сети — из TRUST_PROXY. От всех +// прочих заголовки игнорируются — подделать свой адрес клиенту нечем. + +import ( + "net" + "os" + "strconv" + "strings" + "sync" +) + +// trustedProxies — TRUST_PROXY: IP и сети (CIDR) через запятую; loopback доверен без него. +var trustedProxies = sync.OnceValue(func() []*net.IPNet { return parseTrustProxy(os.Getenv("TRUST_PROXY")) }) + +func parseTrustProxy(v string) []*net.IPNet { + var out []*net.IPNet + for _, s := range strings.Split(v, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if !strings.Contains(s, "/") { + if ip := net.ParseIP(s); ip != nil { + bits := 128 + if ip.To4() != nil { + bits = 32 + } + s = ip.String() + "/" + strconv.Itoa(bits) + } + } + if _, n, err := net.ParseCIDR(s); err == nil { + out = append(out, n) + } else { + warnf("TRUST_PROXY: «%s» — не IP и не сеть, пропущено", s) + } + } + return out +} + +func isTrustedProxy(ip net.IP, nets []*net.IPNet) bool { + if ip.IsLoopback() { + return true + } + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} + +// clientIP — адрес клиента: от доверенного прокси — из X-Forwarded-For (справа налево, первый +// адрес не из доверенных: левые части клиент мог дописать сам) или X-Real-IP; иначе — сам peer. +func clientIP(peer string, h interface{ Values(string) []string }, nets []*net.IPNet) string { + ip := net.ParseIP(peer) + if ip == nil || !isTrustedProxy(ip, nets) { + return peer + } + var hops []string + for _, v := range h.Values("X-Forwarded-For") { + hops = append(hops, strings.Split(v, ",")...) + } + found := "" + for i := len(hops) - 1; i >= 0; i-- { + hop := net.ParseIP(strings.TrimSpace(hops[i])) + if hop == nil { + break + } + found = hop.String() + if !isTrustedProxy(hop, nets) { + return found + } + } + if found != "" { + return found + } + for _, v := range h.Values("X-Real-IP") { + if r := net.ParseIP(strings.TrimSpace(v)); r != nil { + return r.String() + } + } + return peer +} diff --git a/internal/ui/trustproxy_test.go b/internal/ui/trustproxy_test.go new file mode 100644 index 0000000..01b7fa9 --- /dev/null +++ b/internal/ui/trustproxy_test.go @@ -0,0 +1,57 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// За reverse-proxy адрес клиента берётся из его заголовков, но только когда соединение пришло от +// самого прокси (loopback или TRUST_PROXY): напрямую подставить себе чужой IP нельзя. +func TestClientIP(t *testing.T) { + nets := parseTrustProxy("10.0.0.0/8, 192.0.2.7, мусор") + for _, c := range []struct { + name, peer string + xff []string + real, want string + }{ + {"напрямую, заголовки игнорируются", "203.0.113.5", []string{"1.2.3.4"}, "1.2.3.4", "203.0.113.5"}, + {"nginx на этой машине", "127.0.0.1", []string{"203.0.113.5"}, "", "203.0.113.5"}, + {"IPv6 loopback", "::1", []string{"2001:db8::5"}, "", "2001:db8::5"}, + {"клиент дописал себе адрес слева", "127.0.0.1", []string{"6.6.6.6, 203.0.113.5"}, "", "203.0.113.5"}, + {"цепочка доверенных прокси", "10.1.1.1", []string{"203.0.113.5, 10.2.2.2", "192.0.2.7"}, "", "203.0.113.5"}, + {"только X-Real-IP", "127.0.0.1", nil, "203.0.113.9", "203.0.113.9"}, + {"прокси без заголовков", "127.0.0.1", nil, "", "127.0.0.1"}, + {"мусор в X-Forwarded-For", "127.0.0.1", []string{"unknown"}, "203.0.113.9", "203.0.113.9"}, + {"чужой прокси не доверен", "198.51.100.1", []string{"203.0.113.5"}, "", "198.51.100.1"}, + } { + h := http.Header{} + for _, v := range c.xff { + h.Add("X-Forwarded-For", v) + } + if c.real != "" { + h.Set("X-Real-IP", c.real) + } + if got := clientIP(c.peer, h, nets); got != c.want { + t.Errorf("%s: %s, ожидали %s", c.name, got, c.want) + } + } + if len(nets) != 2 { + t.Errorf("TRUST_PROXY разобран в %d сетей", len(nets)) + } +} + +// Каталог без слэша — относительный редирект: за прокси в подпапке абсолютный увёл бы из неё. +func TestStaticRelativeRedirect(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "dist", "assets"), 0o755) + os.WriteFile(filepath.Join(dir, "dist", "assets", "index.html"), []byte("x"), 0o644) + a := &App{baseDir: dir} + w := httptest.NewRecorder() + a.static(&Ctx{W: w, R: httptest.NewRequest("GET", "/assets", nil)}) + if w.Code != 301 || w.Header().Get("Location") != "assets/" { + t.Errorf("%d %q", w.Code, w.Header().Get("Location")) + } +} diff --git a/internal/ui/web.go b/internal/ui/web.go index f0c65a5..b1d3e27 100644 --- a/internal/ui/web.go +++ b/internal/ui/web.go @@ -212,12 +212,13 @@ func (c *Ctx) Obj() jsjson.Object { } // remoteIP — req.socket.remoteAddress. +// remoteIP — адрес клиента; за доверенным reverse-proxy — из его заголовков (trustproxy.go). func (c *Ctx) remoteIP() string { host, _, err := net.SplitHostPort(c.R.RemoteAddr) if err != nil { return "unknown" } - return host + return clientIP(host, c.R.Header, trustedProxies()) } // hostname — req.hostname в Express (trust proxy выключен): Host без порта, у IPv6 — в скобках.