Compare commits
3
Commits
v0.4.5
...
eb9fd700cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb9fd700cb | ||
|
|
96c21408e6 | ||
|
|
7ee126af30 |
@@ -33,6 +33,14 @@
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
|
||||
```
|
||||
|
||||
Не от root — через `sudo bash -c`: `sudo bash <(curl …)` падает с
|
||||
`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор
|
||||
с текстом скрипта.
|
||||
|
||||
```bash
|
||||
sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)"
|
||||
```
|
||||
|
||||
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
|
||||
старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить
|
||||
на 2.0 можно так:
|
||||
@@ -49,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 <PORT>/tcp`) — порты
|
||||
подписки (`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты
|
||||
и ноды ходят на них напрямую, не через nginx.
|
||||
|
||||
## API
|
||||
|
||||
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` —
|
||||
@@ -151,7 +187,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
||||
|
||||
| Метод | Ответ |
|
||||
|---|---|
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.5" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.6" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
||||
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-agent",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.6",
|
||||
"private": true,
|
||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.6",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 }); });
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -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) {
|
||||
<span className="chip chip--offline"><span className="chip-dot" />не подключён</span>
|
||||
</div>
|
||||
<p className="mon-hint">
|
||||
Панель поставит Prometheus на этот сервер и сама подключит его к своим метрикам: основные графики —
|
||||
Панель поставит Prometheus {status.host ? <>на сервер <b>{status.host.name}</b></> : 'на этот сервер'} и сама подключит его к своим метрикам: основные графики —
|
||||
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает
|
||||
только 127.0.0.1, наружу он не открывается.
|
||||
</p>
|
||||
@@ -188,7 +189,10 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mon-hint">Выполните на сервере от root (у этой панели нет своего awg-ctrl, который мог бы сделать это сам):</p>
|
||||
<p className="mon-hint">
|
||||
У этой панели нет своего VPN-сервера: выберите ноду в сервер-баре, чтобы поставить Prometheus на неё
|
||||
кнопкой, или выполните на ней от root:
|
||||
</p>
|
||||
<code className="mon-code">{status.command}</code>
|
||||
<div className="mon-actions">
|
||||
<button className="btn btn--tonal" onClick={() => copyText(status.command).then(() => showMsg('Команда скопирована'))}>
|
||||
@@ -210,8 +214,8 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
|
||||
<span className="chip chip--online"><span className="chip-dot" />работает</span>
|
||||
</div>
|
||||
<p className="mon-hint">
|
||||
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет
|
||||
пакет и сотрёт накопленные данные.
|
||||
Стоит {status.host ? <>на сервере <b>{status.host.name}</b> (панель читает его через агента ноды)</> : 'на этом сервере'} и
|
||||
слушает только 127.0.0.1:9090, графики хранит 31 день. Удаление остановит его, снимет пакет и сотрёт накопленные данные.
|
||||
</p>
|
||||
{status.can_install ? (
|
||||
<div className="mon-actions">
|
||||
@@ -222,7 +226,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mon-hint">Выполните на сервере от root:</p>
|
||||
<p className="mon-hint">Выполните на сервере с Prometheus от root:</p>
|
||||
<code className="mon-code">awg-ctrl monitor remove</code>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.6",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
|
||||
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||
# При бампе версии правь оба места.
|
||||
VERSION="0.4.5"
|
||||
VERSION="0.4.6"
|
||||
CHANNEL="${CHANNEL:-Beta}"
|
||||
|
||||
# Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник
|
||||
|
||||
+176
-6
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 создаёт его при старте.
|
||||
|
||||
+48
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
+155
-39
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-2
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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 — в скобках.
|
||||
|
||||
Reference in New Issue
Block a user