From 63e84e0df77db50290c4cd28dab760b84dea77fb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:26:07 +0000 Subject: [PATCH 01/12] Random I1 and MTU on install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I1 becomes a DNS query to a random popular domain (A/AAAA) instead of the shared icloud.com response every key carried. The format sticks to tags both the kernel module and amneziawg-go accept and is checked before awg1.conf is written: a bad I1 fails the whole awg-quick up. With KEEP_DATA=y the old I1 is kept, or generated if missing — the server never checks it, old keys keep their own. MTU is picked from 1320–1376 (AWG_MTU overrides) and kept on reinstall. awg-ctrl now reads MTU from awg1.conf for vpn:// keys instead of a constant. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- install.sh | 64 +++++++++++++++++++++++++++++++++++++---- internal/ctrl/awg.go | 2 ++ internal/ctrl/params.go | 22 ++++++++++++++ internal/ctrl/server.go | 2 +- 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index cc055a2..6ed4501 100644 --- a/install.sh +++ b/install.sh @@ -151,7 +151,7 @@ NODE_KEY_FILE="$AWG_DIR/node.key" IFACE="awg1" AWG_PORT="${AWG_PORT:-}" # UDP для клиентов; пусто — выбирает choose_awg_params SUBNET="10.9" -MTU="1376" +MTU="${AWG_MTU:-}" # MTU туннеля; пусто — выбирает choose_awg_params # Единый бинарник: сервисы (ctrl/ui/agent) и CLI (start/stop/status/…). Команда awg-ctrl — # ссылка на него. @@ -357,7 +357,7 @@ fi DB_EXISTS="n" [[ -f "$DB_FILE" ]] && DB_EXISTS="y" -# Параметры обфускации и UDP-порт AWG. На новой установке — случайные, у каждого сервера +# Параметры обфускации, UDP-порт и MTU AWG. На новой установке — случайные, у каждого сервера # свои: известные всем значения по умолчанию — готовая сигнатура для блокировки. При KEEP_DATA=y # берутся из прежнего awg1.conf (choose_awg_params ниже): они зашиты во все выданные vpn:// # ключи, их смена сделала бы каждый ключ невалидным. @@ -371,10 +371,17 @@ DB_EXISTS="n" # - S4 добавляется к каждому пакету данных — держим маленьким, как прежние 14 # - H1–H4: непересекающиеся диапазоны в [10^8, 2^31−1] — далеко от 1–4 (типы сообщений # WireGuard) и не выше прежних значений (некоторые клиенты разбирают их как int32) -# - I1–I5 НЕ трогать: в vpn:// ключ уходят пустыми плейсхолдерами +# - I1 — пакет-маскировка, его шлёт инициатор handshake, принимающая сторона просто +# отбрасывает. Сервер его не сверяет: у каждого ключа может быть свой, поэтому при +# KEEP_DATA=y без I1 в конфиге его можно сгенерировать заново — старые ключи не сломаются. +# Формат обязан пройти И kernel-модуль (сервер), И amneziawg-go (клиенты) — см. AWG_I1_RE +# - I2–I5 НЕ трогать: в vpn:// ключ уходят пустыми плейсхолдерами +# - MTU 1320–1376: не выше прежнего (запас под S4, заголовки и IPv6 на пути), не ниже — +# лишние накладные расходы. Расхождение MTU сервера и клиента WireGuard переносит # Прежние постоянные значения: ими собраны ключи установок до рандомизации — нужны, если # при KEEP_DATA=y awg1.conf потерялся. LEGACY_AWG_PORT="47619" +LEGACY_MTU="1376" LEGACY_JC=6; LEGACY_JMIN=10; LEGACY_JMAX=50 LEGACY_S1=90; LEGACY_S2=45; LEGACY_S3=37; LEGACY_S4=14 LEGACY_H1="1224800044-2116730834" @@ -401,6 +408,34 @@ gen_awg_params() { H1=${r[0]}; H2=${r[1]}; H3=${r[2]}; H4=${r[3]} } +# Синтаксис I1, который принимают обе реализации AmneziaWG: +# - только теги , , , , . не знает amneziawg-go (клиент не +# применит ключ), // не знает kernel-модуль; +# - ровно ОДИН пробел между именем тега и аргументом: модуль делит по одиночному пробелу; +# - у префикс 0x обязателен, hex — чётной длины; +# - текст вне <…> обе молча выкидывают — не пишем. +# Ошибка формата роняет весь `awg-quick up`, а модуль говорит лишь «Invalid argument». +AWG_I1_RE='^(|<(r|rc|rd) [1-9][0-9]{0,3}>|)+$' + +# Домены для I1: популярные, чтобы запрос выглядел обычным. +I1_DOMAINS=(google.com youtube.com yandex.ru vk.com mail.ru ok.ru apple.com icloud.com + microsoft.com cloudflare.com wikipedia.org github.com) + +# I1 — DNS-запрос (его шлёт клиент, так что запрос правдоподобнее ответа): — ID, новый +# в каждом пакете; флаги 0100 (стандартный, RD), 1 вопрос; случайный домен, A или AAAA, класс IN. +# 27–36 байт — меньше любого handshake-пакета (самый короткий, cookie, ≥ 79): по длине не путается. +gen_awg_i1() { + local d label qname="" qtype labels + d=${I1_DOMAINS[$(rand_int 0 $(( ${#I1_DOMAINS[@]} - 1 )))]} + [[ $(rand_int 0 1) == 1 ]] && d="www.$d" + IFS=. read -ra labels <<< "$d" + for label in "${labels[@]}"; do + qname+=$(printf '%02x' "${#label}")$(printf '%s' "$label" | od -An -tx1 | tr -d ' \n') + done + qtype=$([[ $(rand_int 0 2) == 0 ]] && echo 001c || echo 0001) + I1="" +} + legacy_awg_params() { JC=$LEGACY_JC; JMIN=$LEGACY_JMIN; JMAX=$LEGACY_JMAX S1=$LEGACY_S1; S2=$LEGACY_S2; S3=$LEGACY_S3; S4=$LEGACY_S4 @@ -416,8 +451,9 @@ gen_awg_port() { done } -# Порт и обфускация: при KEEP_DATA=y — из прежнего awg1.conf (все J/S/H разом или ни одного: -# смешивать с новыми нельзя), иначе случайные. AWG_PORT из окружения важнее всего. +# Порт, MTU и обфускация: при KEEP_DATA=y — из прежнего awg1.conf (все J/S/H разом или ни +# одного: смешивать с новыми нельзя), иначе случайные. AWG_PORT и AWG_MTU из окружения +# важнее всего. choose_awg_params() { local k prev_port="" # || true: без файла sed падает, а с pipefail и set -e это молча роняло бы установку. @@ -436,12 +472,27 @@ choose_awg_params() { prev_port=$(conf_val ListenPort) [[ -z "$prev_port" ]] && prev_port=$(prev_val SERVER_PORT) AWG_PORT="${AWG_PORT:-${prev_port:-$LEGACY_AWG_PORT}}" + MTU="${MTU:-$(conf_val MTU)}" + MTU="${MTU:-$LEGACY_MTU}" + I1=$(conf_val I1) + if [[ -n "$I1" && ! "$I1" =~ $AWG_I1_RE ]]; then + warn "I1 из $AWG_CONF не пройдёт парсер AmneziaWG — генерируем новый" + I1="" + fi + # Конфиги до рандомизации I1 не содержат: ключи брали общий из awg-ctrl. Новый + # безопасен — сервер I1 не сверяет, у старых ключей останется прежний. + [[ -z "$I1" ]] && gen_awg_i1 else gen_awg_params + gen_awg_i1 AWG_PORT="${AWG_PORT:-$(gen_awg_port)}" + MTU="${MTU:-$(rand_int 1320 1376)}" fi [[ "$AWG_PORT" =~ ^[0-9]+$ && "$AWG_PORT" -gt 0 && "$AWG_PORT" -lt 65536 ]] \ || fail "AWG_PORT «${AWG_PORT}» — не номер порта" + [[ "$MTU" =~ ^[0-9]+$ && "$MTU" -ge 1280 && "$MTU" -le 1500 ]] \ + || fail "MTU «${MTU}» — нужно число 1280–1500" + [[ "$I1" =~ $AWG_I1_RE ]] || fail "I1 «${I1}» не пройдёт парсер AmneziaWG" } # Поколение протокола и закрепление версии пакета — управляются переменными @@ -842,7 +893,7 @@ echo -e " ${DIM}Роль ${NC} ${BLD}$ROLE_LABEL${NC}" echo -e " ${DIM}Адрес ${NC} $SERVER_IP ${DIM}· интерфейс $NET_IFACE${NC}" if [[ "$ROLE" != "core" ]]; then echo -e " ${DIM}Имя сервера ${NC} $SERVER_NAME" - echo -e " ${DIM}VPN ${NC} ${AWG_PORT}/udp" + echo -e " ${DIM}VPN ${NC} ${AWG_PORT}/udp ${DIM}· MTU ${MTU}${NC}" fi if [[ "$ROLE" != "node" ]]; then echo -e " ${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER${NC}" @@ -1247,6 +1298,7 @@ H1 = ${H1} H2 = ${H2} H3 = ${H3} H4 = ${H4} +I1 = ${I1} CONF [[ -n "$AWG3_LINES" ]] && printf '%s\n' "$AWG3_LINES" >> "$AWG_CONF" diff --git a/internal/ctrl/awg.go b/internal/ctrl/awg.go index 6d80be2..c7d41f1 100644 --- a/internal/ctrl/awg.go +++ b/internal/ctrl/awg.go @@ -344,8 +344,10 @@ func (s *Server) syncPeers() error { // vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1. func (s *Server) reloadAwgParams() string { p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) }) + mtu := ReadMTU(s.confPath()) s.cfgMu.Lock() s.c.Params = p + s.c.MTU = mtu s.c.Gen = p.Gen() s.c.Keepalive = keepaliveByGen[s.c.Gen] g := s.c.Gen diff --git a/internal/ctrl/params.go b/internal/ctrl/params.go index cb9d967..a88f459 100644 --- a/internal/ctrl/params.go +++ b/internal/ctrl/params.go @@ -70,6 +70,28 @@ var AWG31Defaults = [][2]string{ {"MaxHandshakeAttempts", "15-20"}, } +// DefaultMTU — MTU установок до рандомизации; запасное, если в awg1.conf его нет или оно странное. +const DefaultMTU = 1376 + +// ReadMTU читает MTU из [Interface] awg1.conf: install.sh выбирает его случайно, у каждого +// сервера своё, и клиенты получают его в vpn:// ключе. +func ReadMTU(confFile string) int { + raw, err := os.ReadFile(confFile) + if err != nil { + return DefaultMTU + } + iface := pPeerSplit.Split(string(raw), 2)[0] + m := regexp.MustCompile(`(?m)^\s*MTU\s*=\s*(\d+)`).FindStringSubmatch(iface) + if m == nil { + return DefaultMTU + } + n, _ := strconv.Atoi(m[1]) + if n < 1280 || n > 1500 { + return DefaultMTU + } + return n +} + // При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12. const minSForHeaderProtection = 12 diff --git a/internal/ctrl/server.go b/internal/ctrl/server.go index cafd40a..212c07e 100644 --- a/internal/ctrl/server.go +++ b/internal/ctrl/server.go @@ -150,7 +150,7 @@ func New() (*Server, error) { module, tools := readVersions() s.c = Config{ ServerIP: ip, ServerPort: sp, ServerName: name, - DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: 1376, Subnet: "10.9", + DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: ReadMTU(s.confPath()), Subnet: "10.9", Keepalive: keepaliveByGen[gen], Params: params, Gen: gen, AwgModule: module, AwgTools: tools, } From aa848de31c45cc58f51f778dfb6a679333a3a47c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:30:40 +0000 Subject: [PATCH 02/12] Hide the brand from unauthenticated requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /ui/brand answered without auth with {"brand":"Forgetting",…}: one request was enough for a scanner to tell a found port is this panel. It now requires the panel JWT (Go and the Node reference alike). The login screen says just «Вход», the page title is neutral until login, and the wordmark loads after sign-in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 2 +- awg-ui/index.html | 2 +- awg-ui/server.ts | 6 +++--- awg-ui/src/App.tsx | 13 ++++++++----- internal/ui/server.go | 5 +++-- tools/diffbench/scenario-ui.ts | 1 + 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fd9ce47..899db98 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \ | Метод | Ответ | |---|---| -| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.7" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина | +| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.7" }` — требует JWT панели (без авторизации выдавал бы панель сканерам). Вордмарк в шапке после входа | | `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | diff --git a/awg-ui/index.html b/awg-ui/index.html index 53a3a4a..0fea124 100644 --- a/awg-ui/index.html +++ b/awg-ui/index.html @@ -3,7 +3,7 @@ - AWG Panel + Вход
diff --git a/awg-ui/server.ts b/awg-ui/server.ts index ed2fc4a..0eb3a22 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -206,9 +206,9 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => { res.json({ ok: true }); }); -// Единственный роут без авторизации: вордмарк нужен уже на экране логина, -// до выдачи JWT. Наружу уходит только имя продукта и версия. -app.get("/ui/brand", (_req: Request, res: Response) => { +// За авторизацией: без неё сканер одним GET узнавал бы, что на порту панель Forgetting. +// Экран логина обходится без вордмарка. +app.get("/ui/brand", requireAuth, (_req: Request, res: Response) => { res.json({ brand: BRAND, channel: CHANNEL, version: VERSION }); }); diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index 666fa92..d75c953 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -185,17 +185,20 @@ export default function App() { return () => clearInterval(t); }, [token, loadNodes]); - // Имя продукта и версия — с сервера, до логина (роут без авторизации). - // Если не ответил, остаётся фолбэк из констант. + // Имя продукта и версия — с сервера, только после логина: роут за авторизацией, чтобы + // панель не опознавалась сканером. Если не ответил, остаётся фолбэк из констант. useEffect(() => { - axios.get('/ui/brand') + if (!token) return; + axios.get('/ui/brand', { headers: { Authorization: `Bearer ${token}` } }) .then(({ data }) => setBrand({ brand: data.brand || BRAND_FALLBACK, channel: data.channel || '', version: data.version || '', })) .catch(() => { /* остаётся фолбэк */ }); - }, []); + }, [token]); + + useEffect(() => { document.title = token ? brand.brand : 'Вход'; }, [token, brand.brand]); // Применять и сохранять тему useEffect(() => { @@ -249,7 +252,7 @@ export default function App() { {!token ? (
- {brand.brand} + Вход

Войдите, чтобы продолжить

Date: Wed, 28 Oct 2026 11:41:09 +0000 Subject: [PATCH 03/12] Secret URL path for the panel The panel port is open to the internet, and its login page and JS bundle gave it away to any scanner in one GET. The installer now generates a random UI_PREFIX: from outside the panel answers only under http://IP:PORT// and gives Go's plain "404 page not found" to everything else. Loopback needs no prefix (CLI, local Prometheus, nginx on the same host); while a move code is out, POST /migrate/v1/import is let through without it, since the code carries no path. A reinstall with kept data keeps the prefix; UI_PREFIX=off or a custom value can be set before running the installer. A moved panel brings its prefix in UI_PREFIX_MOVED, so old bookmarks and API clients keep working through the relay. awg-ctrl status and credentials show the full panel address. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 18 +++++++++ install.sh | 32 ++++++++++++++- internal/cli/cli.go | 25 ++++++++++++ internal/cli/cmds.go | 3 ++ internal/cli/migrate.go | 8 +++- internal/migrate/migrate.go | 11 +++++ internal/ui/migrate.go | 14 ++++++- internal/ui/prefix.go | 81 +++++++++++++++++++++++++++++++++++++ internal/ui/prefix_test.go | 57 ++++++++++++++++++++++++++ internal/ui/server.go | 20 ++++----- 10 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 internal/ui/prefix.go create mode 100644 internal/ui/prefix_test.go diff --git a/README.md b/README.md index 899db98..f4bc909 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,24 @@ AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgettin `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика (подробности — в комментариях `install.sh` и в `.env`). +### Секретный путь панели + +Установщик выдаёт панели случайный путь: она открывается по `http://IP:PORT/<путь>/`, а на +любой другой адрес отвечает `404 page not found`, как пустой веб-сервер. Порт панели открыт +в интернет, и без пути сканер узнавал её по странице логина. Путь хранится в `UI_PREFIX` +в `/opt/awg-control/cli/cli.env`; полный адрес показывают `awg-ctrl status` и +`awg-ctrl credentials`. Внешнему API нужен тот же путь: `http://HOST:PORT/<путь>/api/v1/…` +(в примерах ниже `HOST:PORT` — вместе с путём). + +- При переустановке с сохранением данных путь остаётся прежним. Свой путь — + `UI_PREFIX=<8–64 символа A–Z a–z 0–9 _ ->`, без пути — `UI_PREFIX=off` перед запуском + установщика (или правка `cli.env` и `awg-ctrl restart`). +- С этой же машины (loopback) путь не нужен: CLI, локальный Prometheus и nginx ниже + ходят как раньше. +- При переносе панели (вкладка «Перенос», `awg-ctrl migrate`) новая панель принимает и свой + путь, и путь старой (`UI_PREFIX_MOVED`) — закладки и API через ретранслятор продолжают + работать. Пока выдан код переноса, `POST /migrate/v1/import` принимается и без пути. + ### Панель за nginx Панель работает и в корне домена, и в любой подпапке — пути в ней относительные, diff --git a/install.sh b/install.sh index 6ed4501..427d8f9 100644 --- a/install.sh +++ b/install.sh @@ -804,6 +804,29 @@ fi [[ "$ROLE" != "core" ]] && choose_awg_params +if [[ "$ROLE" != "node" ]]; then + # Секретный путь панели: снаружи она отвечает только на http://IP:PORT/<путь>/, на остальное — + # 404, как пустой сервер. Без него сканер узнавал панель по странице логина одним GET. + # При переустановке путь прежний; UI_PREFIX=off — без пути, UI_PREFIX=<свой> — свой + # (8–64 символа A–Z a–z 0–9 _ -). + UI_PREFIX="${UI_PREFIX:-}" + UI_PREFIX_NEW="n" + if [[ "$UI_PREFIX" == "off" ]]; then + UI_PREFIX="" + elif [[ -z "$UI_PREFIX" ]]; then + [[ "$KEEP_DATA" == "y" ]] && UI_PREFIX="$(prev_val UI_PREFIX)" + if [[ -z "$UI_PREFIX" ]]; then + UI_PREFIX=$(rand_alnum 16) + [[ -n "$PREV_CLI_ENV" ]] && UI_PREFIX_NEW="y" + fi + fi + [[ -z "$UI_PREFIX" || "$UI_PREFIX" =~ ^[A-Za-z0-9_-]{8,64}$ ]] \ + || fail "UI_PREFIX «${UI_PREFIX}» — нужно 8–64 символа A–Z a–z 0–9 _ -" + # Путь переехавшей сюда панели (migrate) — её закладкам и API через ретранслятор. + UI_PREFIX_MOVED="" + [[ "$KEEP_DATA" == "y" ]] && UI_PREFIX_MOVED="$(prev_val UI_PREFIX_MOVED)" +fi + # Нода: к какой панели подключаться. Строку awgjoin://… даёт панель («Добавить сервер»): # base64url от JSON {v, h, p, pin, n, s}. Разбираем здесь, до установки, чтобы опечатка не # обнаружилась через десять минут сборки модуля. Значения в строке ограничены @@ -1498,6 +1521,8 @@ cat </…, на всё +// остальное — как пустой Go-сервер (`404 page not found`). Порт панели открыт в интернет, и без +// префикса сканер узнавал её по странице логина и JS-бандлу — одним GET. +// +// UI_PREFIX_MOVED — префикс панели, переехавшей сюда (`migrate`): её закладки и внешний API +// приходят через ретранслятор старого сервера со своим путём. Принимаются оба. +// +// С loopback префикс не нужен: CLI, Prometheus этой машины (127.0.0.1:PORT/metrics) и nginx +// на этой же машине ходят по-старому (nginx в подпапке и есть свой секретный путь). Адрес — +// соединения, не X-Forwarded-For: заголовок подделывается. + +import ( + "net" + "net/http" + "os" + "strings" + + "github.com/maeneko/forgetting/internal/migrate" +) + +// uiPrefixes — UI_PREFIX и UI_PREFIX_MOVED из окружения; пустые и кривые пропускаются. +func uiPrefixes() []string { + var out []string + for _, k := range []string{"UI_PREFIX", "UI_PREFIX_MOVED"} { + p := strings.Trim(strings.TrimSpace(os.Getenv(k)), "/") + switch { + case p == "": + case !migrate.ValidUIPrefix(p): + warnf("ui: %s «%s» не подходит (8–64 символа A–Z a–z 0–9 _ -) — пропущен", k, p) + default: + out = append(out, p) + } + } + return out +} + +func isLoopback(remoteAddr string) bool { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// prefixGate пропускает в панель только запросы под префиксом и срезает его: дальше роутер +// видит те же пути, что без префикса. allow — исключения вне префикса (приём переноса). +func prefixGate(prefixes []string, next http.Handler, allow func(*http.Request) bool) http.Handler { + if len(prefixes) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + for _, px := range prefixes { + root := "/" + px + if p == root { + // Относительный адрес, как у static: за прокси в подпапке абсолютный увёл бы из неё. + w.Header().Set("Location", px+"/") + w.WriteHeader(http.StatusMovedPermanently) + return + } + if strings.HasPrefix(p, root+"/") { + r2 := r.Clone(r.Context()) + r2.URL.Path = p[len(root):] + if r.URL.RawPath != "" { + r2.URL.RawPath = strings.TrimPrefix(r.URL.RawPath, root) + } + r2.RequestURI = r2.URL.RequestURI() + next.ServeHTTP(w, r2) + return + } + } + if isLoopback(r.RemoteAddr) || allow != nil && allow(r) { + next.ServeHTTP(w, r) + return + } + http.NotFound(w, r) + }) +} diff --git a/internal/ui/prefix_test.go b/internal/ui/prefix_test.go new file mode 100644 index 0000000..1506691 --- /dev/null +++ b/internal/ui/prefix_test.go @@ -0,0 +1,57 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPrefixGate(t *testing.T) { + var got string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.URL.Path + w.WriteHeader(200) + }) + allow := func(r *http.Request) bool { return r.Method == "POST" && r.URL.Path == "/migrate/v1/import" } + h := prefixGate([]string{"s3cretPath", "0ldPanel_x"}, next, allow) + + cases := []struct { + method, remote, path string + code int + seen, location string + }{ + {"GET", "203.0.113.5:4000", "/", 404, "", ""}, + {"GET", "203.0.113.5:4000", "/ui/brand", 404, "", ""}, + {"GET", "203.0.113.5:4000", "/s3cretPathX/", 404, "", ""}, + {"GET", "203.0.113.5:4000", "/s3cretPath", 301, "", "s3cretPath/"}, + {"GET", "203.0.113.5:4000", "/s3cretPath/", 200, "/", ""}, + {"GET", "203.0.113.5:4000", "/s3cretPath/assets/a.js", 200, "/assets/a.js", ""}, + {"GET", "203.0.113.5:4000", "/0ldPanel_x/api/v1/users", 200, "/api/v1/users", ""}, + {"POST", "203.0.113.5:4000", "/migrate/v1/import", 200, "/migrate/v1/import", ""}, + {"GET", "203.0.113.5:4000", "/migrate/v1/import", 404, "", ""}, + {"GET", "127.0.0.1:4000", "/metrics", 200, "/metrics", ""}, + {"GET", "[::1]:4000", "/", 200, "/", ""}, + } + for _, c := range cases { + got = "" + r := httptest.NewRequest(c.method, c.path, nil) + r.RemoteAddr = c.remote + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != c.code || got != c.seen || w.Header().Get("Location") != c.location { + t.Errorf("%s %s от %s: %d, роутер видит %q, Location %q; ждали %d, %q, %q", + c.method, c.path, c.remote, w.Code, got, w.Header().Get("Location"), c.code, c.seen, c.location) + } + if c.code == 404 && w.Body.String() != "404 page not found\n" { + t.Errorf("%s: тело 404 не как у пустого Go-сервера: %q", c.path, w.Body.String()) + } + } + + got = "" + r := httptest.NewRequest("GET", "/ui/brand", nil) + r.RemoteAddr = "203.0.113.5:4000" + prefixGate(nil, next, nil).ServeHTTP(httptest.NewRecorder(), r) + if got != "/ui/brand" { + t.Errorf("без префикса запрос должен доходить до роутера как есть, дошёл %q", got) + } +} diff --git a/internal/ui/server.go b/internal/ui/server.go index 3b8a6cd..462aa89 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -91,6 +91,7 @@ func decodeLikeAxios(raw []byte) any { type App struct { port int + prefixes []string // UI_PREFIX, UI_PREFIX_MOVED: секретные пути панели (prefix.go) uiUser string uiPass string jwtSecret []byte @@ -131,14 +132,15 @@ func New() (*App, error) { return def } a := &App{ - port: envNum("PORT"), - uiUser: envOr("UI_USER", "admin"), - uiPass: os.Getenv("UI_PASS"), - brand: pick("BRAND", "Forgetting"), - channel: pick("CHANNEL", "Beta"), - version: pick("VERSION", ""), - ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"), - baseDir: cwd, + port: envNum("PORT"), + prefixes: uiPrefixes(), + uiUser: envOr("UI_USER", "admin"), + uiPass: os.Getenv("UI_PASS"), + brand: pick("BRAND", "Forgetting"), + channel: pick("CHANNEL", "Beta"), + version: pick("VERSION", ""), + ctrlURL: "http://127.0.0.1:" + envOr("AWGCTRL_PORT", "3005"), + baseDir: cwd, // awg-ctrl на loopback: системный прокси сюда не нужен. httpLocal: &http.Client{Transport: &http.Transport{Proxy: nil}}, revoked: map[string]bool{}, @@ -950,7 +952,7 @@ func (a *App) Run() error { if err != nil { return err } - srv := &http.Server{Handler: a.routes(), ReadHeaderTimeout: 30 * time.Second} + srv := &http.Server{Handler: prefixGate(a.prefixes, a.routes(), a.migration.awaitsImport), ReadHeaderTimeout: 30 * time.Second} logf("ui: listening on :%d", a.port) a.nodes.Start() a.sub.Start() From 619af210851723a2dd050a5e720cbe75ffe7c90a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:45:48 +0000 Subject: [PATCH 04/12] Full uninstall from the installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a server with an existing installation the menu gets a third choice, «Удалить»; UNINSTALL=y runs it without the menu. After typing «удалить» it offers a backup of /etc/amnezia and cli.env to /root, then removes the services and the systemd unit, brings awg1 down, drops the shaping and torrent nft tables, closes the ports it had opened in UFW, firewalld or iptables, removes the Prometheus that `awg-ctrl monitor install` set up (only ours), and deletes /opt/awg-control, /etc/amnezia, the awg-ctrl command, the sysctl file and the install logs, which hold the panel password. AmneziaWG packages, module and PPA go only on a separate yes. kill_related moved above the menu so the uninstall can use it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 17 +++++ install.sh | 212 ++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 195 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index f4bc909..7299735 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,23 @@ AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgettin `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика (подробности — в комментариях `install.sh` и в `.env`). +### Удаление + +Тот же установщик: на сервере с установкой — пункт **3 «Удалить»**, без меню — +`UNINSTALL=y` (подтверждение «удалить» тогда не спрашивается): + +```bash +UNINSTALL=y bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh) +``` + +Удаляются сервисы и юнит systemd, интерфейс `awg1`, nft-таблицы шейпинга и блокировки +торрентов, открытые установщиком порты (UFW, firewalld или iptables), Prometheus из +`awg-ctrl monitor install`, `/opt/awg-control`, `/etc/amnezia` (пользователи, ключи, базы), +команда `awg-ctrl`, sysctl-файл и логи установки (в них пароль панели). Перед этим установщик +предлагает сохранить копию данных в `/root/forgetting-backup-<время>.tar.gz`. AmneziaWG +(пакеты, модуль, PPA) — по отдельному вопросу; общие зависимости (dkms, build-essential, +iptables, заголовки ядра) остаются. Ноду после удаления уберите и из списка серверов панели. + ### Секретный путь панели Установщик выдаёт панели случайный путь: она открывается по `http://IP:PORT/<путь>/`, а на diff --git a/install.sh b/install.sh index 427d8f9..cbcca46 100644 --- a/install.sh +++ b/install.sh @@ -163,19 +163,55 @@ echo -e " ${DIM}Панель управления VPN на AmneziaWG — ус echo -e " ${DIM}Лог: $LOGFILE${NC}" rule +# Сначала корректно через CLI (по PID-файлам), затем добиваем всё, что ещё +# держит файлы проекта: осиротевшие процессы, ручной запуск или stale PID, +# которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе сервисы +# продолжат работать с уже удалёнными файлами и порт/интерфейс останутся занятыми. +kill_related() { + if [[ -x "$BIN" ]]; then + "$BIN" stop all 2>/dev/null || true + else + # Установка до Go-версии: её CLI на tsx. + local _tsx="$PROJECT/cli/node_modules/.bin/tsx" + local _cli="$PROJECT/cli/src/index.ts" + [[ -x "$_tsx" && -f "$_cli" ]] && "$_tsx" "$_cli" stop all 2>/dev/null || true + fi + + # cmdline всех сервисов (awg-control <роль>, у старых — tsx …) содержит путь проекта. + if command -v pkill &>/dev/null; then + pkill -TERM -f "$PROJECT/" 2>/dev/null || true + sleep 1 + pkill -KILL -f "$PROJECT/" 2>/dev/null || true + else + local _pids + _pids=$(ps -eo pid=,args= | awk -v p="$PROJECT/" 'index($0,p){print $1}') + [[ -n "$_pids" ]] && kill -TERM $_pids 2>/dev/null || true + sleep 1 + _pids=$(ps -eo pid=,args= | awk -v p="$PROJECT/" 'index($0,p){print $1}') + [[ -n "$_pids" ]] && kill -KILL $_pids 2>/dev/null || true + fi + + rm -f /tmp/awg-ctrl.pid /tmp/awg-ui.pid /tmp/awg-agent.pid 2>/dev/null || true +} + # Поверх существующей установки: обновить (настройки, пользователи и роль остаются) # или переустановить с нуля. Спрашиваем первым — от ответа зависит, спрашивать ли роль. +# Или удалить совсем (3; без меню — UNINSTALL=y bash install.sh). INSTALL_MODE="fresh" -if [[ -d "$PROJECT" ]]; then +if [[ "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then + INSTALL_MODE="uninstall" +elif [[ -d "$PROJECT" ]]; then echo warn "Уже установлено в $PROJECT" echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}" echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}" - ask INST_CHOICE "Выбери [1/2, Enter — 1]: " + echo -e " ${ACC}${BLD}3${NC} Удалить ${DIM}полностью: сервисы, данные, правила фаервола, по желанию AmneziaWG${NC}" + ask INST_CHOICE "Выбери [1–3, Enter — 1]: " case "${INST_CHOICE:-1}" in 1) INSTALL_MODE="update" ;; 2) INSTALL_MODE="fresh" ;; - *) fail "Неверный выбор: введи 1 или 2" ;; + 3) INSTALL_MODE="uninstall" ;; + *) fail "Неверный выбор: введи 1, 2 или 3" ;; esac echo fi @@ -186,6 +222,145 @@ fi PREV_CLI_ENV="" [[ -f "$PROJECT/cli/cli.env" ]] && PREV_CLI_ENV=$(cat "$PROJECT/cli/cli.env") prev_val() { grep "^$1=" <<< "$PREV_CLI_ENV" | tail -1 | cut -d= -f2- || true; } +# ── Полное удаление ───────────────────────────────────────────────────────── +# Убирает всё, что ставили установщик и awg-ctrl: сервисы и юнит, интерфейс awg1, nft-таблицы +# шейпинга и блокировки торрентов, открытые порты в UFW/firewalld/iptables, Prometheus из +# `awg-ctrl monitor install`, каталог проекта, /etc/amnezia (пользователи, ключи, базы), sysctl, +# логи установки (в них пароль панели). AmneziaWG (пакеты, модуль, PPA) — по вопросу: он мог +# стоять и до нас. Общие зависимости (dkms, build-essential, iptables, заголовки) не трогаем. +uninstall_all() { + local role="${PREV_ROLE_HINT:-}" conf="$AWG_CONF" net_iface="" p + local awg_port ports=() + local unit="/etc/systemd/system/awg-control.service" + + step "Удаление ${BRAND}" + echo -e " ${DIM}Будут удалены сервисы, $PROJECT, $AMNEZIA_DIR (пользователи, ключи, базы),${NC}" + echo -e " ${DIM}юнит systemd, правила фаервола и логи установки. Выданные ключи перестанут работать.${NC}" + case "$role" in + node) echo -e " ${DIM}Это нода: после удаления убери сервер и в списке серверов панели.${NC}" ;; + *) [[ -n "$(prev_val NODE_PORT)" ]] && \ + echo -e " ${DIM}Подключённые к этой панели ноды останутся без панели — их VPN продолжит работать.${NC}" ;; + esac + echo + if [[ ! "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then + ask CONFIRM "Для подтверждения введи «удалить»: " + [[ "$CONFIRM" == "удалить" ]] || fail "Отменено — ничего не тронуто" + fi + + # Копия данных — по умолчанию да: без неё выданные ключи не восстановить. + if [[ -d "$AMNEZIA_DIR" || -f "$PROJECT/cli/cli.env" ]]; then + ask KEEP_BACKUP "Сохранить копию данных (пользователи, ключи, cli.env) в /root? [Y/n]: " + if [[ "${KEEP_BACKUP:-y}" =~ ^[Yy]$ ]]; then + local bak="/root/forgetting-backup-$(date +%Y%m%d-%H%M%S).tar.gz" items=() + [[ -d "$AMNEZIA_DIR" ]] && items+=("${AMNEZIA_DIR#/}") + [[ -f "$PROJECT/cli/cli.env" ]] && items+=("${PROJECT#/}/cli/cli.env") + ( umask 077; tar -czf "$bak" -C / "${items[@]}" ) \ + && ok "Копия: $bak" \ + || fail "Не удалось сохранить копию — удаление отменено, ничего не тронуто" + fi + fi + + # Что открывали наружу — из прежнего конфига (порты случайные, угадать нельзя). + awg_port=$(sed -n 's/^ListenPort *= *//p' "$conf" 2>/dev/null | head -1 || true) + [[ -z "$awg_port" ]] && awg_port=$(prev_val SERVER_PORT) + [[ -n "$awg_port" ]] && ports+=("${awg_port}/udp") + for p in UI_PORT SUB_PORT NODE_PORT; do + [[ -n "$(prev_val "$p")" ]] && ports+=("$(prev_val "$p")/tcp") + done + net_iface=$(grep -oE 'POSTROUTING -o [^ ]+' "$conf" 2>/dev/null | head -1 | awk '{print $3}' || true) + + info "остановка сервисов" + if command -v systemctl &>/dev/null && [[ -f "$unit" ]]; then + run systemctl disable --now awg-control.service || true + fi + # Prometheus из `awg-ctrl monitor install` — только наш (метка в конфиге); чужой не трогаем. + if [[ -x "$BIN" ]] && grep -qs '^# managed by forgetting' /etc/prometheus/prometheus.yml; then + info "Prometheus, поставленный панелью" + run "$BIN" monitor remove || warn "Prometheus не удалён — sudo apt purge prometheus" + fi + kill_related + ok "Сервисы остановлены" + + if command -v awg &>/dev/null && awg show "$IFACE" &>/dev/null; then + # down выполняет PreDown из конфига — снимает FORWARD/MASQUERADE. + run awg-quick down "$IFACE" || ip link delete dev "$IFACE" 2>/dev/null || true + ok "Интерфейс $IFACE опущен" + fi + if command -v nft &>/dev/null; then + nft delete table inet awg_shape 2>/dev/null || true + nft delete table inet awg_torrent 2>/dev/null || true + fi + + if [[ ${#ports[@]} -gt 0 ]]; then + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then + for p in "${ports[@]}"; do run ufw delete allow "$p" || true; done + [[ -n "$net_iface" ]] && { run ufw route delete allow in on "$IFACE" out on "$net_iface" || true; } + ok "UFW: закрыты ${ports[*]}" + elif command -v firewall-cmd &>/dev/null && firewall-cmd --state &>/dev/null; then + for p in "${ports[@]}"; do run firewall-cmd --permanent --remove-port="$p" || true; done + run firewall-cmd --reload || true + ok "firewalld: закрыты ${ports[*]}" + elif command -v iptables &>/dev/null; then + local removed=0 + for p in "${ports[@]}"; do + while iptables -C INPUT -p "${p#*/}" --dport "${p%/*}" -j ACCEPT 2>/dev/null; do + iptables -D INPUT -p "${p#*/}" --dport "${p%/*}" -j ACCEPT && removed=1 + done + done + if [[ "$removed" == 1 ]]; then + command -v netfilter-persistent &>/dev/null && { run netfilter-persistent save || true; } + ok "iptables: закрыты ${ports[*]}" + fi + fi + fi + + if [[ -f /etc/systemd/system/awg-control.service ]]; then + rm -f /etc/systemd/system/awg-control.service + command -v systemctl &>/dev/null && { run systemctl daemon-reload || true; } + fi + [[ "$(readlink /usr/local/bin/awg-ctrl 2>/dev/null)" == "$BIN" ]] && rm -f /usr/local/bin/awg-ctrl + # Пути — константы из шапки; :? — страховка от пустой переменной. + rm -rf "${PROJECT:?}" "${AMNEZIA_DIR:?}" + rm -f /etc/sysctl.d/99-awg-control.conf + # Логи установок: в них пароль панели. Текущий (удаления) — без секретов, остаётся. + find /var/log -maxdepth 1 -name 'awg-install-*.log' ! -path "$LOGFILE" -delete 2>/dev/null || true + ok "Удалены $PROJECT, $AMNEZIA_DIR, юнит, команда awg-ctrl, логи установки" + + if dpkg -s amneziawg &>/dev/null || dpkg -s amneziawg-tools &>/dev/null; then + echo + ask DROP_AWG "Удалить и AmneziaWG (пакеты, модуль ядра, репозиторий)? [y/N]: " + if [[ "${DROP_AWG:-n}" =~ ^[Yy]$ ]]; then + run apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools || true + rmmod amneziawg 2>/dev/null || true + DEBIAN_FRONTEND=noninteractive run apt-get purge -y amneziawg amneziawg-dkms amneziawg-tools \ + || warn "apt-get purge завершился ошибкой — проверь: dpkg -l | grep amneziawg" + rm -f /etc/apt/sources.list.d/amnezia-ppa.list /etc/apt/keyrings/amnezia-ppa.gpg + if command -v add-apt-repository &>/dev/null \ + && grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/; then + run add-apt-repository -y --remove ppa:amnezia/ppa || true + fi + ok "AmneziaWG удалён" + else + skip "AmneziaWG оставлен" + fi + fi + + rule + echo -e " ${GRN}${BLD}✓ ${BRAND} удалён${NC}" + rule + echo -e " ${DIM}IP forwarding (sysctl) выключится после перезагрузки — сейчас: sysctl -w net.ipv4.ip_forward=0,${NC}" + echo -e " ${DIM}если он не нужен другим сервисам. Фаервол хостера (панель провайдера) — закрой порты там сам.${NC}" + echo +} + +if [[ "$INSTALL_MODE" == "uninstall" ]]; then + if [[ -n "$(prev_val CORE_HOST)" ]]; then PREV_ROLE_HINT="node" + elif [[ "$(prev_val LOCAL_NODE)" == "off" ]]; then PREV_ROLE_HINT="core" + else PREV_ROLE_HINT="standalone" + fi + uninstall_all + exit 0 +fi # Роль сервера (несколько серверов под одной панелью, docs/node-protocol.md): # standalone — панель + VPN на одной машине, как было всегда; может принимать ноды; @@ -515,37 +690,6 @@ AWG_PIN="${AWG_PIN:-y}" NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') [[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" -# Сначала корректно через CLI (по PID-файлам), затем добиваем всё, что ещё -# держит файлы проекта: осиротевшие процессы, ручной запуск или stale PID, -# которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе сервисы -# продолжат работать с уже удалёнными файлами и порт/интерфейс останутся занятыми. -kill_related() { - if [[ -x "$BIN" ]]; then - "$BIN" stop all 2>/dev/null || true - else - # Установка до Go-версии: её CLI на tsx. - local _tsx="$PROJECT/cli/node_modules/.bin/tsx" - local _cli="$PROJECT/cli/src/index.ts" - [[ -x "$_tsx" && -f "$_cli" ]] && "$_tsx" "$_cli" stop all 2>/dev/null || true - fi - - # cmdline всех сервисов (awg-control <роль>, у старых — tsx …) содержит путь проекта. - if command -v pkill &>/dev/null; then - pkill -TERM -f "$PROJECT/" 2>/dev/null || true - sleep 1 - pkill -KILL -f "$PROJECT/" 2>/dev/null || true - else - local _pids - _pids=$(ps -eo pid=,args= | awk -v p="$PROJECT/" 'index($0,p){print $1}') - [[ -n "$_pids" ]] && kill -TERM $_pids 2>/dev/null || true - sleep 1 - _pids=$(ps -eo pid=,args= | awk -v p="$PROJECT/" 'index($0,p){print $1}') - [[ -n "$_pids" ]] && kill -KILL $_pids 2>/dev/null || true - fi - - rm -f /tmp/awg-ctrl.pid /tmp/awg-ui.pid /tmp/awg-agent.pid 2>/dev/null || true -} - # Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые # файлы на месте. download_extract() { From a3d46642148b885407beaff7d98807a9589db424 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:52:55 +0000 Subject: [PATCH 05/12] Stealth sen:// port, random cert names, external Prometheus card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscription port answered every request with signed JSON: one probe told a scanner what it had found, and the public key from any sen:// link pinned the exact server. It now speaks the protocol only to those who prove a secret — peek and register with the link's master secret, everything else with a valid device signature — and gives Go's plain "404 page not found" to the rest. A deleted device still gets its 410, but only when the request is signed with its key, kept in the new revoked_devices table. The migration freeze answers only proven clients too. Self-signed certs for the subscription (SUB_TLS=on) and the node hub got fixed names (sen, awg-node); new ones get a random host-like name. Clients and nodes pin the key, so existing certs keep working. The panel could already issue a metrics-only token, but nothing in the UI used it. The Monitor tab gets an "External Prometheus" card: issue, reissue and revoke the token, with a ready prometheus.yml fragment whose address is taken from the page, so it carries the secret path or the nginx subfolder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 19 +++ .../src/tabs/monitor/ExternalPrometheus.tsx | 98 +++++++++++++ awg-ui/src/tabs/monitor/index.tsx | 2 + awg-ui/src/tabs/monitor/monitor.css | 2 + install.sh | 5 +- internal/ui/nodes.go | 17 ++- internal/ui/sub.go | 136 +++++++++++------- internal/ui/sub_stealth_test.go | 105 ++++++++++++++ 8 files changed, 331 insertions(+), 53 deletions(-) create mode 100644 awg-ui/src/tabs/monitor/ExternalPrometheus.tsx create mode 100644 internal/ui/sub_stealth_test.go diff --git a/README.md b/README.md index 7299735..2de519a 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,25 @@ iptables, заголовки ядра) остаются. Ноду после у путь, и путь старой (`UI_PREFIX_MOVED`) — закладки и API через ретранслятор продолжают работать. Пока выдан код переноса, `POST /migrate/v1/import` принимается и без пути. +### Порт подписки sen:// + +Подписка отвечает по протоколу только тем, кто доказал знание секрета: `peek`/`register` — +с секретом мастер-ключа из ссылки, остальные запросы — с подписью ключом устройства. На всё +прочее (другие пути и методы, битое тело, чужой секрет, подобранный id устройства, неверная +подпись) — `404 page not found`, как пустой веб-сервер. Удалённому устройству `410` уходит, +только если запрос подписан его ключом (они хранятся в `revoked_devices`); устройства, +удалённые до этой версии, получают тот же тихий 404. Самоподписанные сертификаты подписки +(`SUB_TLS=on`) и хаба нод создаются со случайным именем вместо общих `sen` / `awg-node`: +клиенты и ноды сверяют ключ, а не имя, так что прежние сертификаты продолжают работать. + +### Внешний Prometheus + +Вкладка «Монитор» → «Внешний Prometheus»: выдать токен «только метрики» и скопировать готовый +фрагмент `prometheus.yml`. Адрес в нём берётся от страницы панели, поэтому уже содержит +секретный путь (`metrics_path: /<путь>/metrics`) или подпапку nginx. Без пути `/metrics` +снаружи отвечает 404. Панель по голому HTTP передаёт токен и метрики открытым текстом — +внешний скрейп лучше вести через nginx с TLS или по VPN. + ### Панель за nginx Панель работает и в корне домена, и в любой подпапке — пути в ней относительные, diff --git a/awg-ui/src/tabs/monitor/ExternalPrometheus.tsx b/awg-ui/src/tabs/monitor/ExternalPrometheus.tsx new file mode 100644 index 0000000..06daf2d --- /dev/null +++ b/awg-ui/src/tabs/monitor/ExternalPrometheus.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Ivan Vasilev +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import { useState, useEffect, useCallback } from 'react'; +import { apiFetch, copyText, timeAgo } from '../../lib/shared'; +import { IcoCopy, IcoTrash } from '../../components/icons'; + +// Внешний Prometheus: свой сервер мониторинга скрейпит /metrics панели по токену «только метрики» +// (/ui/metrics-token; он не открывает ни панель, ни /api/v1). Адрес берём от страницы: в нём уже +// секретный путь панели (UI_PREFIX) или подпапка nginx, поэтому конфиг верен в обоих случаях. +interface View { exists: boolean; prefix?: string; created_at?: number; last_used?: number | null; per_peer: boolean } + +function scrapeConfig(tok: string): string { + const u = new URL('metrics', window.location.href); + return [ + 'scrape_configs:', + ' - job_name: vpn-panel', + ` scheme: ${u.protocol.replace(':', '')}`, + ` metrics_path: ${u.pathname}`, + ' authorization:', + ` credentials: ${tok}`, + ' static_configs:', + ` - targets: ['${u.host}']`, + ].join('\n'); +} + +export default function ExternalPrometheus({ token, showMsg }: { token: string; showMsg: (t: string) => void }) { + const [view, setView] = useState(null); + const [fresh, setFresh] = useState(''); // токен показываем один раз — сразу после выпуска + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { setView(await apiFetch('GET', '/ui/metrics-token', token) as View); } catch { /* карточка необязательна */ } + }, [token]); + useEffect(() => { void load(); }, [load]); + + const issue = async () => { + if (view?.exists && !confirm('Выпустить новый токен? Prometheus со старым перестанет получать метрики.')) return; + setBusy(true); + try { const r = await apiFetch('POST', '/ui/metrics-token', token); setFresh(r.token); setView(r); } + catch (e: any) { showMsg(e?.response?.data?.error ?? 'Не удалось выпустить токен'); } + finally { setBusy(false); } + }; + const revoke = async () => { + if (!confirm('Отозвать токен? Внешний Prometheus перестанет получать метрики.')) return; + setBusy(true); + try { setView(await apiFetch('DELETE', '/ui/metrics-token', token)); setFresh(''); } + catch (e: any) { showMsg(e?.response?.data?.error ?? 'Не удалось отозвать токен'); } + finally { setBusy(false); } + }; + + if (!view) return null; + const plain = window.location.protocol === 'http:'; + return ( +
+
+

Внешний Prometheus

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

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

+ {plain && ( +

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

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

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

+
{scrapeConfig(fresh)}
+
+ +
+ + )} + +
+ + {view.exists && ( + + )} +
+
+ ); +} diff --git a/awg-ui/src/tabs/monitor/index.tsx b/awg-ui/src/tabs/monitor/index.tsx index c485efc..b6045b7 100644 --- a/awg-ui/src/tabs/monitor/index.tsx +++ b/awg-ui/src/tabs/monitor/index.tsx @@ -5,6 +5,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared'; import { IcoCopy, IcoTrash } from '../../components/icons'; import LineChart from '../../components/LineChart'; +import ExternalPrometheus from './ExternalPrometheus'; import './monitor.css'; // Вкладка «Монитор»: встроенный Prometheus и основные графики. @@ -360,6 +361,7 @@ export default function MonitorPage({ token, showMsg }: PageProps) { {setup} {dashboard} {manage} + ); } diff --git a/awg-ui/src/tabs/monitor/monitor.css b/awg-ui/src/tabs/monitor/monitor.css index 6d643bc..78cc8b4 100644 --- a/awg-ui/src/tabs/monitor/monitor.css +++ b/awg-ui/src/tabs/monitor/monitor.css @@ -24,6 +24,8 @@ LICENSE file in the root directory of this source tree. border-radius: var(--r-table); padding: 12px; } +/* Многострочный фрагмент (конфиг): переносы строк сохраняются, длинные — переносятся */ +pre.mon-code { white-space: pre-wrap; margin: 0; } @media (max-width: 640px) { .mon-actions .btn { flex: 1; } diff --git a/install.sh b/install.sh index cbcca46..87b7ca3 100644 --- a/install.sh +++ b/install.sh @@ -1389,13 +1389,16 @@ if [[ "$ROLE" != "node" ]]; then chmod 600 "$SUB_SIGN_PRIV" ok "Ключ подписи sen:// создан" fi + # Имя в сертификате случайное: одинаковое у всех серверов было бы сигнатурой для сканера. + # Клиент сверяет pin ключа, а не имя. if [[ "$SUB_TLS" == "on" ]]; then if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then ok "Сертификат подписки sen:// сохранён" else ( umask 077 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ - -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=sen" 2>/dev/null ) + -keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 \ + -subj "/CN=$(rand_alnum 10 | tr 'A-Z' 'a-z').net" 2>/dev/null ) chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT" ok "Самоподписанный сертификат подписки sen:// создан (10 лет)" fi diff --git a/internal/ui/nodes.go b/internal/ui/nodes.go index 50f3d28..3d2a92b 100644 --- a/internal/ui/nodes.go +++ b/internal/ui/nodes.go @@ -153,14 +153,15 @@ func newNodes(a *App) (*Nodes, error) { } return filepath.Join(a.baseDir, name) } - // TLS: самоподписанный сертификат, pin которого зашит в join-строки. Живёт столько же, + // TLS: самоподписанный сертификат, pin которого зашит в join-строки. Имя в нём случайное: + // одинаковое у всех серверов было бы сигнатурой для сканера (pin — по ключу, не по имени). Живёт столько же, // сколько core: пересоздать = переподключить все ноды. if n.port != 0 { keyF, crtF := file("NODE_TLS_KEY_FILE", "node_tls.key"), file("NODE_TLS_CERT_FILE", "node_tls.crt") if err := func() error { if !exists(keyF) && !exists(crtF) { cmd := exec.Command("openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", - "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node") + "-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN="+randomCN()) if err := cmd.Run(); err != nil { return err } @@ -927,3 +928,15 @@ func (n *Nodes) router() *Router { }) return rt } + +// randomCN — имя для самоподписанного сертификата: похожее на обычный хост и своё у каждого +// сервера. +func randomCN() string { + const abc = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 10) + rand.Read(b) + for i := range b { + b[i] = abc[int(b[i])%len(abc)] + } + return string(b) + ".net" +} diff --git a/internal/ui/sub.go b/internal/ui/sub.go index 79aada4..3ed65ef 100644 --- a/internal/ui/sub.go +++ b/internal/ui/sub.go @@ -288,6 +288,13 @@ func (s *Sub) initSchema() error { rekey_requested INTEGER NOT NULL DEFAULT 0, UNIQUE (master_id, device_id) ); + -- Удалённые устройства: ключ авторизации, чтобы проверить подпись их опроса и ответить + -- 410 только им (stealth). Устройства, удалённые до таблицы, получают тихий 404. + CREATE TABLE IF NOT EXISTS revoked_devices ( + id INTEGER PRIMARY KEY, + auth_pub TEXT NOT NULL, + revoked_at INTEGER NOT NULL + ); -- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и -- дальше не читается (кроме переноса ниже). CREATE TABLE IF NOT EXISTS master_servers ( @@ -749,6 +756,9 @@ func (s *Sub) forgetDevice(q queryer, dev *deviceRow) []int { q.Exec("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)", l.ServerID, l.PeerPub.String) } } + // Ключ авторизации удалённого устройства — чтобы на его подписанный опрос ответить 410 + // (снять конфиг), а на подобранный id без подписи — тихим 404. + q.Exec("INSERT OR REPLACE INTO revoked_devices (id, auth_pub, revoked_at) VALUES (?, ?, ?)", dev.ID, dev.AuthPub, now()) q.Exec("DELETE FROM devices WHERE id = ?", dev.ID) // device_peers уйдут каскадом ids := []int{} for _, l := range links { @@ -903,21 +913,23 @@ func reqPath(r *http.Request) string { } // signFailure — подпись запроса и защита от повторов. "" — всё верно, иначе причина (для лога). -func (s *Sub) signFailure(c *Ctx, authPub []byte) string { +// signed — подпись сама по себе верна (провал только по времени или повтору): запрос сделан +// ключом устройства, ему можно отвечать по протоколу. Без неё — тихий 404 (stealth). +func (s *Sub) signFailure(c *Ctx, authPub []byte) (why string, signed bool) { tsF := jsToNumber(strings.Join(c.R.Header["X-Sen-Ts"], ", ")) if _, has := c.R.Header["X-Sen-Ts"]; !has { tsF = jsToNumber("x") } sig := c.R.Header.Get("X-Sen-Sig") if tsF != float64(int64(tsF)) || sig == "" { - return "no_signature" + return "no_signature", false } ts := int64(tsF) - if d := ts - now(); d > tsWindow || d < -tsWindow { - return fmt.Sprintf("ts_window (часы клиента расходятся на %d с)", d) - } if !verifyRequest(c.R.Method, reqPath(c.R), ts, c.RawBody, sig, authPub) { - return "bad_signature" + return "bad_signature", false + } + if d := ts - now(); d > tsWindow || d < -tsWindow { + return fmt.Sprintf("ts_window (часы клиента расходятся на %d с)", d), true } s.sigMu.Lock() defer s.sigMu.Unlock() @@ -928,10 +940,27 @@ func (s *Sub) signFailure(c *Ctx, authPub []byte) string { } } if _, ok := s.seenSigs[sig]; ok { - return "replay" + return "replay", true } s.seenSigs[sig] = t.Add(2 * tsWindow * time.Second) - return "" + return "", true +} + +// stealth — ответ тому, кто не доказал знание ни секрета ссылки, ни ключа устройства: как у +// пустого Go-сервера, без подписи и JSON. Иначе сканер одним запросом узнавал бы подписку (а с +// публичным ключом из любой ссылки — и конкретный сервер). Клиенту с верной ссылкой это не мешает. +func stealth(c *Ctx) bool { + http.NotFound(c.W, c.R) + return false +} + +// frozenFor — перенос панели идёт: доказавшему клиенту — подписанный 502 (он повторит позже). +func (s *Sub) frozenFor(c *Ctx) bool { + if s.frozen.Load() { + s.sendC(c, 502, subErr("unavailable")) + return true + } + return false } func (s *Sub) deviceAuth(c *Ctx) bool { @@ -942,16 +971,31 @@ func (s *Sub) deviceAuth(c *Ctx) bool { if valid { dev = s.deviceByID(s.a.db, int64(f)) } + if !valid { + return stealth(c) + } // Устройства нет — его удалили (id не переиспользуются). 410, чтобы клиент снял конфиг, и // не в лимит неудач: такие клиенты опрашивают по таймеру и выбивали бы лимит соседям за NAT. - if dev == nil && valid { + // Только если запрос подписан ключом удалённого устройства (revoked_devices): иначе на + // подобранный id сканер получал бы ответ протокола. + if dev == nil { + var authPub string + if s.a.db.QueryRow("SELECT auth_pub FROM revoked_devices WHERE id = ?", int64(f)).Scan(&authPub) != nil { + return stealth(c) + } + if _, signed := s.signFailure(c, fromB64url(authPub)); !signed { + return stealth(c) + } return s.sendC(c, 410, subErr("revoked")) } - why := "no_device" - if dev != nil { - why = s.signFailure(c, fromB64url(dev.AuthPub)) + why, signed := s.signFailure(c, fromB64url(dev.AuthPub)) + if !signed { + return stealth(c) } if why == "" { + if s.frozenFor(c) { + return false + } c.device = dev return true } @@ -959,11 +1003,7 @@ func (s *Sub) deviceAuth(c *Ctx) bool { if !s.failLimit.allow(ip) { return s.sendC(c, 429, subErr("rate_limited")) } - hdr := c.R.Header.Get("X-Sen-Device") - if !hasHdr { - hdr = "-" - } - warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), hdr, why, ip) + warnf("sub: 401 %s %s device=%s %s ip=%s", c.R.Method, reqPath(c.R), c.R.Header.Get("X-Sen-Device"), why, ip) return s.sendC(c, 401, subErr("unauthorized")) } @@ -986,10 +1026,6 @@ var errLimit = errors.New("limit") func (s *Sub) register(c *Ctx) error { ip := c.remoteIP() - if !s.registerLimit.allow(ip) { - s.sendC(c, 429, subErr("rate_limited")) - return nil - } b := c.Obj() strRe := func(k string, re *regexp.Regexp) (string, bool) { v, ok := b.Get2(k).(string) @@ -1010,7 +1046,23 @@ func (s *Sub) register(c *Ctx) error { if !ok5 { version = "" } - if !ok1 || !ok2 || !ok3 || !ok4 { + // Доказательство — секрет ссылки: без верного секрета ответа протокола нет (stealth). + var master *masterRow + if ok1 { + master = s.masterBySecret(fromB64url(secretB)) + } + if master == nil { + stealth(c) + return nil + } + if s.frozenFor(c) { + return nil + } + if !s.registerLimit.allow(ip) { + s.sendC(c, 429, subErr("rate_limited")) + return nil + } + if !ok2 || !ok3 || !ok4 { s.sendC(c, 400, subErr("bad_request")) return nil } @@ -1019,7 +1071,7 @@ func (s *Sub) register(c *Ctx) error { authPub := fromB64url(authPubS) why := "bad_auth_pub" if len(authPub) == 32 { - why = s.signFailure(c, authPub) + why, _ = s.signFailure(c, authPub) } if why != "" { warnf("sub: 401 register %s ip=%s", why, ip) @@ -1027,11 +1079,6 @@ func (s *Sub) register(c *Ctx) error { return nil } - master := s.masterBySecret(fromB64url(secretB)) - if master == nil { - s.sendC(c, 404, subErr("not_found")) - return nil - } var serverIDs []int for _, id := range s.serverIDsOf(s.a.db, master.ID) { if s.a.nodes.Exists(id) { @@ -1092,23 +1139,18 @@ func (s *Sub) register(c *Ctx) error { // peek — сколько мест у ключа занято, по секрету из ссылки (устройства ещё нет). func (s *Sub) peek(c *Ctx) error { - ip := c.remoteIP() secretB, ok := c.Obj().Get2("sub").(string) ok = ok && reSubSecret.MatchString(secretB) var master *masterRow if ok { master = s.masterBySecret(fromB64url(secretB)) } + // Секрет не тот — ответа протокола нет (stealth): иначе сканер отличал бы подписку. if master == nil { - if !s.failLimit.allow(ip) { - s.sendC(c, 429, subErr("rate_limited")) - return nil - } - if ok { - s.sendC(c, 404, subErr("not_found")) - } else { - s.sendC(c, 400, subErr("bad_request")) - } + stealth(c) + return nil + } + if s.frozenFor(c) { return nil } s.sendC(c, 200, jsjson.Object{{Key: "key_uuid", Val: master.UUID}, {Key: "devices", Val: s.deviceCount(s.a.db, master.ID)}, @@ -1199,18 +1241,12 @@ func (s *Sub) publicRouter() *Router { rt := newRouter() rt.bodyLimit = 4 * 1024 // Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять. - rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) { - s.send(w, r, 400, subErr("bad_request")) - } - rt.notFound = func(c *Ctx) { s.sendC(c, 404, subErr("not_found")) } - // Перенос панели: изменения сейчас остались бы только в уезжающей базе. 502 unavailable - // клиент понимает как «подписка недоступна» — туннель не трогает и повторит позже. - rt.Use("/", func(c *Ctx) bool { - if s.frozen.Load() { - return s.sendC(c, 502, subErr("unavailable")) - } - return true - }) + // Всё, что не доказало знание секрета ссылки или ключа устройства, получает ответ пустого + // Go-сервера (stealth): битое тело, чужой путь, чужой метод. Подписанные ответы — только + // своим. Перенос панели (frozen) — тоже только им: 502 unavailable клиент понимает как + // «подписка недоступна», туннель не трогает и повторит позже (frozenFor). + rt.badBody = func(w http.ResponseWriter, r *http.Request, _ int, _ string) { http.NotFound(w, r) } + rt.notFound = func(c *Ctx) { stealth(c) } rt.Post("/sub/v1/register", s.wrapSub(s.register)) rt.Post("/sub/v1/peek", s.wrapSub(s.peek)) rt.Get("/sub/v1/config", s.deviceAuth, s.wrapSub(s.config)) diff --git a/internal/ui/sub_stealth_test.go b/internal/ui/sub_stealth_test.go new file mode 100644 index 0000000..de07d4b --- /dev/null +++ b/internal/ui/sub_stealth_test.go @@ -0,0 +1,105 @@ +package ui + +import ( + "crypto/ed25519" + "crypto/rand" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +// Публичный листенер подписки: ответ протокола (подписанный JSON) — только тому, кто знает +// секрет ссылки или ключ устройства. Остальным — 404 пустого Go-сервера. +func TestSubStealth(t *testing.T) { + a := testApp(t, t.TempDir(), "on") + _, signKey, _ := ed25519.GenerateKey(rand.Reader) + s := &Sub{a: a, signKey: signKey, seenSigs: map[string]time.Time{}} + s.registerLimit = newLimiter(30, time.Minute, func(string) {}) + s.failLimit = newLimiter(30, time.Minute, func(string) {}) + if err := s.initSchema(); err != nil { + t.Fatal(err) + } + devPub, devKey, _ := ed25519.GenerateKey(rand.Reader) + oldPub, oldKey, _ := ed25519.GenerateKey(rand.Reader) + _, strangerKey, _ := ed25519.GenerateKey(rand.Reader) + secret := make([]byte, 16) + rand.Read(secret) + for _, q := range []struct { + sql string + args []any + }{ + {"INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'k', ?, 1)", []any{secret}}, + {"INSERT INTO devices (id, master_id, device_id, pub_key, auth_pub, created_at) VALUES (5, 1, 'd', 'WG', ?, 1)", []any{b64url.EncodeToString(devPub)}}, + {"INSERT INTO revoked_devices (id, auth_pub, revoked_at) VALUES (6, ?, 1)", []any{b64url.EncodeToString(oldPub)}}, + } { + if _, err := a.db.Exec(q.sql, q.args...); err != nil { + t.Fatalf("%s: %v", q.sql, err) + } + } + + rt := s.publicRouter() + do := func(method, path, body string, hdr map[string]string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.RemoteAddr = "203.0.113.5:4000" + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + for k, v := range hdr { + r.Header.Set(k, v) + } + w := httptest.NewRecorder() + rt.ServeHTTP(w, r) + return w + } + signed := func(key ed25519.PrivateKey, dev int, method, path string, ts int64) map[string]string { + sig := ed25519.Sign(key, []byte(requestSigString(method, path, ts, nil))) + return map[string]string{"X-Sen-Device": strconv.Itoa(dev), "X-Sen-Ts": strconv.FormatInt(ts, 10), + "X-Sen-Sig": b64url.EncodeToString(sig)} + } + stealthy := func(name string, w *httptest.ResponseRecorder) { + t.Helper() + if w.Code != 404 || w.Body.String() != "404 page not found\n" { + t.Errorf("%s: ждали тихий 404, получили %d %q", name, w.Code, w.Body.String()) + } + } + protocol := func(name string, w *httptest.ResponseRecorder, code int) { + t.Helper() + if w.Code != code || !strings.Contains(w.Body.String(), `"sig"`) { + t.Errorf("%s: ждали подписанный %d, получили %d %q", name, code, w.Code, w.Body.String()) + } + } + sec := `{"sub":"` + b64url.EncodeToString(secret) + `"}` + other := make([]byte, 16) + rand.Read(other) + now := time.Now().Unix() + + stealthy("GET /", do("GET", "/", "", nil)) + stealthy("чужой путь", do("GET", "/sub/v1/nope", "", nil)) + stealthy("чужой метод", do("GET", "/sub/v1/peek", "", nil)) + stealthy("битый JSON", do("POST", "/sub/v1/peek", "{", nil)) + stealthy("peek без секрета", do("POST", "/sub/v1/peek", `{"sub":"x"}`, nil)) + stealthy("peek чужой секрет", do("POST", "/sub/v1/peek", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil)) + stealthy("register чужой секрет", do("POST", "/sub/v1/register", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil)) + stealthy("config без заголовков", do("GET", "/sub/v1/config", "", nil)) + stealthy("config чужой подписью", do("GET", "/sub/v1/config", "", signed(strangerKey, 5, "GET", "/sub/v1/config", now))) + stealthy("config подобранный id", do("GET", "/sub/v1/config", "", signed(strangerKey, 999, "GET", "/sub/v1/config", now))) + stealthy("config удалённого чужой подписью", do("GET", "/sub/v1/config", "", signed(strangerKey, 6, "GET", "/sub/v1/config", now))) + + protocol("peek своим секретом", do("POST", "/sub/v1/peek", sec, nil), 200) + protocol("register своим секретом, без полей", do("POST", "/sub/v1/register", sec, nil), 400) + protocol("удалённое устройство своей подписью", do("GET", "/sub/v1/config", "", signed(oldKey, 6, "GET", "/sub/v1/config", now)), 410) + protocol("часы устройства уехали", do("GET", "/sub/v1/config", "", signed(devKey, 5, "GET", "/sub/v1/config", now-3600)), 401) + + s.frozen.Store(true) + stealthy("перенос: чужой", do("POST", "/sub/v1/peek", `{"sub":"`+b64url.EncodeToString(other)+`"}`, nil)) + protocol("перенос: свой", do("POST", "/sub/v1/peek", sec, nil), 502) + protocol("перенос: устройство", do("GET", "/sub/v1/devices", "", signed(devKey, 5, "GET", "/sub/v1/devices", now)), 502) + s.frozen.Store(false) + + // Удаление устройства оставляет его ключ в revoked_devices: дальше — 410 только ему. + s.forgetDeviceTx(s.deviceByID(a.db, 5)) + protocol("после удаления — своей подписью", do("GET", "/sub/v1/config", "", signed(devKey, 5, "GET", "/sub/v1/config", now+1)), 410) + stealthy("после удаления — чужой", do("GET", "/sub/v1/config", "", signed(strangerKey, 5, "GET", "/sub/v1/config", now))) +} From fd5e2cc76170645a39eb4f9cb035805af47dcecc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:58:06 +0000 Subject: [PATCH 06/12] Hub: node signature before the WebSocket upgrade The hub accepted a WebSocket from anyone on /node/v1, a path in the open code, and replied with a hello nonce: one connection was enough to tell a scanner what it had found. The agent now signs the upgrade request itself (X-Node-Id, X-Node-Ts, X-Node-Sig; on first binding also the join secret and its key), and the hub checks it before Accept. Without a valid signature it answers Go's plain "404 page not found". Inside the socket the protocol is unchanged, and the auth frame must come from the node that signed the request. Older agents send no signature. The hub lets them in while some node has never connected the new way (nodes.preauth), logs them as outdated, and closes that door by itself once every node is updated; HUB_LEGACY=on/off overrides. The timestamp window is a day: node clocks drift, and the headers travel inside pinned TLS. The agent treats a 404 at upgrade as a rejection and backs off for a minute. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 17 ++- internal/agent/agent.go | 29 ++++- internal/cli/cli.go | 2 + internal/ui/hub_preauth.go | 89 +++++++++++++ internal/ui/hub_preauth_test.go | 224 ++++++++++++++++++++++++++++++++ internal/ui/nodes.go | 45 +++++-- 6 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 internal/ui/hub_preauth.go create mode 100644 internal/ui/hub_preauth_test.go diff --git a/README.md b/README.md index 2de519a..44b99a0 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,24 @@ iptables, заголовки ядра) остаются. Ноду после у (`SUB_TLS=on`) и хаба нод создаются со случайным именем вместо общих `sen` / `awg-node`: клиенты и ноды сверяют ключ, а не имя, так что прежние сертификаты продолжают работать. +### Порт хаба нод + +Агент ноды подписывает своим ключом уже сам запрос на WebSocket (заголовки `X-Node-Id`, +`X-Node-Ts`, `X-Node-Sig`, при первой привязке — ещё секрет из строки `awgjoin://`). Хаб +проверяет подпись до апгрейда и без неё отвечает `404 page not found`, как пустой сервер: +одним подключением к `/node/v1` его больше не узнать. Агенты прежних версий подписи не шлют — +хаб пускает их, пока в панели есть нода, ни разу не подключавшаяся по-новому, и пишет об этом +в лог. Обновили все ноды — путь без подписи закрывается сам. `HUB_LEGACY=on` / `off` в +`cli.env` (и `awg-ctrl restart`) — принудительно. Часы ноды могут расходиться с панелью до +суток. + ### Внешний Prometheus -Вкладка «Монитор» → «Внешний Prometheus»: выдать токен «только метрики» и скопировать готовый +Prometheus на ноде («Подключить Prometheus» у панели без своего VPN) от секретного пути не +зависит: он забирает метрики у агента на `127.0.0.1`, а тот — у панели внутри TLS-соединения +с хабом. Снаружи этот трафик не отличить от обычной связи ноды с панелью. + +Для стороннего сервера мониторинга — вкладка «Монитор» → «Внешний Prometheus»: выдать токен «только метрики» и скопировать готовый фрагмент `prometheus.yml`. Адрес в нём берётся от страницы панели, поэтому уже содержит секретный путь (`metrics_path: /<путь>/metrics`) или подпапку nginx. Без пути `/metrics` снаружи отвечает 404. Панель по голому HTTP передаёт токен и метрики открытым текстом — diff --git a/internal/agent/agent.go b/internal/agent/agent.go index afc679e..541f28c 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -198,6 +198,26 @@ func authMsg(nonce string, nodeID int) []byte { return []byte("awg-node-v1\n" + nonce + "\n" + strconv.Itoa(nodeID)) } +// preauthMsg — подпись в заголовке запроса на WebSocket: хаб проверяет её до апгрейда и без неё +// отвечает 404, как пустой сервер (internal/ui/hub_preauth.go, там же копия). +func preauthMsg(nodeID int, ts int64) []byte { + return []byte("awg-node-pre-v1\n" + strconv.Itoa(nodeID) + "\n" + strconv.FormatInt(ts, 10)) +} + +// preauthHeader — подпись ноды (и при первой привязке — секрет с ключом) для запроса на WebSocket. +func (a *Agent) preauthHeader() http.Header { + ts := time.Now().Unix() + h := http.Header{} + h.Set("X-Node-Id", strconv.Itoa(a.c.NodeID)) + h.Set("X-Node-Ts", strconv.FormatInt(ts, 10)) + h.Set("X-Node-Sig", b64.EncodeToString(ed25519.Sign(a.nodeKey, preauthMsg(a.c.NodeID, ts)))) + if s := a.secret(); s != "" { + h.Set("X-Node-Join", s) + h.Set("X-Node-Pub", a.nodePubRaw) + } + return h +} + // ── Запросы core → awg-ctrl ──────────────────────────────────────────────── var methods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true} @@ -476,13 +496,20 @@ func (a *Agent) connectOnce(ctx context.Context, onReady func()) (authFailed boo tr := &http.Transport{Proxy: nil, TLSClientConfig: a.tlsConfig(), TLSHandshakeTimeout: handshakeTimeout} dctx, dcancel := context.WithTimeout(ctx, handshakeTimeout) - ws, _, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{ + ws, resp, err := websocket.Dial(dctx, fmt.Sprintf("wss://%s/node/v1", a.addr()), &websocket.DialOptions{ HTTPClient: &http.Client{Transport: tr}, + HTTPHeader: a.preauthHeader(), CompressionMode: websocket.CompressionDisabled, OnPingReceived: func(context.Context, []byte) bool { armDead(); return true }, }) dcancel() if err != nil { + // 404 до апгрейда — хаб не принял подпись (или IP временно заблокирован за неудачи): + // как отказ в auth, переподключение — не чаще раза в минуту. + if resp != nil && resp.StatusCode == http.StatusNotFound { + a.c.Errorf("agent: core отклонил ноду до подключения (неверный ключ, просроченная или использованная ссылка, часы ноды сильно расходятся)") + return true + } if errors.Is(err, errPin) { a.c.Errorf("agent: %v", errPin) } else { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4349633..1c70246 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -185,6 +185,8 @@ func New() *CLI { {"SUB_HOST", env("SUB_HOST", "")}, // Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN. {"NODE_PORT", env("NODE_PORT", "")}, + // Хаб: пускать ли агентов без подписи до апгрейда (on/off; пусто — пока не обновлены все). + {"HUB_LEGACY", env("HUB_LEGACY", "")}, // IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY. {"RELAY_FROM", env("RELAY_FROM", "")}, // Reverse-proxy перед панелью (кроме loopback): от них — X-Forwarded-For / X-Real-IP. diff --git a/internal/ui/hub_preauth.go b/internal/ui/hub_preauth.go new file mode 100644 index 0000000..ce3cf4c --- /dev/null +++ b/internal/ui/hub_preauth.go @@ -0,0 +1,89 @@ +package ui + +// Подпись агента ДО апгрейда на WebSocket (stealth хаба нод). Раньше хаб принимал WebSocket у +// любого и сразу слал hello с nonce: путь /node/v1 есть в открытом коде, и сканер, знающий +// протокол, узнавал хаб одним подключением. Теперь агент кладёт подпись своим ключом в заголовки +// запроса, хаб проверяет её до Accept и без неё отвечает 404 пустого Go-сервера. +// +// Переходный режим: агенты до этой версии заголовков не шлют. Их пускают по-старому, пока в +// панели есть нода, ни разу не подключавшаяся с подписью (nodes.preauth = 0): обновили все — +// путь без подписи закрывается сам. HUB_LEGACY=on / off — принудительно. +// +// Внутри WebSocket протокол прежний (hello → auth → ready): кадр auth обязан быть от той же ноды. + +import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/subtle" + "net/http" + "os" + "strconv" + "strings" +) + +// Часы ноды могут расходиться с панелью: окно широкое. Повтор подписи не страшен — заголовки +// идут внутри TLS с закреплённым сертификатом, подсмотреть их нельзя. +const preauthWindow = 24 * 3600 + +// preauthMsg — что агент подписывает в заголовке X-Node-Sig. Дублируется в internal/agent. +func preauthMsg(nodeID, ts int64) []byte { + return []byte("awg-node-pre-v1\n" + strconv.FormatInt(nodeID, 10) + "\n" + strconv.FormatInt(ts, 10)) +} + +// joinOK — одноразовый секрет первой привязки ноды верен и не просрочен. +func joinOK(row *nodeRow, secret string) bool { + if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || secret == "" { + return false + } + h := sha256.Sum256(fromB64url(secret)) + return len(row.JoinHash) == len(h) && subtle.ConstantTimeCompare(h[:], row.JoinHash) == 1 +} + +// preauth — подпись из заголовков. present — агент её прислал (новая версия); row — нода, если +// подпись верна. +func (n *Nodes) preauth(r *http.Request) (row *nodeRow, present bool) { + idS, tsS, sig := r.Header.Get("X-Node-Id"), r.Header.Get("X-Node-Ts"), r.Header.Get("X-Node-Sig") + if idS == "" && tsS == "" && sig == "" { + return nil, false + } + id, err1 := strconv.ParseInt(idS, 10, 64) + ts, err2 := strconv.ParseInt(tsS, 10, 64) + if err1 != nil || err2 != nil || sig == "" { + return nil, true + } + if d := ts - now(); d > preauthWindow || d < -preauthWindow { + warnf("nodes: подпись ноды #%d вне окна: часы расходятся на %d с", id, d) + return nil, true + } + row = n.row(id) + if row == nil { + return nil, true + } + pub := row.Pub.String + if !row.Pub.Valid { + // Первое подключение: ключа ноды ещё нет, его приносит агент вместе с секретом привязки. + if !joinOK(row, r.Header.Get("X-Node-Join")) { + return nil, true + } + pub = r.Header.Get("X-Node-Pub") + } + key := fromB64url(pub) + if len(key) != ed25519.PublicKeySize || !ed25519.Verify(key, preauthMsg(id, ts), fromB64url(sig)) { + return nil, true + } + return row, true +} + +// legacyOpen — пускать ли подключение без подписи (агент старой версии). +func (n *Nodes) legacyOpen() bool { + switch strings.ToLower(os.Getenv("HUB_LEGACY")) { + case "on": + return true + case "off": + return false + } + var waiting int + n.a.db.QueryRow(`SELECT COUNT(*) FROM nodes WHERE preauth = 0 + AND (pub IS NOT NULL OR (join_hash IS NOT NULL AND join_expires >= ?))`, now()).Scan(&waiting) + return waiting > 0 +} diff --git a/internal/ui/hub_preauth_test.go b/internal/ui/hub_preauth_test.go new file mode 100644 index 0000000..39bb39e --- /dev/null +++ b/internal/ui/hub_preauth_test.go @@ -0,0 +1,224 @@ +package ui + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/maeneko/forgetting/internal/agent" + "github.com/maeneko/forgetting/internal/jsjson" +) + +// Хаб нод: WebSocket — только агенту с подписью в заголовках; остальным 404 пустого сервера. +// Старых агентов (без подписи) пускают, пока не все ноды подключались по-новому. +func TestHubPreauth(t *testing.T) { + p := newMovePanel(t, "on", "") + pub3, priv3, _ := ed25519.GenerateKey(rand.Reader) + _, stranger, _ := ed25519.GenerateKey(rand.Reader) + if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at) VALUES (3, 'Frankfurt', ?, 1)", b64url.EncodeToString(pub3)); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(p.a.nodes.serveWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + hdr := func(id int64, key ed25519.PrivateKey, ts int64) http.Header { + h := http.Header{} + h.Set("X-Node-Id", strconv.FormatInt(id, 10)) + h.Set("X-Node-Ts", strconv.FormatInt(ts, 10)) + h.Set("X-Node-Sig", b64url.EncodeToString(ed25519.Sign(key, preauthMsg(id, ts)))) + return h + } + // dial — код ответа на апгрейд (101 — WebSocket принят) и первый кадр. + dial := func(h http.Header) (int, *websocket.Conn) { + ws, resp, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: h}) + if err != nil { + if resp == nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == 404 && string(body) != "404 page not found\n" { + t.Errorf("тело 404 не как у пустого сервера: %q", body) + } + return resp.StatusCode, nil + } + return 101, ws + } + read := func(ws *websocket.Conn) jsjson.Object { + _, raw, err := ws.Read(ctx) + if err != nil { + return nil + } + v, _ := jsjson.Decode(raw) + o, _ := v.(jsjson.Object) + return o + } + auth := func(ws *websocket.Conn, id int64, key ed25519.PrivateKey) jsjson.Object { + nonce := read(ws).Get2("nonce").(string) + ws.Write(ctx, websocket.MessageText, jsjson.MustMarshal(jsjson.Object{{Key: "t", Val: "auth"}, {Key: "node_id", Val: id}, + {Key: "sig", Val: b64url.EncodeToString(ed25519.Sign(key, authMsg(nonce, id)))}})) + return read(ws) + } + now := time.Now().Unix() + + if code, _ := dial(hdr(3, stranger, now)); code != 404 { + t.Errorf("чужая подпись: %d, ждали 404", code) + } + if code, _ := dial(hdr(99, stranger, now)); code != 404 { + t.Errorf("несуществующая нода: %d, ждали 404", code) + } + if code, _ := dial(hdr(3, priv3, now-2*preauthWindow)); code != 404 { + t.Errorf("подпись вне окна: %d, ждали 404", code) + } + + // Переходный режим: нода 3 ещё не подключалась с подписью — старый агент проходит. + code, ws := dial(nil) + if code != 101 { + t.Fatalf("старый агент в переходном режиме: %d", code) + } + if r := auth(ws, 3, priv3); r.Get2("t") != "ready" { + t.Fatalf("старый агент: %v", r) + } + ws.CloseNow() + + // Новый агент: подпись в заголовках, затем прежний auth внутри. + code, ws = dial(hdr(3, priv3, now)) + if code != 101 { + t.Fatalf("новый агент: %d", code) + } + if r := auth(ws, 3, priv3); r.Get2("t") != "ready" { + t.Fatalf("новый агент: %v", r) + } + ws.CloseNow() + + // Все ноды подключались с подписью — без неё больше не пускают. + if code, _ := dial(nil); code != 404 { + t.Errorf("без подписи после перехода: %d, ждали 404", code) + } + resp, err := http.Get(srv.URL + "/node/v1") + if err != nil || resp.StatusCode != 404 { + t.Errorf("обычный GET: %v %v", resp, err) + } + t.Setenv("HUB_LEGACY", "on") + if code, _ := dial(nil); code != 101 { + t.Errorf("HUB_LEGACY=on: %d", code) + } + t.Setenv("HUB_LEGACY", "") + + // Подпись одной ноды, auth от имени другой — отказ. + pub5, priv5, _ := ed25519.GenerateKey(rand.Reader) + p.a.db.Exec("INSERT INTO nodes (id, name, pub, created_at, preauth) VALUES (5, 'Riga', ?, 1, 1)", b64url.EncodeToString(pub5)) + code, ws = dial(hdr(3, priv3, now+1)) + if code != 101 { + t.Fatalf("нода 3: %d", code) + } + if r := auth(ws, 5, priv5); r != nil { + t.Errorf("auth чужой ноды после подписи ноды 3 принят: %v", r) + } + ws.CloseNow() +} + +// Настоящий агент привязывается к настоящему хабу по строке join и подключается снова уже +// своим ключом — в обоих случаях с подписью до апгрейда. +func TestHubAgentEndToEnd(t *testing.T) { + t.Setenv("HUB_LEGACY", "off") + p := newMovePanel(t, "on", "") + secretRaw := make([]byte, 16) + rand.Read(secretRaw) + secret := b64url.EncodeToString(secretRaw) + h := sha256.Sum256(secretRaw) + if _, err := p.a.db.Exec("INSERT INTO nodes (id, name, join_hash, join_expires, created_at) VALUES (7, 'Новая', ?, ?, 1)", + h[:], time.Now().Unix()+600); err != nil { + t.Fatal(err) + } + srv := httptest.NewTLSServer(http.HandlerFunc(p.a.nodes.serveWS)) + defer srv.Close() + u, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(u.Port()) + pin := certPin(t, srv.Certificate().Raw) + + dir := t.TempDir() + _, ik, _ := ed25519.GenerateKey(rand.Reader) + der, _ := x509.MarshalPKCS8PrivateKey(ik) + os.WriteFile(filepath.Join(dir, "internal.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600) + env := filepath.Join(dir, "cli.env") + os.WriteFile(env, []byte("JOIN_SECRET="+secret+"\nNODE_ID=7\n"), 0o600) + run := func() { + a, err := agent.New(agent.Config{ + CoreHost: u.Hostname(), CorePort: port, CorePin: pin, NodeID: 7, Ctrl: "http://127.0.0.1:1", + NodeKeyFile: filepath.Join(dir, "node.key"), InternalKey: filepath.Join(dir, "internal.key"), + CLIEnvFile: env, JoinSecret: envValue(env, "JOIN_SECRET"), Version: "Beta 0.4.7", + Logf: t.Logf, Errorf: t.Logf, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.Run(ctx) + for range 100 { + if p.a.nodes.IsOnline(7) { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal("агент не подключился") + } + + run() // привязка по секрету + row := p.a.nodes.row(7) + if !row.Pub.Valid { + t.Fatal("ключ ноды не сохранён после привязки") + } + var pre int + p.a.db.QueryRow("SELECT preauth FROM nodes WHERE id = 7").Scan(&pre) + if pre != 1 { + t.Error("подключение с подписью не отмечено (preauth)") + } + for range 100 { // прежнее соединение снимается при отмене контекста + if !p.a.nodes.IsOnline(7) { + break + } + time.Sleep(20 * time.Millisecond) + } + if strings.Contains(envValue(env, "JOIN_SECRET"), secret) { + t.Error("секрет привязки не стёрт после первого подключения") + } + run() // повторное подключение — уже своим ключом +} + +func certPin(t *testing.T, raw []byte) string { + cert, err := x509.ParseCertificate(raw) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo) + return b64url.EncodeToString(sum[:]) +} + +func envValue(file, key string) string { + raw, _ := os.ReadFile(file) + for _, l := range strings.Split(string(raw), "\n") { + if v, ok := strings.CutPrefix(l, key+"="); ok { + return v + } + } + return "" +} diff --git a/internal/ui/nodes.go b/internal/ui/nodes.go index 3d2a92b..1c17f81 100644 --- a/internal/ui/nodes.go +++ b/internal/ui/nodes.go @@ -15,7 +15,6 @@ import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" - "crypto/subtle" "crypto/tls" "database/sql" "fmt" @@ -216,6 +215,13 @@ func newNodes(a *App) (*Nodes, error) { return nil, err } } + // preauth — нода хоть раз подключилась с подписью в заголовках (hub_preauth.go): пока такие + // есть не все, хаб пускает и старых агентов без подписи. + if !hasColumn(a.db, "nodes", "preauth") { + if _, err := a.db.Exec("ALTER TABLE nodes ADD COLUMN preauth INTEGER NOT NULL DEFAULT 0"); err != nil { + return nil, err + } + } // Нода №0 и локальный сервер — один и тот же id: вместе их не бывает. if n.local && n.row(0) != nil { return nil, fmt.Errorf("nodes: в ui.db есть нода #0 (сервер, с которого переехала панель), а LOCAL_NODE=on — " + @@ -480,15 +486,27 @@ func closeWS(ws *websocket.Conn, code int, reason string) { func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { ip, _, _ := net.SplitHostPort(r.RemoteAddr) + // До апгрейда: без верной подписи ноды (или старого агента в переходном режиме) — 404, как + // у пустого сервера; WebSocket и hello видит только своя нода (hub_preauth.go). + if n.blocked(ip) { + http.NotFound(w, r) + return + } + pre, present := n.preauth(r) + if present && pre == nil { + n.failed(ip) + http.NotFound(w, r) + return + } + if !present && !n.legacyOpen() { + http.NotFound(w, r) + return + } ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true, CompressionMode: websocket.CompressionDisabled}) if err != nil { return } ws.SetReadLimit(hubMaxFrame) - if n.blocked(ip) { - ws.Close(4029, "rate_limited") - return - } nb := make([]byte, 16) rand.Read(nb) nonce := b64url.EncodeToString(nb) @@ -506,21 +524,16 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { return } row := n.row(int64(m.Get2("node_id").(float64))) - if row == nil { + if row == nil || pre != nil && pre.ID != row.ID { deny() return } pubB64 := row.Pub.String joining := !row.Pub.Valid if joining { - secret, sOK := field(m, "join", "secret").(string) + secret, _ := field(m, "join", "secret").(string) pub, pOK := field(m, "join", "pub").(string) - if row.JoinHash == nil || !row.JoinExpires.Valid || row.JoinExpires.Int64 == 0 || row.JoinExpires.Int64 < now() || !sOK || !pOK { - deny() - return - } - h := sha256.Sum256(fromB64url(secret)) - if len(row.JoinHash) != len(h) || subtle.ConstantTimeCompare(h[:], row.JoinHash) != 1 { + if !pOK || !joinOK(row, secret) { deny() return } @@ -541,6 +554,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { version = v } n.a.db.Exec("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?", now(), version, row.ID) + if pre != nil { + n.a.db.Exec("UPDATE nodes SET preauth = 1 WHERE id = ?", row.ID) + } conn = &nodeConn{ws: ws, alive: true, pending: map[int64]*pending{}} nodeID = row.ID @@ -561,6 +577,9 @@ func (n *Nodes) serveWS(w http.ResponseWriter, r *http.Request) { if joining { first = " (первое подключение)" } + if pre == nil { + first += " — без подписи до апгрейда: старый агент, обновите ноду, чтобы хаб скрылся от сканеров" + } logf("nodes: «%s» (#%d) подключена%s", row.Name, row.ID, first) go n.refreshHealth(row.ID) go n.fireOnline(int(row.ID)) From 1a54d05ccaf5a02db1b9856eacaf41bf91419575 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:06:40 +0000 Subject: [PATCH 07/12] VPN connection log: who connected and when MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WireGuard has no connect events, only the last handshake and byte counters. awg-ctrl now checks them every 30 s and writes conn_log in users.db: connect at the first handshake, disconnect at the last activity (the last tick the counters moved), with the session length and traffic, plus peer_seen, the last time each peer was online. Open sessions are restored from the database, so a restart adds no duplicates; a down interface is not a disconnect; a peer removed mid-session is closed. Events keep the name, owner and VPN IP as of that moment and live 90 days. Client public IPs are not recorded. GET /api/connlog serves the events and the per-peer last-seen list. The panel fetches it from the selected server at /ui/connlog and fills in master key labels and device names for sen:// peers. A new "Подключения" tab shows the event feed with search and paging, and "who was online when", including those who never connected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 16 ++ awg-ui/src/components/icons.tsx | 7 + awg-ui/src/tabs/connlog/connlog.css | 46 +++++ awg-ui/src/tabs/connlog/index.tsx | 183 ++++++++++++++++ awg-ui/src/tabs/connlog/metadata.json | 7 + internal/ctrl/connlog.go | 287 ++++++++++++++++++++++++++ internal/ctrl/connlog_test.go | 116 +++++++++++ internal/ctrl/db.go | 20 ++ internal/ctrl/routes.go | 8 + internal/ctrl/server.go | 3 + internal/ui/connlog.go | 78 +++++++ internal/ui/connlog_test.go | 30 +++ internal/ui/server.go | 1 + 13 files changed, 802 insertions(+) create mode 100644 awg-ui/src/tabs/connlog/connlog.css create mode 100644 awg-ui/src/tabs/connlog/index.tsx create mode 100644 awg-ui/src/tabs/connlog/metadata.json create mode 100644 internal/ctrl/connlog.go create mode 100644 internal/ctrl/connlog_test.go create mode 100644 internal/ui/connlog.go create mode 100644 internal/ui/connlog_test.go diff --git a/README.md b/README.md index 44b99a0..af93a1d 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,22 @@ base64url-декодировать, отбросить первые 4 байта | `DELETE /ui/devices/:id` | Отвязать устройство | | `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства | +## Журнал подключений + +Вкладка «Подключения» показывает по выбранному серверу: + +- **События** — кто подключился к VPN и отключился, когда, сколько длилась сессия и сколько + скачал и отдал. Поиск — по имени пользователя, мастер-ключу, устройству или внутреннему IP. +- **Кто когда был** — все пользователи и устройства sen:// сервера: онлайн сейчас, когда были на + связи последний раз и кто не подключался ни разу с начала журнала. + +Журнал ведёт awg-ctrl каждого сервера (`conn_log` в `users.db`, 90 дней). У WireGuard нет событий +подключения, поэтому раз в 30 с он сверяет время handshake и счётчики трафика: «подключился» — +время первого handshake, «отключился» — последняя активность (точность около 30 с). Перезапуск +awg-ctrl сессии не рвёт, падение интерфейса отключением не считается. Внешний IP клиентов не +записывается. Тот же журнал по API: `GET /api/connlog?limit=&before=&q=`. Серверы на прежней +версии журнала не ведут — вкладка попросит их обновить. + ## Управление ```bash diff --git a/awg-ui/src/components/icons.tsx b/awg-ui/src/components/icons.tsx index 1882371..8a663f8 100644 --- a/awg-ui/src/components/icons.tsx +++ b/awg-ui/src/components/icons.tsx @@ -108,10 +108,17 @@ export const IcoMove = () => ( ); +export const IcoHistory = () => ( + + + +); + export const ICONS: Record JSX.Element> = { users: IcoUsers, key: IcoKey, shield: IcoShield, pulse: IcoPulse, move: IcoMove, + history: IcoHistory, }; \ No newline at end of file diff --git a/awg-ui/src/tabs/connlog/connlog.css b/awg-ui/src/tabs/connlog/connlog.css new file mode 100644 index 0000000..03e1425 --- /dev/null +++ b/awg-ui/src/tabs/connlog/connlog.css @@ -0,0 +1,46 @@ +/* Вкладка «Подключения»: лента событий и список «кто когда был». Строки — гибкие, без таблицы: + на телефоне колонки сами переносятся. */ +.cl { display: flex; flex-direction: column; gap: 12px; } + +.cl-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.cl-views { display: flex; gap: 8px; } +.cl-view { + min-height: 44px; padding: 0 18px; + font: inherit; font-size: 14px; color: var(--on-surface); + background: var(--surface-bright); + border: 1px solid var(--outline); border-radius: var(--r-pill); + cursor: pointer; +} +.cl-view:hover { background: var(--state-primary); } +.cl-view--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; } +.cl-search { flex: 1; min-width: 200px; width: auto; } + +.cl-hint { margin: 0; font-size: 13px; color: var(--neutral-text); } +.cl-err { text-align: left; } + +.cl-list { + list-style: none; margin: 0; padding: 0; + background: var(--surface-bright); + border: 1px solid var(--outline-variant); border-radius: var(--r-card); + overflow: hidden; +} +.cl-row { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; + padding: 12px 16px; + border-top: 1px solid var(--outline-variant); +} +.cl-row:first-child { border-top: none; } +.cl-time { flex: 0 0 110px; font-size: 13px; color: var(--neutral-text); font-variant-numeric: tabular-nums; } +.cl-chip { flex: none; } +.cl-who { flex: 1 1 200px; display: flex; flex-direction: column; min-width: 0; } +.cl-who b { font-weight: 500; overflow-wrap: anywhere; } +.cl-sub { font-size: 12px; color: var(--neutral-text); overflow-wrap: anywhere; } +.cl-session, .cl-seen { font-size: 13px; color: var(--on-surface); font-variant-numeric: tabular-nums; } +.cl-empty { padding: 24px 16px; text-align: center; color: var(--neutral-text); } +.cl-more { display: flex; justify-content: center; padding: 12px; border-top: 1px solid var(--outline-variant); } + +@media (max-width: 640px) { + .cl-time { flex-basis: auto; } + .cl-views { width: 100%; } + .cl-view { flex: 1; } +} diff --git a/awg-ui/src/tabs/connlog/index.tsx b/awg-ui/src/tabs/connlog/index.tsx new file mode 100644 index 0000000..f94232f --- /dev/null +++ b/awg-ui/src/tabs/connlog/index.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2026 Ivan Vasilev +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { apiFetch, bytes, timeAgo, type PageProps } from '../../lib/shared'; +import { IcoRefresh } from '../../components/icons'; +import './connlog.css'; + +// Вкладка «Подключения»: журнал VPN выбранного сервера. Ведёт его awg-ctrl (internal/ctrl/connlog.go) +// по handshake и счётчикам WireGuard раз в 30 с, панель подставляет имена ключей и устройств sen:// +// (/ui/connlog). Время отключения — последняя активность, точность около 30 с. +interface Who { pub_key: string; name: string; owner: string; ip: string; key?: string | null; device?: string | null; foreign?: boolean } +interface Ev extends Who { id: number; at: number; event: 'connect' | 'disconnect'; duration?: number | null; rx?: number | null; tx?: number | null } +interface Peer extends Who { online: boolean; last_seen: number | null } +interface Log { events: Ev[]; peers: Peer[]; since: number | null; keep_days: number } + +const errText = (e: any): string => e?.response?.data?.error ?? 'Не удалось получить журнал'; + +// Кто это: пользователь — по имени, устройство sen:// — ключ и устройство. +function who(w: Who): { title: string; sub: string } { + if (w.name) return { title: w.name, sub: 'пользователь' }; + if (w.foreign) return { title: 'Устройство другой панели', sub: w.owner }; + if (w.owner) { + return { title: w.device || 'Удалённое устройство', sub: w.key ? `мастер-ключ «${w.key}»` : 'мастер-ключ удалён' }; + } + return { title: `ключ ${w.pub_key.slice(0, 10)}…`, sub: 'не из панели' }; +} + +const pad = (n: number) => String(n).padStart(2, '0'); +function when(ts: number): string { + const d = new Date(ts * 1000), now = new Date(); + const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`; + if (d.toDateString() === now.toDateString()) return `сегодня ${time}`; + const y = new Date(now); y.setDate(now.getDate() - 1); + if (d.toDateString() === y.toDateString()) return `вчера ${time}`; + return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}${d.getFullYear() === now.getFullYear() ? '' : '.' + d.getFullYear()} ${time}`; +} +function duration(s: number): string { + if (s < 60) return `${s} сек`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} мин`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} ч ${m % 60} мин`; + return `${Math.floor(h / 24)} дн ${h % 24} ч`; +} + +export default function ConnLogPage({ token, showMsg }: PageProps) { + const [view, setView] = useState<'events' | 'peers'>('events'); + const [log, setLog] = useState(null); + const [more, setMore] = useState([]); // подгруженные страницы старее первой + const [done, setDone] = useState(false); // старее событий нет + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + const [query, setQuery] = useState(''); + + // Первая страница: обновляем раз в 30 с — журнал пишется с тем же шагом. + const load = useCallback(async () => { + try { + const l = await apiFetch('GET', '/ui/connlog?limit=200', token) as Log; + setLog(l); setErr(''); + if (l.events.length < 200) setDone(true); + } catch (e) { setErr(errText(e)); } + }, [token]); + useEffect(() => { + void load(); + const t = setInterval(() => { void load(); }, 30000); + return () => clearInterval(t); + }, [load]); + + const all = useMemo(() => { + const first = log?.events ?? []; + const seen = new Set(first.map(e => e.id)); + return [...first, ...more.filter(e => !seen.has(e.id))]; + }, [log, more]); + + const loadMore = async () => { + const last = all[all.length - 1]; + if (!last) return; + setBusy(true); + try { + const l = await apiFetch('GET', `/ui/connlog?limit=200&before=${last.id}`, token) as Log; + setMore(m => [...m, ...l.events]); + if (l.events.length < 200) setDone(true); + } catch (e) { showMsg(errText(e)); } + finally { setBusy(false); } + }; + + const q = query.trim().toLowerCase(); + const match = (w: Who) => !q || [w.name, w.owner, w.ip, w.key ?? '', w.device ?? '', w.pub_key] + .some(s => s.toLowerCase().includes(q)); + + const events = all.filter(match); + const peers = (log?.peers ?? []).filter(match).sort((a, b) => + Number(b.online) - Number(a.online) || (b.last_seen ?? 0) - (a.last_seen ?? 0)); + const onlineNow = (log?.peers ?? []).filter(p => p.online).length; + + return ( +
+
+
+ + +
+ setQuery(e.target.value)} /> + +
+ + {err &&

{err}

} + {!log && !err &&

Загрузка…

} + + {log && ( +

+ Сейчас онлайн: {onlineNow} из {log.peers.length}. + {' '}{log.since ? <>Журнал ведётся с {when(log.since)}, хранится {log.keep_days} дней. : 'Журнал пуст: события появятся при первом подключении.'} + {' '}Время отключения — последняя активность, точность около 30 секунд. +

+ )} + + {log && view === 'events' && ( +
    + {events.length === 0 &&
  • {q ? 'Ничего не найдено' : 'Событий пока нет'}
  • } + {events.map(e => { + const w = who(e); + const on = e.event === 'connect'; + return ( +
  • + {when(e.at)} + + {on ? 'подключился' : 'отключился'} + + + {w.title} + {w.sub}{e.ip && <> · {e.ip}} + + {!on && ( + + {e.duration != null && <>сессия {duration(e.duration)}} + {/* rx/tx — со стороны сервера: то, что сервер принял, клиент отправил */} + {(e.tx || e.rx) ? <> · ↓ {bytes(e.tx ?? 0)} ↑ {bytes(e.rx ?? 0)} : null} + + )} +
  • + ); + })} + {!done && !q && all.length > 0 && ( +
  • + +
  • + )} +
+ )} + + {log && view === 'peers' && ( +
    + {peers.length === 0 &&
  • {q ? 'Ничего не найдено' : 'На сервере нет пользователей'}
  • } + {peers.map(p => { + const w = who(p); + return ( +
  • + + {w.title} + {w.sub}{p.ip && <> · {p.ip}} + + {p.online ? ( + онлайн + ) : p.last_seen ? ( + был {when(p.last_seen)} ({timeAgo(p.last_seen)} назад) + ) : ( + не подключался{log.since ? ` с ${when(log.since)}` : ''} + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/awg-ui/src/tabs/connlog/metadata.json b/awg-ui/src/tabs/connlog/metadata.json new file mode 100644 index 0000000..cf7bad5 --- /dev/null +++ b/awg-ui/src/tabs/connlog/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "connlog", + "label": "Подключения", + "icon": "history", + "group": "main", + "order": 1.5 +} diff --git a/internal/ctrl/connlog.go b/internal/ctrl/connlog.go new file mode 100644 index 0000000..cadba57 --- /dev/null +++ b/internal/ctrl/connlog.go @@ -0,0 +1,287 @@ +package ctrl + +// Журнал VPN-подключений: кто, когда подключился и отключился, сколько длилась сессия и сколько +// прошло трафика, и когда каждый пир был на связи последний раз (для «кто давно не подключался»). +// +// У WireGuard нет событий подключения — только время последнего handshake и счётчики байт в +// `awg show dump`. Раз в 30 с сверяем их с открытыми сессиями: +// - пир стал онлайн (handshake моложе 3 мин) — «подключился» со временем этого handshake; +// - пир ушёл из онлайна или с интерфейса — «отключился» со временем последней активности +// (последнего тика, когда менялись счётчики): handshake идёт раз в 2 мин, активность точнее. +// Открытые сессии восстанавливаются из базы, поэтому перезапуск awg-ctrl не плодит дублей. +// Внешний IP клиента не пишем: это уже слежка, а не журнал. + +import ( + "strconv" + "strings" + "sync" + "time" +) + +const ( + connlogEvery = 30 * time.Second + connlogKeepS = 90 * 24 * 3600 // столько дней храним события + seenEveryS = 60 // last_seen пишем не чаще раза в минуту на пира + connlogLimit = 500 +) + +type connSession struct { + since int64 + rx0, tx0 float64 // счётчики на начало сессии + lastRx, lastTx float64 + active int64 // последний тик, когда менялись счётчики + seenSaved int64 +} + +type peerIdent struct{ name, owner, ip string } + +type ConnLog struct { + s *Server + mu sync.Mutex + open map[string]*connSession + loaded bool + pruned int64 + peersFn func() (map[string]peerStat, bool) // подменяется в тестах +} + +func newConnLog(s *Server) *ConnLog { + l := &ConnLog{s: s, open: map[string]*connSession{}} + l.peersFn = func() (map[string]peerStat, bool) { + if !s.interfaceStatus().Up { + return nil, false // интерфейс лежит — это не «все отключились», а пауза + } + return s.getPeersData(), true + } + return l +} + +func (l *ConnLog) Start() { + go func() { + for { + l.tick(time.Now().Unix()) + time.Sleep(connlogEvery) + } + }() +} + +// idents — имя пользователя или владелец пира sen:// и внутренний IP по публичному ключу. Строки +// читаем до конца до любых других запросов: соединение с SQLite одно (см. metrics_test.go). +func (l *ConnLog) idents() map[string]peerIdent { + out := map[string]peerIdent{} + if rows, err := l.s.db.Query("SELECT pub_key, name, ip FROM users"); err == nil { + for rows.Next() { + var pub, name, ip string + if rows.Scan(&pub, &name, &ip) == nil { + out[pub] = peerIdent{name: name, ip: ip} + } + } + rows.Close() + } + if rows, err := l.s.db.Query("SELECT pub_key, owner, ip FROM peers"); err == nil { + for rows.Next() { + var pub, owner, ip string + if rows.Scan(&pub, &owner, &ip) == nil { + out[pub] = peerIdent{owner: owner, ip: ip} + } + } + rows.Close() + } + return out +} + +// load — открытые сессии (последнее событие пира — «подключился») после перезапуска. +func (l *ConnLog) load(now int64) { + type row struct { + pub string + at, rx, tx int64 + seen int64 + } + var open []row + rows, err := l.s.db.Query(`SELECT c.pub_key, c.at, COALESCE(c.rx, 0), COALESCE(c.tx, 0), COALESCE(s.at, c.at) + FROM conn_log c + JOIN (SELECT pub_key, MAX(id) AS id FROM conn_log GROUP BY pub_key) last ON last.id = c.id + LEFT JOIN peer_seen s ON s.pub_key = c.pub_key + WHERE c.event = 'connect'`) + if err != nil { + return + } + for rows.Next() { + var r row + if rows.Scan(&r.pub, &r.at, &r.rx, &r.tx, &r.seen) == nil { + open = append(open, r) + } + } + rows.Close() + for _, r := range open { + l.open[r.pub] = &connSession{since: r.at, rx0: float64(r.rx), tx0: float64(r.tx), + lastRx: float64(r.rx), lastTx: float64(r.tx), active: r.seen, seenSaved: r.seen} + } + l.loaded = true +} + +func (l *ConnLog) tick(now int64) { + stats, ok := l.peersFn() + if !ok { + return + } + l.mu.Lock() + defer l.mu.Unlock() + if !l.loaded { + l.load(now) + } + who := l.idents() + write := func(event, pub string, at int64, dur *int64, rx, tx float64) { + id := who[pub] + var d any + if dur != nil { + d = *dur + } + l.s.db.Exec("INSERT INTO conn_log (at, event, pub_key, name, owner, ip, duration, rx, tx) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + at, event, pub, id.name, id.owner, id.ip, d, int64(rx), int64(tx)) + } + seen := func(pub string, at int64) { + l.s.db.Exec("INSERT INTO peer_seen (pub_key, at) VALUES (?, ?) ON CONFLICT(pub_key) DO UPDATE SET at = excluded.at", pub, at) + } + closeSession := func(pub string, sess *connSession) { + at := max(sess.active, sess.since) + dur := at - sess.since + // Счётчики сбрасываются при перезапуске интерфейса: тогда считаем от нуля. + rx, tx := sess.lastRx-sess.rx0, sess.lastTx-sess.tx0 + if rx < 0 { + rx = sess.lastRx + } + if tx < 0 { + tx = sess.lastTx + } + write("disconnect", pub, at, &dur, rx, tx) + seen(pub, at) + delete(l.open, pub) + } + + for pub, st := range stats { + sess := l.open[pub] + switch { + case st.Online && sess == nil: + at := int64(st.LastHandshake) + if at <= 0 || at > now { + at = now + } + write("connect", pub, at, nil, st.Rx, st.Tx) + l.open[pub] = &connSession{since: at, rx0: st.Rx, tx0: st.Tx, lastRx: st.Rx, lastTx: st.Tx, active: now, seenSaved: now} + seen(pub, now) + case st.Online: + if st.Rx != sess.lastRx || st.Tx != sess.lastTx { + sess.lastRx, sess.lastTx, sess.active = st.Rx, st.Tx, now + } + if now-sess.seenSaved >= seenEveryS { + seen(pub, sess.active) + sess.seenSaved = now + } + case sess != nil: + if hs := int64(st.LastHandshake); hs > sess.active && hs <= now { + sess.active = hs + } + if st.Rx > sess.lastRx || st.Tx > sess.lastTx { + sess.lastRx, sess.lastTx = st.Rx, st.Tx + } + closeSession(pub, sess) + } + } + // Пира сняли с интерфейса (удалили пользователя, перевыпустили ключ) посреди сессии. + for pub, sess := range l.open { + if _, ok := stats[pub]; !ok { + closeSession(pub, sess) + } + } + if now-l.pruned >= 3600 { + l.s.db.Exec("DELETE FROM conn_log WHERE at < ?", now-connlogKeepS) + l.pruned = now + } +} + +// list — GET /api/connlog: события (новые сверху) и «когда был на связи» по каждому пиру. +// +// ?limit=N (до 500), ?before= — следующая страница, ?q=<строка> — по имени, владельцу, IP. +func (l *ConnLog) list(limit, before int64, q string) (ob, error) { + if limit <= 0 || limit > connlogLimit { + limit = connlogLimit + } + where, args := []string{"1 = 1"}, []any{} + if before > 0 { + where, args = append(where, "id < ?"), append(args, before) + } + if q = strings.TrimSpace(q); q != "" { + like := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(q) + "%" + where = append(where, `(name LIKE ? ESCAPE '\' OR owner LIKE ? ESCAPE '\' OR ip LIKE ? ESCAPE '\')`) + args = append(args, like, like, like) + } + args = append(args, limit) + rows, err := l.s.db.Query("SELECT id, at, event, pub_key, name, owner, ip, duration, rx, tx FROM conn_log WHERE "+ + strings.Join(where, " AND ")+" ORDER BY id DESC LIMIT ?", args...) + if err != nil { + return nil, err + } + events := []any{} + for rows.Next() { + var id, at int64 + var event, pub, name, owner, ip string + var dur, rx, tx *int64 + if err := rows.Scan(&id, &at, &event, &pub, &name, &owner, &ip, &dur, &rx, &tx); err != nil { + rows.Close() + return nil, err + } + e := ob{{"id", id}, {"at", at}, {"event", event}, {"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip}} + if event == "disconnect" { + e = append(e, kv{"duration", deref(dur)}, kv{"rx", deref(rx)}, kv{"tx", deref(tx)}) + } + events = append(events, e) + } + rows.Close() + + // Все пиры сервера с последним временем на связи (null — с тех пор, как ведётся журнал, не было). + stats := map[string]peerStat{} + if st, ok := l.peersFn(); ok { + stats = st + } + peers := []any{} + rows, err = l.s.db.Query(`SELECT u.pub_key, u.name, '' AS owner, u.ip, s.at FROM users u LEFT JOIN peer_seen s ON s.pub_key = u.pub_key + UNION ALL + SELECT p.pub_key, '', p.owner, p.ip, s.at FROM peers p LEFT JOIN peer_seen s ON s.pub_key = p.pub_key`) + if err != nil { + return nil, err + } + for rows.Next() { + var pub, name, owner, ip string + var at *int64 + if err := rows.Scan(&pub, &name, &owner, &ip, &at); err != nil { + rows.Close() + return nil, err + } + var last any + if at != nil { + last = *at + } + peers = append(peers, ob{{"pub_key", pub}, {"name", name}, {"owner", owner}, {"ip", ip}, + {"online", stats[pub].Online}, {"last_seen", last}}) + } + rows.Close() + + var since any + var first int64 + if l.s.db.QueryRow("SELECT MIN(at) FROM conn_log").Scan(&first) == nil && first > 0 { + since = first + } + return ob{{"events", events}, {"peers", peers}, {"since", since}, {"keep_days", connlogKeepS / 86400}}, nil +} + +func deref(p *int64) any { + if p == nil { + return nil + } + return *p +} + +func queryInt(c *rctx, k string) int64 { + n, _ := strconv.ParseInt(c.r.URL.Query().Get(k), 10, 64) + return n +} diff --git a/internal/ctrl/connlog_test.go b/internal/ctrl/connlog_test.go new file mode 100644 index 0000000..4c507ef --- /dev/null +++ b/internal/ctrl/connlog_test.go @@ -0,0 +1,116 @@ +package ctrl + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/maeneko/forgetting/internal/jsjson" + "github.com/maeneko/forgetting/internal/logx" +) + +func TestConnLog(t *testing.T) { + db, err := openDB(filepath.Join(t.TempDir(), "users.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.Exec("INSERT INTO users (name, ip, pub_key, vpn_key) VALUES ('ivan', '10.9.0.2', 'PK_IVAN', 'vpn://x')") + db.Exec("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES ('PK_PHONE', '10.9.0.3', 'psk', 'p1/m1/d2', 1)") + s := &Server{db: db, iface: "awg1", log: logx.New(os.Stderr, t.TempDir())} + + stats := map[string]peerStat{} + up := true + newLog := func() *ConnLog { + l := newConnLog(s) + l.peersFn = func() (map[string]peerStat, bool) { + cp := map[string]peerStat{} + for k, v := range stats { + cp[k] = v + } + return cp, up + } + return l + } + l := newLog() + peer := func(pub string, online bool, hs, rx, tx float64) { + stats[pub] = peerStat{Online: online, LastHandshake: hs, Rx: rx, Tx: tx} + } + events := func() string { + v, err := l.list(0, 0, "") + if err != nil { + t.Fatal(err) + } + var b []string + for _, e := range v.Get2("events").([]any) { + o := e.(jsjson.Object) + s := o.Get2("event").(string) + " " + o.Get2("name").(string) + o.Get2("owner").(string) + " @" + string(jsjson.MustMarshal(o.Get2("at"))) + if o.Get2("event") == "disconnect" { + s += " dur=" + string(jsjson.MustMarshal(o.Get2("duration"))) + " rx=" + string(jsjson.MustMarshal(o.Get2("rx"))) + } + b = append([]string{s}, b...) // старые сверху — читать проще + } + return strings.Join(b, "; ") + } + + peer("PK_IVAN", false, 0, 0, 0) + peer("PK_PHONE", false, 0, 0, 0) + l.tick(1000) + if got := events(); got != "" { + t.Fatalf("без онлайна событий быть не должно: %s", got) + } + + peer("PK_IVAN", true, 1010, 100, 50) // подключился, handshake в 1010 + l.tick(1030) + peer("PK_IVAN", true, 1010, 900, 300) // качает + l.tick(1060) + peer("PK_IVAN", true, 1130, 900, 300) // keepalive-handshake без трафика + l.tick(1150) + up = false // интерфейс лёг — это не отключение + l.tick(1180) + up = true + peer("PK_IVAN", false, 1130, 900, 300) + l.tick(1330) + if got, want := events(), "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800"; got != want { + t.Fatalf("сессия:\n got %s\nwant %s", got, want) + } + + // Перезапуск awg-ctrl посреди сессии: новый ConnLog поднимает открытую сессию из базы. + peer("PK_PHONE", true, 2000, 10, 10) + l.tick(2010) + l = newLog() + peer("PK_PHONE", true, 2100, 70, 10) + l.tick(2110) + // Пира сняли с интерфейса (удалили устройство) — сессия закрывается. + delete(stats, "PK_PHONE") + l.tick(2140) + want := "connect ivan @1010; disconnect ivan @1130 dur=120 rx=800; connect p1/m1/d2 @2000; disconnect p1/m1/d2 @2110 dur=110 rx=60" + if got := events(); got != want { + t.Fatalf("после перезапуска:\n got %s\nwant %s", got, want) + } + + // Счётчики сбросились (перезапуск интерфейса) — трафик сессии считается с нуля, не уходит в минус. + peer("PK_IVAN", true, 3000, 5000, 5000) + l.tick(3010) + peer("PK_IVAN", true, 3100, 40, 40) + l.tick(3110) + peer("PK_IVAN", false, 3100, 40, 40) + l.tick(3400) + if got := events(); !strings.HasSuffix(got, "connect ivan @3000; disconnect ivan @3110 dur=110 rx=40") { + t.Fatalf("сброс счётчиков: %s", got) + } + + v, _ := l.list(0, 0, "") + seen := map[string]any{} + for _, p := range v.Get2("peers").([]any) { + o := p.(jsjson.Object) + seen[o.Get2("pub_key").(string)] = o.Get2("last_seen") + } + if seen["PK_IVAN"] != float64(3110) && seen["PK_IVAN"] != int64(3110) { + t.Errorf("last_seen ivan: %v", seen["PK_IVAN"]) + } + if f, _ := l.list(0, 0, "ivan"); len(f.Get2("events").([]any)) != 4 { + t.Errorf("поиск по имени: %d событий", len(f.Get2("events").([]any))) + } +} diff --git a/internal/ctrl/db.go b/internal/ctrl/db.go index 064364a..5860fae 100644 --- a/internal/ctrl/db.go +++ b/internal/ctrl/db.go @@ -100,6 +100,26 @@ func initSchema(db *sql.DB) error { psk_key TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL + )`, + // Журнал VPN-подключений (connlog.go). name/owner/ip — на момент события: пользователя + // могут удалить, а запись о нём остаётся. + `CREATE TABLE IF NOT EXISTS conn_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at INTEGER NOT NULL, + event TEXT NOT NULL, + pub_key TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + duration INTEGER, + rx INTEGER, + tx INTEGER + )`, + "CREATE INDEX IF NOT EXISTS idx_conn_log_at ON conn_log (at)", + "CREATE INDEX IF NOT EXISTS idx_conn_log_pub ON conn_log (pub_key, id)", + `CREATE TABLE IF NOT EXISTS peer_seen ( + pub_key TEXT PRIMARY KEY, + at INTEGER NOT NULL )`, } { if _, err := db.Exec(q); err != nil { diff --git a/internal/ctrl/routes.go b/internal/ctrl/routes.go index 5370b0c..88de669 100644 --- a/internal/ctrl/routes.go +++ b/internal/ctrl/routes.go @@ -37,6 +37,14 @@ func (s *Server) routes() []route { {"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil }), + R("GET", "/api/connlog", false, a, func(c *rctx) (result, error) { + v, err := s.connlog.list(queryInt(c, "limit"), queryInt(c, "before"), c.r.URL.Query().Get("q")) + if err != nil { + return result{}, err + } + return ok(v) + }), + R("POST", "/api/users", true, an, func(c *rctx) (result, error) { name, _ := c.obj().Get2("name").(string) if u, err := s.userGet(name); err != nil { diff --git a/internal/ctrl/server.go b/internal/ctrl/server.go index 212c07e..76fdc6b 100644 --- a/internal/ctrl/server.go +++ b/internal/ctrl/server.go @@ -56,6 +56,7 @@ type Server struct { shaping *Shaping torrent *Torrent + connlog *ConnLog metrics metricsCache monitor monitorJob cliPath string @@ -157,6 +158,7 @@ func New() (*Server, error) { s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen) s.shaping = newShaping(s) s.torrent = newTorrent(s) + s.connlog = newConnLog(s) return s, nil } @@ -185,5 +187,6 @@ func (s *Server) Run() error { go s.shaping.Apply() s.shaping.Start() go s.torrent.Apply() // nft-таблицы после перезагрузки нет — ставим заново + s.connlog.Start() return <-errc } diff --git a/internal/ui/connlog.go b/internal/ui/connlog.go new file mode 100644 index 0000000..3f65f17 --- /dev/null +++ b/internal/ui/connlog.go @@ -0,0 +1,78 @@ +package ui + +// Журнал VPN-подключений (вкладка «Подключения»): события ведёт awg-ctrl каждого сервера +// (internal/ctrl/connlog.go), панель забирает их у выбранного сервера и подставляет имена, которых +// awg-ctrl не знает: пир sen:// там — только владелец «<панель>/m<ключ>/d<устройство>». + +import ( + "net/url" + "regexp" + "strconv" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`) + +// ownerNames — название мастер-ключа и имя устройства для владельца пира. foreign — пир другой +// панели на том же awg-ctrl (у ноды может быть своя): её имён мы не знаем. +func (s *Sub) ownerNames(owner string, cache map[string][3]any) (key, device any, foreign bool) { + if v, ok := cache[owner]; ok { + return v[0], v[1], v[2].(bool) + } + m := reOwner.FindStringSubmatch(owner) + switch { + case m == nil, m[1] != "" && m[1] != s.panelID: // не наш формат или чужая панель + foreign = true + default: + mid, _ := strconv.ParseInt(m[2], 10, 64) + did, _ := strconv.ParseInt(m[3], 10, 64) + var label, name string + if s.a.db.QueryRow("SELECT label FROM master_keys WHERE id = ?", mid).Scan(&label) == nil { + key = label + } + if s.a.db.QueryRow("SELECT device_name FROM devices WHERE id = ?", did).Scan(&name) == nil { + device = name + } + } + cache[owner] = [3]any{key, device, foreign} + return key, device, foreign +} + +func (a *App) connlog(c *Ctx) bool { + q := url.Values{} + for _, k := range []string{"limit", "before", "q"} { + if v := c.R.URL.Query().Get(k); v != "" { + q.Set(k, v) + } + } + r, err := a.nodes.CtrlFor(a.serverIDOf(c))("GET", "/api/connlog?"+q.Encode(), nil) + if err != nil { + return c.Error(502, "awg-ctrl недоступен") + } + if r.Status == 404 { + return c.Error(501, "awg-ctrl на этом сервере старой версии и журнала не ведёт — обновите сервер") + } + data, ok := r.Data.(jsjson.Object) + if r.Status != 200 || !ok { + return c.JSON(r.Status, r.Data) + } + cache := map[string][3]any{} + enrich := func(list any) { + items, _ := list.([]any) + for i, it := range items { + o, ok := it.(jsjson.Object) + if !ok { + continue + } + if owner, _ := o.Get2("owner").(string); owner != "" && a.sub != nil { + key, dev, foreign := a.sub.ownerNames(owner, cache) + o = append(o, jsjson.KV{Key: "key", Val: key}, jsjson.KV{Key: "device", Val: dev}, jsjson.KV{Key: "foreign", Val: foreign}) + } + items[i] = o + } + } + enrich(data.Get2("events")) + enrich(data.Get2("peers")) + return c.JSON(200, data) +} diff --git a/internal/ui/connlog_test.go b/internal/ui/connlog_test.go new file mode 100644 index 0000000..fc4acdc --- /dev/null +++ b/internal/ui/connlog_test.go @@ -0,0 +1,30 @@ +package ui + +import "testing" + +// Владелец пира sen:// в журнале → название мастер-ключа и устройства этой панели. +func TestConnLogOwnerNames(t *testing.T) { + a := testApp(t, t.TempDir(), "on") + s := &Sub{a: a} + if err := s.initSchema(); err != nil { + t.Fatal(err) + } + s.panelID = "abcd1234" // initSchema заводит свой + + a.db.Exec("INSERT INTO master_keys (id, uuid, label, secret, created_at) VALUES (1, 'u1', 'Семья', x'01', 1)") + a.db.Exec("INSERT INTO devices (id, master_id, device_id, device_name, pub_key, auth_pub, created_at) VALUES (2, 1, 'd', 'iPhone Маши', 'W', 'A', 1)") + cache := map[string][3]any{} + for owner, want := range map[string][3]any{ + "abcd1234/m1/d2": {"Семья", "iPhone Маши", false}, + "m1/d2": {"Семья", "iPhone Маши", false}, // выдан до областей панели + "abcd1234/m1/d9": {"Семья", nil, false}, // устройство удалено + "abcd1234/m7/d9": {nil, nil, false}, // и ключ удалён + "ffff0000/m1/d2": {nil, nil, true}, // другая панель на том же awg-ctrl + "что-то/другое": {nil, nil, true}, + } { + k, d, f := s.ownerNames(owner, cache) + if k != want[0] || d != want[1] || f != want[2] { + t.Errorf("%s: %v %v %v, ждали %v", owner, k, d, f, want) + } + } +} diff --git a/internal/ui/server.go b/internal/ui/server.go index 462aa89..9aa19f7 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -879,6 +879,7 @@ func (a *App) routes() *Router { app.Use("/ui/metrics-token", auth) app.Mount("/ui/metrics-token", a.metrics.router()) app.Get("/metrics", a.metrics.requireToken, a.metrics.handler) + app.Get("/ui/connlog", auth, a.connlog) app.Use("/ui/monitor", auth) app.Mount("/ui/monitor", a.monitor.router()) // Перенос панели: приём архива — без JWT, его пускает только ключ из кода переноса. From ff2d71600cf9fd3c10286750f6c7bdcb017a7a79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:13:40 +0000 Subject: [PATCH 08/12] Rewrite the README Sections were added one feature at a time and ended up in arrival order, with the stealth notes inside "Installation". The README now has a feature summary and contents, an architecture section with the ports each server opens, installation with roles and a table of installer variables, update / reinstall / uninstall, a section on several servers and moving the panel (it pointed to docs/node-protocol.md, which is not in the repo), one "protection against detection" section covering randomized parameters, the quiet ports and what they do not cover, the connection log, monitoring, nginx, the API with the secret path in the base URL and the new connlog and metrics routes, CLI commands and the main cli.env settings. Also fixed on the way: the subscription is HTTPS by default, and a plain update does not add a secret path to an older install. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 427 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 280 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index af93a1d..93e8abe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,30 @@ # Forgetting -Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) +Панель управления и API для VPN на базе [AmneziaWG](https://github.com/amnezia-vpn/amneziawg-linux-kernel-module) > [!WARNING] > Данный проект предназначен только для использования в законных целях. + +- **VPN-пользователи** с ключами `vpn://` для AmneziaVPN и **мастер-ключи** `sen://` для SenAWG: + одна ссылка на несколько устройств, каждое регистрируется само. +- **Несколько серверов** под одной панелью: ноды подключаются к ней сами, панель можно + вынести на отдельный сервер без VPN. +- **Журнал подключений**: кто, когда и сколько был в VPN, кто давно не подключался. +- **Мониторинг**: встроенный Prometheus и графики в панели, метрики для своего Prometheus. +- **Защита от обнаружения**: параметры обфускации, порты и пути у каждого сервера свои, а + открытые порты отвечают сканеру как пустой веб-сервер. + +## Содержание + +- [Архитектура](#архитектура) +- [Требования](#требования) +- [Установка](#установка) · [Параметры установщика](#параметры-установщика) · [Обновление и удаление](#обновление-и-удаление) +- [Несколько серверов](#несколько-серверов) +- [Защита от обнаружения](#защита-от-обнаружения) +- [Журнал подключений](#журнал-подключений) +- [Мониторинг](#мониторинг) +- [Панель за nginx](#панель-за-nginx) +- [API](#api) +- [Управление](#управление) · [Настройки `cli.env`](#настройки-clienv) + ## Архитектура Один статический бинарник `awg-control` (Go) в `/opt/awg-control/` — в нём все роли; @@ -9,17 +32,34 @@ | Компонент | Роль | |---|---| -| **awg-ctrl** (`awg-control ctrl`) | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. | -| **awg-ui** (`awg-control ui`) | Веб-панель (фронтенд на React, `awg-ui/dist`), аутентификация (JWT), прокси к awg-ctrl, публичный API `/api/v1` и своя база его ключей. Единственный сервис, доступный по сети. | +| **awg-ctrl** (`awg-control ctrl`) | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу (`users.db`) и журнал подключений, генерирует `vpn://` ключи. Слушает только loopback. | +| **awg-ui** (`awg-control ui`) | Веб-панель (React, `awg-ui/dist`), вход по JWT, прокси к awg-ctrl, публичный API `/api/v1`, подписка `sen://`, хаб нод и `/metrics`. База — `ui.db`. | | **awg-agent** (`awg-control agent`) | Только на ноде: исходящее WSS-соединение к панели, исполняет её запросы на локальном awg-ctrl. | -| **cli** (`awg-ctrl …`) | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. | +| **cli** (`awg-ctrl …`) | Менеджер процессов, логин и пароль панели, привязка нод, перенос панели, мониторинг, TUI-меню. | -Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным. +Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только +у awg-ui, awg-ctrl проверяет подпись публичным. Данные — в `/etc/amnezia/amneziawg/` (вне +каталога установки, переустановку переживают), настройки сервисов — в +`/opt/awg-control/cli/cli.env`. + +Порты наружу (номера у каждого сервера случайные): + +| Порт | Что | Кто открывает | +|---|---|---| +| `SERVER_PORT`/udp | VPN-клиенты | везде, где есть VPN | +| `UI_PORT`/tcp | панель и API, под секретным путём | панель | +| `SUB_PORT`/tcp | подписка `sen://` (по умолчанию HTTPS) | панель | +| `NODE_PORT`/tcp | хаб нод (WSS) | панель, если принимает ноды | + +Нода открывает только UDP для клиентов: к панели она подключается сама. ## Требования -- **ОС**: Ubuntu 24.04+ (Тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и переопределяется через `AMNEZIA_PPA_SUITE`) +- **ОС**: Ubuntu 24.04+ (тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка + экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и + переопределяется через `AMNEZIA_PPA_SUITE`). - **Права**: root. -- **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG). +- **Виртуализация**: KVM или железо (не OpenVZ/LXC/Docker/WSL — нужен загружаемый + kernel-модуль AmneziaWG). - **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля). - **Архитектура**: x86_64 или aarch64. @@ -34,103 +74,180 @@ bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/instal ``` Не от root — через `sudo bash -c`: `sudo bash <(curl …)` падает с -`/dev/fd/63: No such file or directory`, потому что `sudo` закрывает дескриптор -с текстом скрипта. +`/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 можно так: +Установщик спросит роль сервера: -```bash -AWG_GEN=auto bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh) -``` +| Роль | Что ставится | +|---|---| +| **Панель + VPN** (`standalone`) | Всё на одной машине; к ней можно подключать ноды | +| **Только панель** (`core`) | Панель без VPN; серверы подключаются к ней нодами | +| **Нода** (`node`) | VPN без панели; подключается к панели по строке `awgjoin://…` («Добавить сервер» в панели) | -Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели — -переустановка не нужна, пользователи сохраняются, ключи перевыпускаются -автоматически. +В конце установщик печатает адрес панели **вместе с секретным путём** — +`http://IP:PORT/<путь>/` — логин и пароль. Без пути панель отвечает `404`, поэтому адрес +сохраните целиком; позже его покажут `awg-ctrl status` и `awg-ctrl credentials`. -Источник архива и имя панели не зашиты в код — задаются переменными -`REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика -(подробности — в комментариях `install.sh` и в `.env`). +Порты на самом сервере установщик открывает сам (UFW, firewalld или iptables — что +активно). **Фаервол хостера** (панель провайдера, security group) отсюда не виден: открой там +порты из итога установки, иначе клиенты не подключатся, а на сервере всё будет выглядеть +исправным. -### Удаление +Сервер ставится на AmneziaWG 3.1. Если модуль в ядре старше 3.x или ядро старше 5.5, +установка остановится с диагностикой — поставить на 2.0 можно через `AWG_GEN=auto`. Перевести +сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели: пользователи сохраняются, +ключи перевыпускаются автоматически. -Тот же установщик: на сервере с установкой — пункт **3 «Удалить»**, без меню — -`UNINSTALL=y` (подтверждение «удалить» тогда не спрашивается): +### Параметры установщика -```bash -UNINSTALL=y bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh) -``` +Задаются переменными окружения перед запуском, например +`ROLE=node JOIN='awgjoin://…' bash <(curl -Ls …/install.sh)`. Всё необязательно. -Удаляются сервисы и юнит systemd, интерфейс `awg1`, nft-таблицы шейпинга и блокировки -торрентов, открытые установщиком порты (UFW, firewalld или iptables), Prometheus из -`awg-ctrl monitor install`, `/opt/awg-control`, `/etc/amnezia` (пользователи, ключи, базы), -команда `awg-ctrl`, sysctl-файл и логи установки (в них пароль панели). Перед этим установщик -предлагает сохранить копию данных в `/root/forgetting-backup-<время>.tar.gz`. AmneziaWG -(пакеты, модуль, PPA) — по отдельному вопросу; общие зависимости (dkms, build-essential, -iptables, заголовки ядра) остаются. Ноду после удаления уберите и из списка серверов панели. +| Переменная | Что делает | +|---|---| +| `ROLE` | `standalone`, `core` или `node` — без вопроса | +| `JOIN` | строка `awgjoin://…` для ноды | +| `AWG_GEN` | `3.1` (по умолчанию), `auto` — 3.1, если ядро позволяет, иначе 2.0; `2.0` — не трогать модуль | +| `AWG_PIN` | `y` (по умолчанию) — закрепить версию пакетов AmneziaWG (`apt-mark hold`) | +| `AWG_PORT` | UDP-порт VPN вместо случайного | +| `AWG_MTU` | MTU туннеля вместо случайного (1280–1500) | +| `UI_PREFIX` | свой секретный путь панели (8–64 символа `A–Z a–z 0–9 _ -`); `off` — без пути | +| `SUB_PORT`, `SUB_TLS`, `NODE_PORT` | порт и HTTPS подписки, порт хаба нод — без вопросов | +| `UNINSTALL` | `y` — удалить, см. ниже | +| `AMNEZIA_PPA_SUITE` | серия PPA для Debian (`focal`/`jammy`/`noble`) | +| `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `AWG_LOCAL_TARBALL` | откуда брать архив релиза (своё зеркало, локальный файл) | +| `BRAND`, `CHANNEL` | имя продукта в баннерах и панели | -### Секретный путь панели +### Обновление и удаление -Установщик выдаёт панели случайный путь: она открывается по `http://IP:PORT/<путь>/`, а на -любой другой адрес отвечает `404 page not found`, как пустой веб-сервер. Порт панели открыт -в интернет, и без пути сканер узнавал её по странице логина. Путь хранится в `UI_PREFIX` -в `/opt/awg-control/cli/cli.env`; полный адрес показывают `awg-ctrl status` и -`awg-ctrl credentials`. Внешнему API нужен тот же путь: `http://HOST:PORT/<путь>/api/v1/…` -(в примерах ниже `HOST:PORT` — вместе с путём). +Повторный запуск установщика на сервере с установкой предлагает: -- При переустановке с сохранением данных путь остаётся прежним. Свой путь — - `UI_PREFIX=<8–64 символа A–Z a–z 0–9 _ ->`, без пути — `UI_PREFIX=off` перед запуском - установщика (или правка `cli.env` и `awg-ctrl restart`). -- С этой же машины (loopback) путь не нужен: CLI, локальный Prometheus и nginx ниже - ходят как раньше. -- При переносе панели (вкладка «Перенос», `awg-ctrl migrate`) новая панель принимает и свой - путь, и путь старой (`UI_PREFIX_MOVED`) — закладки и API через ретранслятор продолжают - работать. Пока выдан код переноса, `POST /migrate/v1/import` принимается и без пути. +1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. `cli.env` не + меняется, поэтому секретного пути у установки, где его не было, не появится — включить: + `UI_PREFIX=<путь>` в `cli.env` и `awg-ctrl restart` (или переустановка с сохранением данных, + она выдаст путь сама). +2. **Переустановить** — с нуля; пользователей и ключ сервера можно сохранить. Тогда сохраняются + и порт, параметры обфускации, MTU и секретный путь — они зашиты в выданные ключи и ссылки. +3. **Удалить** — полностью (без меню: `UNINSTALL=y`, подтверждение «удалить» тогда не + спрашивается). -### Порт подписки sen:// +Удаление снимает сервисы и юнит systemd, интерфейс `awg1`, nft-таблицы шейпинга и блокировки +торрентов, открытые установщиком порты, Prometheus из `awg-ctrl monitor install`, +`/opt/awg-control`, `/etc/amnezia` (пользователи, ключи, базы), команду `awg-ctrl`, sysctl-файл +и логи установки (в них пароль панели). Перед этим установщик предлагает сохранить копию данных +в `/root/forgetting-backup-<время>.tar.gz`. AmneziaWG (пакеты, модуль, PPA) — по отдельному +вопросу; общие зависимости (dkms, build-essential, iptables, заголовки ядра) остаются. Ноду +после удаления уберите и из списка серверов панели. -Подписка отвечает по протоколу только тем, кто доказал знание секрета: `peek`/`register` — -с секретом мастер-ключа из ссылки, остальные запросы — с подписью ключом устройства. На всё -прочее (другие пути и методы, битое тело, чужой секрет, подобранный id устройства, неверная -подпись) — `404 page not found`, как пустой веб-сервер. Удалённому устройству `410` уходит, -только если запрос подписан его ключом (они хранятся в `revoked_devices`); устройства, -удалённые до этой версии, получают тот же тихий 404. Самоподписанные сертификаты подписки -(`SUB_TLS=on`) и хаба нод создаются со случайным именем вместо общих `sen` / `awg-node`: -клиенты и ноды сверяют ключ, а не имя, так что прежние сертификаты продолжают работать. +## Несколько серверов -### Порт хаба нод +**Подключить сервер.** В панели «Добавить сервер» выдаёт строку `awgjoin://…` (действует час). +На новом сервере — установщик с ролью «Нода» и этой строкой, или на уже установленном — +`awg-ctrl join ''`. Агент ноды сам подключается к хабу панели по WSS; доверие — +закреплённый отпечаток сертификата хаба и ключ ноды. В панели нода появляется в списке +серверов, ею управляют так же, как своим. `awg-ctrl unjoin` — отвязать. -Агент ноды подписывает своим ключом уже сам запрос на WebSocket (заголовки `X-Node-Id`, -`X-Node-Ts`, `X-Node-Sig`, при первой привязке — ещё секрет из строки `awgjoin://`). Хаб -проверяет подпись до апгрейда и без неё отвечает `404 page not found`, как пустой сервер: -одним подключением к `/node/v1` его больше не узнать. Агенты прежних версий подписи не шлют — -хаб пускает их, пока в панели есть нода, ни разу не подключавшаяся по-новому, и пишет об этом -в лог. Обновили все ноды — путь без подписи закрывается сам. `HUB_LEGACY=on` / `off` в -`cli.env` (и `awg-ctrl restart`) — принудительно. Часы ноды могут расходиться с панелью до -суток. +**Перенести панель на отдельный сервер** (например, без VPN). Вкладка «Перенос» или CLI: -### Внешний Prometheus +1. На новом сервере — установка с ролью «Только панель». +2. На новой панели — код переноса `awgmove://…`; на старой — вставить его (или + `awg-ctrl migrate export` на старом и `awg-ctrl migrate import <архив>` на новом). +3. Старый сервер становится нодой #0 новой панели и пересылает туда свои прежние порты: + ссылки `sen://`, ноды и внешний API продолжают работать, закладки панели — тоже (новая + панель принимает и свой секретный путь, и старый). +4. Когда вкладка «Перенос» покажет, что новый адрес знают все устройства и ноды, старый + сервер можно выключать. Ноды, переехавшие вручную, — `awg-ctrl rehome <хост[:порт]>`. -Prometheus на ноде («Подключить Prometheus» у панели без своего VPN) от секретного пути не -зависит: он забирает метрики у агента на `127.0.0.1`, а тот — у панели внутри TLS-соединения -с хабом. Снаружи этот трафик не отличить от обычной связи ноды с панелью. +`awg-ctrl migrate cancel` — отменить на старом сервере, пока перенос не завершён. -Для стороннего сервера мониторинга — вкладка «Монитор» → «Внешний Prometheus»: выдать токен «только метрики» и скопировать готовый -фрагмент `prometheus.yml`. Адрес в нём берётся от страницы панели, поэтому уже содержит -секретный путь (`metrics_path: /<путь>/metrics`) или подпапку nginx. Без пути `/metrics` -снаружи отвечает 404. Панель по голому HTTP передаёт токен и метрики открытым текстом — -внешний скрейп лучше вести через nginx с TLS или по VPN. +## Защита от обнаружения -### Панель за nginx +Раньше у всех установок были одинаковые порт, параметры обфускации и ответы на запросы — одно +правило DPI находило их все. Теперь общего у серверов нет: -Панель работает и в корне домена, и в любой подпапке — пути в ней относительные, -настраивать сам префикс не нужно. Пример для `https://example.com/forgetting/` -(`39708` — `PORT` панели из `/opt/awg-control/cli/cli.env`): +**VPN.** На новой установке случайны: +- UDP-порт (20000–59999), если не задан `AWG_PORT`; +- параметры обфускации `Jc/Jmin/Jmax`, `S1–S4` (размеры handshake-пакетов разные — по длине + тип пакета не узнать), `H1–H4` (непересекающиеся диапазоны); +- `I1` — пакет-маскировка перед handshake: DNS-запрос к случайному популярному домену (формат + проверяется до записи — его принимают и kernel-модуль, и amneziawg-go); +- MTU в диапазоне 1320–1376. + +При переустановке с сохранением данных всё это берётся из прежнего `awg1.conf`: параметры +зашиты в выданные ключи. Поэтому **серверы, поставленные до рандомизации, сохраняют старые общие +значения** — чтобы уйти от них, нужна переустановка без сохранения данных и раздача новых +ключей. Исключение — `I1`: его сервер не сверяет, и если в конфиге его не было, при переустановке +он генерируется новым, не ломая старые ключи. + +**Открытые порты** отвечают тому, кто не доказал право на ответ, как пустой веб-сервер на Go — +`404 page not found`: + +| Порт | Кому отвечает по протоколу | +|---|---| +| Панель | запросам под секретным путём (`UI_PREFIX`); с loopback — и без него | +| Подписка `sen://` | `peek`/`register` с секретом мастер-ключа из ссылки; остальным запросам — с подписью ключом устройства | +| Хаб нод | агенту, подписавшему своим ключом сам запрос на WebSocket | + +Подробности: +- `GET /ui/brand` (имя и версия) — только после входа; экран логина не показывает имя продукта. +- Удалённому устройству подписка отвечает `410` (снять конфиг), только если запрос подписан его + ключом (`revoked_devices`); устройства, удалённые до этой версии, получают тихий `404`. +- Пока выдан код переноса, `POST /migrate/v1/import` принимается и без секретного пути. +- Агенты нод прежних версий подписи не шлют: хаб пускает их, пока в панели есть нода, ни разу + не подключавшаяся по-новому, и пишет об этом в лог. Обновили все ноды — путь без подписи + закрывается сам (`HUB_LEGACY` в `cli.env` — принудительно). Часы ноды могут расходиться с + панелью до суток. +- Самоподписанные сертификаты подписки и хаба получают случайное имя вместо общих `sen` / + `awg-node`. Клиенты и ноды сверяют ключ, а не имя; прежние сертификаты остаются со старым именем. + +**Чего это не закрывает.** Секретный путь и тихий `404` защищают от активного сканирования. +Панель по умолчанию работает по голому HTTP — её трафик (пути, JSON, токены) виден в сети. +Ставьте её за nginx с TLS ([ниже](#панель-за-nginx)) или заходите по VPN. Подписка `sen://` +по умолчанию HTTPS, хаб — всегда TLS. + +## Журнал подключений + +Вкладка «Подключения» показывает по выбранному серверу: + +- **События** — кто подключился к VPN и отключился, когда, сколько длилась сессия и сколько + скачал и отдал. Поиск — по имени пользователя, мастер-ключу, устройству или внутреннему IP. +- **Кто когда был** — все пользователи и устройства `sen://` сервера: онлайн сейчас, когда + были на связи последний раз и кто не подключался ни разу с начала журнала. + +Журнал ведёт awg-ctrl каждого сервера (`conn_log` в `users.db`, 90 дней). У WireGuard нет событий +подключения, поэтому раз в 30 с он сверяет время handshake и счётчики трафика: «подключился» — +время первого handshake, «отключился» — последняя активность (точность около 30 с). Перезапуск +awg-ctrl сессии не рвёт, падение интерфейса отключением не считается. Внешний IP клиентов не +записывается. Серверы на прежней версии журнала не ведут — вкладка попросит их обновить. + +## Мониторинг + +**Встроенный Prometheus.** Вкладка «Монитор» → «Подключить Prometheus»: панель просит awg-ctrl +поставить его (`awg-ctrl monitor install`, только Debian/Ubuntu). Он слушает только +`127.0.0.1:9090`, хранит 31 день и забирает метрики **всех серверов** панели. Графики — скорость, +устройства онлайн, нагрузка, CPU и память — появляются через минуту-две. Удалить — там же или +`awg-ctrl monitor remove`. + +У панели без своего VPN Prometheus ставится на ноду (выберите её в списке серверов). Он +забирает метрики у агента на `127.0.0.1`, а агент — у панели внутри TLS-соединения с хабом; +панель читает графики через того же агента. Снаружи этот трафик не отличить от обычной связи +ноды с панелью, секретный путь ему не нужен. Агенты старше 0.4.6 этого не умеют. + +**Свой Prometheus.** Вкладка «Монитор» → «Внешний Prometheus»: выдать токен «только метрики» +(он не открывает ни панель, ни API) и скопировать готовый фрагмент `prometheus.yml`. Адрес в нём +берётся от страницы панели, поэтому уже содержит секретный путь +(`metrics_path: /<путь>/metrics`) или подпапку nginx. По голому HTTP токен и метрики идут +открытым текстом — скрейпьте через nginx с TLS или по VPN. Метрики по отдельным пирам +выключены (`METRICS_PEERS=on` в `cli.env` — включить). + +## Панель за nginx + +Панель работает и в корне домена, и в любой подпапке — пути в ней относительные. Пример для +`https://example.com/forgetting/` (`39708` — `UI_PORT` из `/opt/awg-control/cli/cli.env`): ```nginx location = /forgetting { return 301 /forgetting/; } @@ -143,35 +260,34 @@ location ^~ /forgetting/ { } ``` -Адрес клиента панель берёт из `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`. От остальных -заголовки игнорируются. Без этого все входы шли бы с адреса прокси и делили один -лимит неудачных попыток. +nginx на этой же машине ходит к панели по loopback, поэтому секретный путь ему не нужен — +подпапка в nginx и становится путём панели (выберите неочевидную). Адрес клиента панель берёт +из `X-Forwarded-For` / `X-Real-IP` только от доверенного прокси: с loopback — всегда, с других +адресов — если они перечислены в `TRUST_PROXY`. Без этого все входы шли бы с адреса прокси и +делили один лимит неудачных попыток. -Сам порт панели после этого можно закрыть снаружи (`ufw deny /tcp`) — порты -подписки (`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты -и ноды ходят на них напрямую, не через nginx. +Сам порт панели после этого можно закрыть снаружи (`ufw deny /tcp`). Порты подписки +(`SUB_PORT`) и хаба нод (`NODE_PORT`) должны оставаться открытыми: клиенты и ноды ходят на них +напрямую, не через nginx. ## API -Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` — -стабильный контракт для сторонних программ, авторизация по API-ключу. -**Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый -URL в обоих случаях — адрес сервера и порт, выбранный при установке. +Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` — стабильный контракт +для сторонних программ, авторизация по API-ключу. **Роуты панели** — всё, что умеет +веб-интерфейс, авторизация по JWT. + +В примерах `HOST:PORT` — адрес панели **вместе с секретным путём**: +`http://203.0.113.9:39708/k3f9xQ2mTq8vLw0a`. Без пути снаружи любой запрос получает `404`. > [!WARNING] -> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не -> публичный контракт, в отличие от `/api/v1`. Не выставляй её порт в интернет -> без TLS и ограничения по адресам. +> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не публичный контракт, +> в отличие от `/api/v1`. По голому HTTP они идут открытым текстом: ставьте панель за TLS. ### Публичный API (`/api/v1`) -Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`. -Открытое значение показывается один раз при создании — на сервере лежит только -SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля: -`psk_key` и `pub_key` не покидают сервер. +Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`. Открытое значение +показывается один раз при создании — на сервере лежит только SHA-256 и префикс для показа в +списке. Наружу отдаются только публичные поля: `psk_key` и `pub_key` не покидают сервер. ```bash curl -s -X POST http://HOST:PORT/api/v1/users \ @@ -186,22 +302,20 @@ curl -s -X POST http://HOST:PORT/api/v1/users \ | `GET /api/v1/users/:name` | `{ name, ip, gen, vpn_key }`; `404` — не найден | | `DELETE /api/v1/users/:name` | `{ "success": true, "name": "alice" }` | -`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или -`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить» -ключ = удалить и создать заново. +`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или `"3.1"`). Операции +только CRD: у пира нет изменяемых полей, «перевыпустить» ключ = удалить и создать заново. -У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка; -`0` — сервер самой панели, остальные — подключённые ноды). Пользователи `vpn://` -заводятся на сервере ключа по умолчанию; другой сервер из набора выбирается -заголовком `X-Server-Id: `, сервер вне набора → `404`. +У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка; `0` — сервер +самой панели, остальные — подключённые ноды). Пользователи `vpn://` заводятся на сервере ключа +по умолчанию; другой сервер из набора выбирается заголовком `X-Server-Id: `, сервер вне +набора → `404`. #### Мастер-ключи `sen://` -Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое -устройство само регистрируется по ссылке и получает пира на всех серверах ключа, -его приватный ключ сервер не видит. API-ключ видит и меняет мастер-ключ, только -если **все** его серверы входят в набор API-ключа; остальные для него не -существуют (`404`). +Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое устройство +само регистрируется по ссылке и получает пира на всех серверах ключа, его приватный ключ сервер +не видит. API-ключ видит и меняет мастер-ключ, только если **все** его серверы входят в набор +API-ключа; остальные для него не существуют (`404`). ```bash curl -s -X POST http://HOST:PORT/api/v1/masterkeys \ @@ -229,12 +343,11 @@ curl -s -X POST http://HOST:PORT/api/v1/masterkeys \ | `401 {"error":"Invalid API key"}` | ключ не найден — удалён или неверен | | `502 {"error":"awg-ctrl недоступен"}` | awg-ui посредничает, а awg-ctrl не отвечает | -Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне -каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про -внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией. -Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` (с полем -`servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не -перевыпуская ключ, и `DELETE /ui/apikeys/:id`. Подробно — `awg-ui/API.md`. +Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`), поэтому переустановка их +не трогает. Сама awg-ctrl про внешние ключи ничего не знает: awg-ui ходит к ней своей +внутренней авторизацией. Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` +(с полем `servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не +перевыпуская ключ, и `DELETE /ui/apikeys/:id`. ### Авторизация панели @@ -249,19 +362,20 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \ | `POST /login` | `{ "token": "" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут | | `POST /logout` | `{ "ok": true }`, токен отзывается немедленно | -Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые -дёргает сама панель; в отличие от `/api/v1` их форма может меняться. +Дальше в каждый запрос: `Authorization: Bearer $TOKEN`; сервер — заголовком `X-Server-Id` +(без него — сервер по умолчанию). Ниже — роуты, которые дёргает сама панель; в отличие от +`/api/v1` их форма может меняться. ### Состояние | Метод | Ответ | |---|---| -| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.7" }` — требует JWT панели (без авторизации выдавал бы панель сканерам). Вордмарк в шапке после входа | +| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.7" }` | | `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | -`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс: -`"2"` или `"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно. +`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс: `"2"` или +`"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно. ### Пользователи @@ -281,8 +395,16 @@ curl -s -X POST http://HOST:PORT/api/users \ ``` Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс `vpn://`, -base64url-декодировать, отбросить первые 4 байта (длина), распаковать -`zlib inflate` → JSON; текст конфига лежит в `last_config.config`. +base64url-декодировать, отбросить первые 4 байта (длина), распаковать `zlib inflate` → JSON; +текст конфига лежит в `last_config.config`. + +### Журнал подключений + +| Метод | Ответ | +|---|---| +| `GET /ui/connlog` | Журнал сервера из `X-Server-Id`: `{ events: [{ id, at, event, name, owner, ip, key?, device?, duration?, rx?, tx? }], peers: [{ name, owner, ip, online, last_seen, key?, device? }], since, keep_days }`. `event` — `connect` или `disconnect` (у него `duration`, `rx`, `tx` — со стороны сервера); `key`/`device` — мастер-ключ и устройство `sen://`. Параметры: `limit` (до 500), `before=` — следующая страница, `q` — поиск по имени, владельцу, IP. `501` — сервер старой версии | + +Без имён `sen://` тот же журнал отдаёт awg-ctrl: `GET /api/connlog`. ### Интерфейс AWG @@ -292,10 +414,9 @@ base64url-декодировать, отбросить первые 4 байта | `POST /awg/restart` | `awg-quick down/up` + ресинк пиров, `{ "success": true }`. Соединения клиентов кратковременно рвутся | | `POST /awg/upgrade` | Переводит сервер с 2.0 на 3.1: правит `awg1.conf`, перезапускает интерфейс и перевыпускает все ключи. `{ gen, backup, bumpedS: [], reissue: { total, reissued, … } }` | -`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не -3.x или ядро старше 5.5, и `500` с текстом причины, если ядро не приняло -3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс -поднимается обратно на 2.0. +`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не 3.x или ядро +старше 5.5, и `500` с текстом причины, если ядро не приняло 3.1-конфиг — в этом случае прежний +конфиг возвращается из копии, а интерфейс поднимается обратно на 2.0. ### Мастер-ключи @@ -314,32 +435,27 @@ base64url-декодировать, отбросить первые 4 байта | `DELETE /ui/devices/:id` | Отвязать устройство | | `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства | -## Журнал подключений +### Метрики -Вкладка «Подключения» показывает по выбранному серверу: - -- **События** — кто подключился к VPN и отключился, когда, сколько длилась сессия и сколько - скачал и отдал. Поиск — по имени пользователя, мастер-ключу, устройству или внутреннему IP. -- **Кто когда был** — все пользователи и устройства sen:// сервера: онлайн сейчас, когда были на - связи последний раз и кто не подключался ни разу с начала журнала. - -Журнал ведёт awg-ctrl каждого сервера (`conn_log` в `users.db`, 90 дней). У WireGuard нет событий -подключения, поэтому раз в 30 с он сверяет время handshake и счётчики трафика: «подключился» — -время первого handshake, «отключился» — последняя активность (точность около 30 с). Перезапуск -awg-ctrl сессии не рвёт, падение интерфейса отключением не считается. Внешний IP клиентов не -записывается. Тот же журнал по API: `GET /api/connlog?limit=&before=&q=`. Серверы на прежней -версии журнала не ведут — вкладка попросит их обновить. +| Метод | Что делает | +|---|---| +| `GET /ui/metrics-token` | `{ exists, prefix?, created_at?, last_used?, per_peer }` — токен для своего Prometheus | +| `POST /ui/metrics-token` | Выпустить или перевыпустить: `201` с `token` (показывается один раз); старый перестаёт работать | +| `DELETE /ui/metrics-token` | Отозвать | +| `GET /metrics` | Экспозиция Prometheus по всем серверам; `Authorization: Bearer awgm_…` | ## Управление ```bash awg-ctrl # меню -awg-ctrl start|stop|restart|status -awg-ctrl credentials [user|pass] +awg-ctrl start|stop|restart|status # status показывает и адрес панели с путём +awg-ctrl credentials [user|pass] # логин и пароль панели, адрес awg-ctrl join '' [--no-panel] / unjoin awg-ctrl rehome <хост[:порт]> # нода: панель переехала на новый адрес -awg-ctrl migrate export [файл] # перенос панели на отдельный сервер без VPN -awg-ctrl migrate import <архив> # (порядок — docs/node-protocol.md, «Перенос панели») +awg-ctrl peers [drop <область>] # пиры sen:// на этом сервере по панелям +awg-ctrl monitor install|status|remove # встроенный Prometheus +awg-ctrl migrate export [файл] # перенос панели (см. «Несколько серверов») +awg-ctrl migrate import <архив> awg-ctrl migrate cancel ``` @@ -349,5 +465,22 @@ awg-ctrl migrate cancel systemctl start|stop|restart awg-control ``` +### Настройки `cli.env` + +`/opt/awg-control/cli/cli.env` пишет установщик; после правки — `awg-ctrl restart`. Чаще всего +нужны: + +| Переменная | Что делает | +|---|---| +| `UI_PORT`, `UI_USER`, `UI_PASS` | порт, логин и пароль панели (`awg-ctrl credentials`) | +| `UI_PREFIX` | секретный путь панели; пусто — без пути | +| `UI_PREFIX_MOVED` | путь панели, переехавшей сюда: принимается вместе со своим | +| `SUB_PORT`, `SUB_TLS`, `SUB_HOST` | подписка `sen://`: порт, HTTPS, адрес в ссылках | +| `NODE_PORT` | хаб нод; пусто — ноды не принимаются | +| `HUB_LEGACY` | `on` / `off` — пускать ли агентов нод без подписи; пусто — пока не обновлены все | +| `TRUST_PROXY` | IP или сети reverse-proxy, кроме loopback: от них верим `X-Forwarded-For` | +| `METRICS_PEERS` | `on` — метрики по отдельным пирам | +| `AUDIT` | `off` — выключить периодический аудит пиров | + ## Лицензия [MIT](LICENSE) © 2026 Ivan Vasilev From 1169a0adc7a56f4b64f733b7dda179f76b366cc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:27:08 +0000 Subject: [PATCH 09/12] Tidy up the installer Layout: the file now reads top to bottom as output helpers, constants, functions and the scenario. Functions were scattered between executable code (some defined inside steps), the previous role was computed twice, and the firewall code sat in the middle of a step. The firewall is now a function and a step of its own; installing the awg-ctrl command moved into the services step, so there are still six steps. Fixes: - the shebang was on line 4, after the license comment, so it did nothing; - a reinstall removed /opt/awg-control before the configuration questions, so answering "no" to "Continue?" left the server with no installation; it is now removed only after the confirmation; - with kept data the panel port was asked again with a new random default, which broke bookmarks and the API address even though the secret path was kept; the panel port, login, password and JWT secret now default to the previous values; - UI_PORT, UI_USER, UI_PASS and SERVER_NAME from the environment were overwritten by the prompts; they now skip them, as the README says; - when the user database existed but was not kept, the server name was not asked and silently became "VPN"; - random ports are checked to be free and distinct from each other; - the server IP from ifconfig.me is validated, with a fallback to the source address of the default route; - prompts are all in Russian; a panel with VPN can pick a random node port with "+"; - comments no longer point to docs/node-protocol.md, which is not in the repo; - shellcheck -S warning is clean. An update now gives a secret path to an install that has none and prints the new panel address (UI_PREFIX=off, or an empty UI_PREFIX= in cli.env, keeps it without one). The summary shows whether the panel has a secret path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 16 +- install.sh | 1191 +++++++++++++++++++++++++++------------------------- 2 files changed, 639 insertions(+), 568 deletions(-) diff --git a/README.md b/README.md index 93e8abe..c5db2cd 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,9 @@ sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/ma | `AWG_PIN` | `y` (по умолчанию) — закрепить версию пакетов AmneziaWG (`apt-mark hold`) | | `AWG_PORT` | UDP-порт VPN вместо случайного | | `AWG_MTU` | MTU туннеля вместо случайного (1280–1500) | +| `UI_PORT`, `UI_USER`, `UI_PASS` | порт, логин и пароль панели — без вопросов | | `UI_PREFIX` | свой секретный путь панели (8–64 символа `A–Z a–z 0–9 _ -`); `off` — без пути | +| `SERVER_NAME` | имя VPN-сервера — без вопроса | | `SUB_PORT`, `SUB_TLS`, `NODE_PORT` | порт и HTTPS подписки, порт хаба нод — без вопросов | | `UNINSTALL` | `y` — удалить, см. ниже | | `AMNEZIA_PPA_SUITE` | серия PPA для Debian (`focal`/`jammy`/`noble`) | @@ -126,12 +128,14 @@ sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/ma Повторный запуск установщика на сервере с установкой предлагает: -1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. `cli.env` не - меняется, поэтому секретного пути у установки, где его не было, не появится — включить: - `UI_PREFIX=<путь>` в `cli.env` и `awg-ctrl restart` (или переустановка с сохранением данных, - она выдаст путь сама). -2. **Переустановить** — с нуля; пользователей и ключ сервера можно сохранить. Тогда сохраняются - и порт, параметры обфускации, MTU и секретный путь — они зашиты в выданные ключи и ссылки. +1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. Установке без + секретного пути панели он выдаётся, и установщик печатает новый адрес: закладки и адрес + внешнего API нужно обновить. Оставить без пути — `UI_PREFIX=off` перед запуском или пустое + `UI_PREFIX=` в `cli.env`. +2. **Переустановить** — с нуля; пользователей и ключ сервера можно сохранить. Тогда по умолчанию + остаются и порты (VPN, панели, подписки, хаба), параметры обфускации, MTU, секретный путь, + логин и пароль панели — они зашиты в выданные ключи, ссылки и закладки. Прежняя установка + убирается только после ответа «да» на «Продолжить?». 3. **Удалить** — полностью (без меню: `UNINSTALL=y`, подтверждение «удалить» тогда не спрашивается). diff --git a/install.sh b/install.sh index 87b7ca3..a8884f6 100644 --- a/install.sh +++ b/install.sh @@ -1,7 +1,15 @@ +#!/usr/bin/env bash # Copyright (c) 2026 Ivan Vasilev # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. -#!/usr/bin/env bash +# +# Установщик Forgetting: панель управления VPN на AmneziaWG. +# bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh) +# Поверх установки — меню: обновить, переустановить, удалить. Параметры (ROLE, JOIN, AWG_GEN, +# AWG_PORT, AWG_MTU, UI_PREFIX, UNINSTALL…) задаются окружением — таблица в README, +# «Параметры установщика». +# +# Порядок файла: помощники вывода → константы → функции → сценарий (с «Сценарий» ниже). set -euo pipefail @@ -56,6 +64,9 @@ rand_alnum() { head -c 1024 /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' | head -c "$1" } +rand_int() { shuf -i "$1-$2" -n 1; } +url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; } + # Вопрос: ask VAR "текст: " [secret]. Введённое печатает сам терминал — в поток вывода, а значит # и в лог, оно не попадает. Поэтому после ответа строка «вопрос ответ» печатается ещё раз: на # экране — поверх себя же (курсор на строку вверх), в логе — целиком. Скрытый ввод — точками. @@ -78,6 +89,16 @@ ask() { printf -v "$__var" '%s' "$__ans" } +# ask_def VAR "вопрос" ПО_УМОЛЧАНИЮ [secret] — спросить, только если VAR не задана окружением; +# пустой ответ — значение по умолчанию. +ask_def() { + local __v="$1" + [[ -n "${!__v:-}" ]] && return 0 + ask "$__v" "$2" "${4:-}" + [[ -n "${!__v}" ]] || printf -v "$__v" '%s' "$3" +} + +# shellcheck disable=SC2154 # rc присваивается в самой строке trap trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} строка ${LINENO} код ${rc}\n команда: ${BASH_COMMAND}\n полный лог: ${LOGFILE:-<ещё не открыт>}" >&2' ERR [[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh" @@ -97,6 +118,7 @@ LOG_PID="${!:-}" # Дождаться, пока tee допишет хвост, — иначе последние строки вылезут уже после приглашения шелла. trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT +# ═══ Константы ═════════════════════════════════════════════════════════════════ # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # При бампе версии правь оба места. @@ -124,7 +146,6 @@ case "$(uname -m)" in esac ARCHIVE_NAME="awgcontrol-${VERSION}-linux-${GOARCH:-unknown}.tar.gz" ARCHIVE_URL="${ARCHIVE_URL:-}" -url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; } PROJECT="/opt/awg-control" AMNEZIA_DIR="/etc/amnezia" AWG_DIR="$AMNEZIA_DIR/amneziawg" @@ -142,7 +163,7 @@ INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key" SUB_SIGN_PRIV="$AWG_DIR/sub_sign.key" SUB_TLS_KEY="$AWG_DIR/sub_tls.key" SUB_TLS_CRT="$AWG_DIR/sub_tls.crt" -# Несколько серверов (docs/node-protocol.md). Хаб панели: самоподписанный сертификат, его +# Несколько серверов (README, «Несколько серверов»). Хаб панели: самоподписанный сертификат, его # отпечаток зашит в строки подключения нод — живёт столько же, сколько панель. Нода: свой # Ed25519-ключ, которым она представляется панели. NODE_TLS_KEY="$AWG_DIR/node_tls.key" @@ -157,12 +178,57 @@ MTU="${AWG_MTU:-}" # MTU туннеля; пусто — выбирает choos # ссылка на него. BIN="$PROJECT/awg-control" -echo -echo -e " ${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}" -echo -e " ${DIM}Панель управления VPN на AmneziaWG — установка${NC}" -echo -e " ${DIM}Лог: $LOGFILE${NC}" -rule +# Поколение протокола и закрепление версии пакета — управляются переменными +# окружения, задаются ДО запуска: `AWG_GEN=auto bash install.sh`. +# AWG_GEN=3.1 (по умолчанию) — основное поколение: если модуль в ядре не 3.x +# или ядро старше 5.5, install.sh падает с диагностикой. Тихий откат на 2.0 +# здесь нежелателен: он молча выдал бы сервер со слабой обфускацией. +# AWG_GEN=auto — старое поведение: 3.1, если условия выполняются, иначе 2.0 +# без вопросов. Для установки на заведомо старое ядро/модуль. +# AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0. +# Сервер, установленный на 2.0, переводится на 3.1 позже из панели +# (POST /awg/upgrade) — переустановка для этого не нужна. +AWG_GEN="${AWG_GEN:-3.1}" +[[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)" +# AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools} +# через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым +# сервером (issue #215 — именно так словили регрессию между dkms-сборками). +AWG_PIN="${AWG_PIN:-y}" +[[ "$AWG_PIN" =~ ^[yn]$ ]] || fail "AWG_PIN должен быть y или n (задано: $AWG_PIN)" +# Параметры обфускации, UDP-порт и MTU AWG. На новой установке — случайные, у каждого сервера +# свои: известные всем значения по умолчанию — готовая сигнатура для блокировки. При KEEP_DATA=y +# берутся из прежнего awg1.conf (choose_awg_params ниже): они зашиты во все выданные vpn:// +# ключи, их смена сделала бы каждый ключ невалидным. +# Ограничения генератора (gen_awg_params): +# - Jc 4–8; Jmin < Jmax, оба много меньше MTU +# - S1–S4 ≥ 12: при HeaderProtectionKey (AmneziaWG 3.x) меньшее модуль отвергает с одним +# «Invalid argument» (причина видна лишь при +# `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`) +# - размеры пакетов после добавки разные: 148+S1 (init), 92+S2 (response), 64+S3 (cookie) — +# иначе тип пакета снова узнаётся по длине; отсюда же S1 != S2 и S2 != S1+56 +# - S4 добавляется к каждому пакету данных — держим маленьким, как прежние 14 +# - H1–H4: непересекающиеся диапазоны в [10^8, 2^31−1] — далеко от 1–4 (типы сообщений +# WireGuard) и не выше прежних значений (некоторые клиенты разбирают их как int32) +# - I1 — пакет-маскировка, его шлёт инициатор handshake, принимающая сторона просто +# отбрасывает. Сервер его не сверяет: у каждого ключа может быть свой, поэтому при +# KEEP_DATA=y без I1 в конфиге его можно сгенерировать заново — старые ключи не сломаются. +# Формат обязан пройти И kernel-модуль (сервер), И amneziawg-go (клиенты) — см. AWG_I1_RE +# - I2–I5 НЕ трогать: в vpn:// ключ уходят пустыми плейсхолдерами +# - MTU 1320–1376: не выше прежнего (запас под S4, заголовки и IPv6 на пути), не ниже — +# лишние накладные расходы. Расхождение MTU сервера и клиента WireGuard переносит +# Прежние постоянные значения: ими собраны ключи установок до рандомизации — нужны, если +# при KEEP_DATA=y awg1.conf потерялся. +LEGACY_AWG_PORT="47619" +LEGACY_MTU="1376" +LEGACY_JC=6; LEGACY_JMIN=10; LEGACY_JMAX=50 +LEGACY_S1=90; LEGACY_S2=45; LEGACY_S3=37; LEGACY_S4=14 +LEGACY_H1="1224800044-2116730834" +LEGACY_H2="2122053282-2133204808" +LEGACY_H3="2133604274-2140756116" +LEGACY_H4="2143656228-2147444225" + +# ═══ Функции ═══════════════════════════════════════════════════════════════════ # Сначала корректно через CLI (по PID-файлам), затем добиваем всё, что ещё # держит файлы проекта: осиротевшие процессы, ручной запуск или stale PID, # которые `stop all` не находит. Вызывать ДО `rm -rf $PROJECT` — иначе сервисы @@ -194,34 +260,15 @@ kill_related() { rm -f /tmp/awg-ctrl.pid /tmp/awg-ui.pid /tmp/awg-agent.pid 2>/dev/null || true } -# Поверх существующей установки: обновить (настройки, пользователи и роль остаются) -# или переустановить с нуля. Спрашиваем первым — от ответа зависит, спрашивать ли роль. -# Или удалить совсем (3; без меню — UNINSTALL=y bash install.sh). -INSTALL_MODE="fresh" -if [[ "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then - INSTALL_MODE="uninstall" -elif [[ -d "$PROJECT" ]]; then - echo - warn "Уже установлено в $PROJECT" - echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}" - echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}" - echo -e " ${ACC}${BLD}3${NC} Удалить ${DIM}полностью: сервисы, данные, правила фаервола, по желанию AmneziaWG${NC}" - ask INST_CHOICE "Выбери [1–3, Enter — 1]: " - case "${INST_CHOICE:-1}" in - 1) INSTALL_MODE="update" ;; - 2) INSTALL_MODE="fresh" ;; - 3) INSTALL_MODE="uninstall" ;; - *) fail "Неверный выбор: введи 1, 2 или 3" ;; - esac - echo -fi - -# cli.env прежней установки читаем сразу: при переустановке каталог проекта удаляется -# раньше, чем до него доходит дело, а из него берутся роль и порты, зашитые в уже -# выданные ссылки (SUB_PORT, NODE_PORT) и в подключение ноды к панели. -PREV_CLI_ENV="" -[[ -f "$PROJECT/cli/cli.env" ]] && PREV_CLI_ENV=$(cat "$PROJECT/cli/cli.env") +# Значение из cli.env прежней установки (PREV_CLI_ENV читается в начале сценария). prev_val() { grep "^$1=" <<< "$PREV_CLI_ENV" | tail -1 | cut -d= -f2- || true; } +# Прежнее значение — только если данные сохраняются (KEEP_DATA): порт и путь панели зашиты в +# закладки и адрес внешнего API, порты подписки и хаба — в выданные ссылки и строки нод. +keep() { [[ "$KEEP_DATA" == "y" ]] && prev_val "$1" || true; } + +# Пункт чеклиста совместимости: ✓ — пройдено, ✗ — нет. +mark() { if [[ "$1" == 1 ]]; then echo -e " ${GRN}✓${NC} $2"; else echo -e " ${RED}✗${NC} $2"; fi; } + # ── Полное удаление ───────────────────────────────────────────────────────── # Убирает всё, что ставили установщик и awg-ctrl: сервисы и юнит, интерфейс awg1, nft-таблицы # шейпинга и блокировки торрентов, открытые порты в UFW/firewalld/iptables, Prometheus из @@ -229,7 +276,7 @@ prev_val() { grep "^$1=" <<< "$PREV_CLI_ENV" | tail -1 | cut -d= -f2- || true; } # логи установки (в них пароль панели). AmneziaWG (пакеты, модуль, PPA) — по вопросу: он мог # стоять и до нас. Общие зависимости (dkms, build-essential, iptables, заголовки) не трогаем. uninstall_all() { - local role="${PREV_ROLE_HINT:-}" conf="$AWG_CONF" net_iface="" p + local role="$PREV_ROLE" conf="$AWG_CONF" net_iface="" p local awg_port ports=() local unit="/etc/systemd/system/awg-control.service" @@ -251,7 +298,8 @@ uninstall_all() { if [[ -d "$AMNEZIA_DIR" || -f "$PROJECT/cli/cli.env" ]]; then ask KEEP_BACKUP "Сохранить копию данных (пользователи, ключи, cli.env) в /root? [Y/n]: " if [[ "${KEEP_BACKUP:-y}" =~ ^[Yy]$ ]]; then - local bak="/root/forgetting-backup-$(date +%Y%m%d-%H%M%S).tar.gz" items=() + local bak items=() + bak="/root/forgetting-backup-$(date +%Y%m%d-%H%M%S).tar.gz" [[ -d "$AMNEZIA_DIR" ]] && items+=("${AMNEZIA_DIR#/}") [[ -f "$PROJECT/cli/cli.env" ]] && items+=("${PROJECT#/}/cli/cli.env") ( umask 077; tar -czf "$bak" -C / "${items[@]}" ) \ @@ -353,219 +401,6 @@ uninstall_all() { echo } -if [[ "$INSTALL_MODE" == "uninstall" ]]; then - if [[ -n "$(prev_val CORE_HOST)" ]]; then PREV_ROLE_HINT="node" - elif [[ "$(prev_val LOCAL_NODE)" == "off" ]]; then PREV_ROLE_HINT="core" - else PREV_ROLE_HINT="standalone" - fi - uninstall_all - exit 0 -fi - -# Роль сервера (несколько серверов под одной панелью, docs/node-protocol.md): -# standalone — панель + VPN на одной машине, как было всегда; может принимать ноды; -# core — только панель, без VPN на этой машине (LOCAL_NODE=off): AmneziaWG, модуль -# ядра и заголовки не ставятся, VPN-серверы подключаются к ней нодами; -# node — VPN без панели: awg-ctrl + awg-agent, который сам подключается к панели -# по строке awgjoin://… («Добавить сервер» в панели). Входящих портов, -# кроме UDP для клиентов, нода не открывает. -# Можно задать окружением: ROLE=node JOIN='awgjoin://…' bash install.sh -# При обновлении роль берётся из прежней установки: смена роли — только переустановкой. -PREV_ROLE="" -if [[ -n "$PREV_CLI_ENV" ]]; then - if [[ -n "$(prev_val CORE_HOST)" ]]; then PREV_ROLE="node" - elif [[ "$(prev_val LOCAL_NODE)" == "off" ]]; then PREV_ROLE="core" - else PREV_ROLE="standalone" - fi -fi -ROLE="${ROLE:-}" -if [[ "$INSTALL_MODE" == "update" ]]; then - if [[ -n "$ROLE" && -n "$PREV_ROLE" && "$ROLE" != "$PREV_ROLE" ]]; then - fail "Смена роли ($PREV_ROLE → $ROLE) при обновлении невозможна — выбери полную переустановку" - fi - ROLE="${PREV_ROLE:-standalone}" -elif [[ -z "$ROLE" ]]; then - case "$PREV_ROLE" in node) ROLE_DEF=3 ;; core) ROLE_DEF=2 ;; *) ROLE_DEF=1 ;; esac - echo - echo -e " ${BLD}Роль сервера${NC}" - echo -e " ${ACC}${BLD}1${NC} Панель + VPN ${DIM}самостоятельный сервер; к нему можно подключать ноды${NC}" - echo -e " ${ACC}${BLD}2${NC} Только панель ${DIM}без VPN на этой машине; серверы подключаются нодами${NC}" - echo -e " ${ACC}${BLD}3${NC} Нода ${DIM}VPN без панели; подключается к уже установленной${NC}" - ask ROLE_CHOICE "Выбери [1–3, Enter — ${ROLE_DEF}]: " - case "${ROLE_CHOICE:-$ROLE_DEF}" in - 1) ROLE="standalone" ;; - 2) ROLE="core" ;; - 3) ROLE="node" ;; - *) fail "Неверный выбор: введи 1, 2 или 3" ;; - esac -fi -[[ "$ROLE" =~ ^(standalone|core|node)$ ]] || fail "ROLE должен быть standalone, core или node (задано: $ROLE)" -case "$ROLE" in - standalone) ROLE_LABEL="панель + VPN" ;; - core) ROLE_LABEL="только панель" ;; - node) ROLE_LABEL="нода" ;; -esac - -# Запускается ДО вопросов конфигурации и любого деструктива (rm -rf): на несовместимой -# машине лучше упасть сразу, а не после ввода данных или удаления каталога. -# Порты (UDP VPN / порт UI) здесь НЕ проверяем — это отдельно (фаервол/облако). -step "Проверка совместимости" - -KERNEL=$(uname -r) -VIRT=$(systemd-detect-virt 2>/dev/null || echo "unknown") - -# Семейство дистрибутива решает, как ставить AmneziaWG: -# ubuntu — add-apt-repository ppa:amnezia/ppa, заголовки linux-headers-generic; -# debian — ни software-properties-common (в Debian 13 его нет), ни -# linux-headers-generic там не существует: PPA подключаем вручную -# (ключ + signed-by), заголовки — только под текущее ядро. -# Производные (Mint, Pop!_OS, Kali…) определяются через ID_LIKE. -OS_ID=""; OS_LIKE=""; OS_CODENAME=""; OS_NAME="" -if [[ -r /etc/os-release ]]; then - OS_ID=$(. /etc/os-release; echo "${ID:-}") - OS_LIKE=$(. /etc/os-release; echo "${ID_LIKE:-}") - OS_CODENAME=$(. /etc/os-release; echo "${VERSION_CODENAME:-}") - OS_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-}") -fi -if [[ "$OS_ID" == "ubuntu" || " $OS_LIKE " == *" ubuntu "* ]]; then - OS_FAMILY="ubuntu" -elif [[ "$OS_ID" == "debian" || " $OS_LIKE " == *" debian "* ]]; then - OS_FAMILY="debian" -else - OS_FAMILY="" -fi -# Вариант ядра без версии: 6.12.85+deb13-cloud-amd64 → cloud-amd64, -# 6.1.0-18-cloud-amd64 → cloud-amd64. Нужен для мета-пакетов Debian -# (linux-image-<вариант> / linux-headers-<вариант>). -KFLAVOUR=$(sed -E 's/^[0-9][^-]*-([0-9]+-)?//' <<< "$KERNEL") -# Что советовать, если заголовков под текущее ядро нет. -if [[ "$OS_FAMILY" == "debian" ]]; then - HDR_FIX="apt-get update && apt-get install -y linux-image-$KFLAVOUR linux-headers-$KFLAVOUR && reboot" -else - HDR_FIX="apt-get install -y linux-generic && reboot" -fi - -echo -e " ${DIM}${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)} · ядро $KERNEL · виртуализация $VIRT · роль: $ROLE_LABEL${NC}" - -# Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист -# со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим -# с кодом 0 (чистый выход, без вида «упало с ошибкой»). -OS_OK=1; ARCH_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1 -OS_WHY=""; ARCH_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY="" - -# Архитектура: релиз собирается под amd64 и arm64. -if [[ -z "$GOARCH" ]]; then - ARCH_OK=0 - ARCH_WHY="архитектура '$(uname -m)' не поддерживается — сборки есть для x86_64 и aarch64" -fi - -# 0) Дистрибутив: установщик умеет только apt-семейство Ubuntu/Debian. -if [[ -z "$OS_FAMILY" ]]; then - OS_OK=0 - OS_WHY="дистрибутив '${OS_NAME:-${OS_ID:-неизвестно}}' не поддерживается — нужен Ubuntu или Debian" -fi - -# Виртуализация и заголовки нужны только для модуля ядра — панели без VPN (core) они не важны, -# её можно ставить и в контейнер. -if [[ "$ROLE" != "core" ]]; then - # 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом. - case "$VIRT" in - openvz|lxc|lxc-libvirt|docker|podman|wsl) - VIRT_OK=0 - VIRT_WHY="виртуализация '$VIRT' — ядро общее с хостом, kernel-модуль AmneziaWG не загрузить (нужен userspace amneziawg-go)" - ;; - esac - - # 2) Заголовки ядра: DKMS соберёт модуль только при их наличии под текущее ядро. - # Уже в системе или есть кандидат в apt — ок. «(none)» — кастомное ядро, не - # проходит. Пустой apt-индекс не валим: добьёт поздняя проверка на шаге AWG. - HDR_POLICY=$(apt-cache policy "linux-headers-$KERNEL" 2>/dev/null || true) - if [[ -d "/lib/modules/$KERNEL/build" ]]; then - : - elif echo "$HDR_POLICY" | grep -q 'Candidate: [^(]'; then - : - elif echo "$HDR_POLICY" | grep -q 'Candidate: (none)'; then - HDR_OK=0 - HDR_WHY="нет заголовков под ядро $KERNEL в apt. Решение: $HDR_FIX, затем запусти install.sh заново" - fi -fi - -# 3) Доступ в интернет: нужен для архива и пакетов AWG. Без -f: любой -# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS. -# Проверяем хосты, с которых реально качаем: хватает любого из двух — -# download_extract() умеет откатываться на запасной. -NET_HOSTS=("$(url_host "$REPO_BASE")") -[[ -n "$ARCHIVE_URL" ]] && NET_HOSTS=("$(url_host "$ARCHIVE_URL")") -[[ -z "$ARCHIVE_URL" && "$(url_host "$REPO_FALLBACK")" != "${NET_HOSTS[0]}" ]] \ - && NET_HOSTS+=("$(url_host "$REPO_FALLBACK")") -NET_OK=0 -for h in "${NET_HOSTS[@]}"; do - if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi -done -[[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS" - -# Чеклист: ✓ — пройдено, ✗ — нет. -mark() { if [[ "$1" == 1 ]]; then echo -e " ${GRN}✓${NC} $2"; else echo -e " ${RED}✗${NC} $2"; fi; } -mark "$OS_OK" "Дистрибутив" -mark "$ARCH_OK" "Архитектура" -if [[ "$ROLE" != "core" ]]; then - mark "$VIRT_OK" "Виртуализация" - mark "$HDR_OK" "Заголовки ядра" -fi -mark "$NET_OK" "Доступ в интернет" - -if [[ "$OS_OK" == 0 || "$ARCH_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then - echo - warn "Установка невозможна — не пройдены проверки:" - [[ "$OS_OK" == 0 ]] && echo -e " ${RED}•${NC} $OS_WHY" - [[ "$ARCH_OK" == 0 ]] && echo -e " ${RED}•${NC} $ARCH_WHY" - [[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY" - [[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY" - [[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY" - echo - trap - ERR - exit 0 -fi - -# Наличие базы определяем заранее и молча — от него зависит, спросим ли ниже имя -# сервера (если база есть, имя берётся из неё). Без вывода. KEEP_DATA — отдельно. -DB_EXISTS="n" -[[ -f "$DB_FILE" ]] && DB_EXISTS="y" - -# Параметры обфускации, UDP-порт и MTU AWG. На новой установке — случайные, у каждого сервера -# свои: известные всем значения по умолчанию — готовая сигнатура для блокировки. При KEEP_DATA=y -# берутся из прежнего awg1.conf (choose_awg_params ниже): они зашиты во все выданные vpn:// -# ключи, их смена сделала бы каждый ключ невалидным. -# Ограничения генератора (gen_awg_params): -# - Jc 4–8; Jmin < Jmax, оба много меньше MTU -# - S1–S4 ≥ 12: при HeaderProtectionKey (AmneziaWG 3.x) меньшее модуль отвергает с одним -# «Invalid argument» (причина видна лишь при -# `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`) -# - размеры пакетов после добавки разные: 148+S1 (init), 92+S2 (response), 64+S3 (cookie) — -# иначе тип пакета снова узнаётся по длине; отсюда же S1 != S2 и S2 != S1+56 -# - S4 добавляется к каждому пакету данных — держим маленьким, как прежние 14 -# - H1–H4: непересекающиеся диапазоны в [10^8, 2^31−1] — далеко от 1–4 (типы сообщений -# WireGuard) и не выше прежних значений (некоторые клиенты разбирают их как int32) -# - I1 — пакет-маскировка, его шлёт инициатор handshake, принимающая сторона просто -# отбрасывает. Сервер его не сверяет: у каждого ключа может быть свой, поэтому при -# KEEP_DATA=y без I1 в конфиге его можно сгенерировать заново — старые ключи не сломаются. -# Формат обязан пройти И kernel-модуль (сервер), И amneziawg-go (клиенты) — см. AWG_I1_RE -# - I2–I5 НЕ трогать: в vpn:// ключ уходят пустыми плейсхолдерами -# - MTU 1320–1376: не выше прежнего (запас под S4, заголовки и IPv6 на пути), не ниже — -# лишние накладные расходы. Расхождение MTU сервера и клиента WireGuard переносит -# Прежние постоянные значения: ими собраны ключи установок до рандомизации — нужны, если -# при KEEP_DATA=y awg1.conf потерялся. -LEGACY_AWG_PORT="47619" -LEGACY_MTU="1376" -LEGACY_JC=6; LEGACY_JMIN=10; LEGACY_JMAX=50 -LEGACY_S1=90; LEGACY_S2=45; LEGACY_S3=37; LEGACY_S4=14 -LEGACY_H1="1224800044-2116730834" -LEGACY_H2="2122053282-2133204808" -LEGACY_H3="2133604274-2140756116" -LEGACY_H4="2143656228-2147444225" - -rand_int() { shuf -i "$1-$2" -n 1; } - gen_awg_params() { JC=$(rand_int 4 8) JMIN=$(rand_int 8 40) @@ -578,8 +413,8 @@ gen_awg_params() { S4=$(rand_int 12 20) # 8 разных чисел по возрастанию → 4 непересекающихся диапазона, раздаём H1–H4 вперемешку. local n r - n=($(shuf -i 100000000-2147483647 -n 8 | sort -n)) - r=($(printf '%s\n' "${n[0]}-${n[1]}" "${n[2]}-${n[3]}" "${n[4]}-${n[5]}" "${n[6]}-${n[7]}" | shuf)) + mapfile -t n < <(shuf -i 100000000-2147483647 -n 8 | sort -n) + mapfile -t r < <(printf '%s\n' "${n[0]}-${n[1]}" "${n[2]}-${n[3]}" "${n[4]}-${n[5]}" "${n[6]}-${n[7]}" | shuf) H1=${r[0]}; H2=${r[1]}; H3=${r[2]}; H4=${r[3]} } @@ -670,25 +505,17 @@ choose_awg_params() { [[ "$I1" =~ $AWG_I1_RE ]] || fail "I1 «${I1}» не пройдёт парсер AmneziaWG" } -# Поколение протокола и закрепление версии пакета — управляются переменными -# окружения, задаются ДО запуска: `AWG_GEN=auto bash install.sh`. -# AWG_GEN=3.1 (по умолчанию) — основное поколение: если модуль в ядре не 3.x -# или ядро старше 5.5, install.sh падает с диагностикой. Тихий откат на 2.0 -# здесь нежелателен: он молча выдал бы сервер со слабой обфускацией. -# AWG_GEN=auto — старое поведение: 3.1, если условия выполняются, иначе 2.0 -# без вопросов. Для установки на заведомо старое ядро/модуль. -# AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0. -# Сервер, установленный на 2.0, переводится на 3.1 позже из панели -# (POST /awg/upgrade) — переустановка для этого не нужна. -AWG_GEN="${AWG_GEN:-3.1}" -[[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)" -# AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools} -# через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым -# сервером (issue #215 — именно так словили регрессию между dkms-сборками). -AWG_PIN="${AWG_PIN:-y}" +valid_port() { [[ "$1" =~ ^[0-9]+$ && "$1" -gt 0 && "$1" -lt 65536 ]]; } -NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') -[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" +# Случайный свободный TCP-порт 32768–55535, не из уже занятых установкой (аргументы). +gen_tcp_port() { + local p used=" $* " + while :; do + p=$(rand_int 32768 55535) + [[ "$used" == *" $p "* ]] && continue + [[ -z "$(ss -Htlnp "sport = :$p" 2>/dev/null)" ]] && { echo "$p"; return; } + done +} # Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые # файлы на месте. @@ -817,160 +644,6 @@ start_and_status() { "$BIN" status } -if [[ "$INSTALL_MODE" == "update" ]]; then - step "Обновление до версии ${VERSION} ($ROLE_LABEL)" - - info "останавливаем и убиваем все процессы awg-control" - kill_related - - download_extract - install_command - start_and_status - - rule - echo -e " ${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${ROLE_LABEL}${NC}" - rule - echo - exit 0 -fi - -# При полной переустановке убиваем все процессы awg-control и очищаем старый -# каталог, чтобы не оставалось ни запущенных процессов, ни старых файлов. -if [[ -d "$PROJECT" ]]; then - kill_related - rm -rf "$PROJECT" -fi - -step "Конфигурация" - -# Server IP определяем автоматически (внешний через ifconfig.me, иначе локальный -# по маршруту) — не спрашиваем. Если определить не удалось — падаем. -SERVER_IP=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null \ - || ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' \ - || echo "") -[[ -z "$SERVER_IP" ]] && fail "Не удалось определить Server IP автоматически" -ok "Server IP: $SERVER_IP" - -# Имя сервера спрашиваем ниже — только если базы ещё нет (DB_EXISTS=n). -# Если база есть, имя берётся из неё. - -# Внутренняя авторизация awg-ui ↔ awg-ctrl — асимметричная пара, генерируется -# в шаге 4 (Ключи). Здесь секрет не нужен. -AWGCTRL_PORT=$(( (RANDOM % 22768) + 32768 )) - -# Панель (awg-ui) есть везде, кроме ноды. -if [[ "$ROLE" != "node" ]]; then - ask UI_PORT "UI port (Enter — случайный): " - UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}" - - ask UI_USER "UI логин [admin]: " - UI_USER="${UI_USER:-admin}" - - SUGGESTED_PASS=$(rand_alnum 12) - ask UI_PASS "UI пароль [$SUGGESTED_PASS]: " secret - UI_PASS="${UI_PASS:-$SUGGESTED_PASS}" - - JWT_SECRET=$(rand_alnum 32) -fi - -# Что сохранять при переустановке. -# С VPN (standalone, node): база пользователей и ключ сервера — только вместе: -# vpn:// ключи пользователей привязаны к ключу сервера. ui.db следует за ними. -# Панель без VPN (core): её данные — ui.db (серверы, ключи API, мастер-ключи). -KEEP_DATA="n" -if [[ "$ROLE" == "core" ]]; then - if [[ -f "$UI_DB_FILE" ]]; then - echo - warn "Найдены данные панели: $UI_DB_FILE" - ask KD "Сохранить серверы, ключи API и мастер-ключи? [Y/n]: " - [[ "${KD:-y}" =~ ^[Yy]$ || -z "${KD}" ]] && KEEP_DATA="y" - fi -elif [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then - echo - warn "Найдена существующая установка:" - [[ -f "$DB_FILE" ]] && echo " база пользователей: $DB_FILE" - [[ -f "$PRIV_KEY_FILE" ]] && echo " ключ сервера: $PRIV_KEY_FILE" - - if [[ -f "$DB_FILE" && -f "$PRIV_KEY_FILE" && -f "$PUB_KEY_FILE" ]]; then - ask KD "Сохранить пользователей и ключ сервера? [Y/n]: " - [[ "${KD:-y}" =~ ^[Yy]$ || -z "${KD}" ]] && KEEP_DATA="y" - else - warn "Для сохранения нужны и база, и оба ключа сервера — часть отсутствует." - warn "Пользователей не сохранить (vpn:// ключи стали бы невалидными)." - fi -fi - -# sen://-подписка и приём нод живут в панели — у ноды их нет. -# Порты и режим TLS зашиты в уже выданные ссылки, поэтому при KEEP_DATA=y берём прежние -# значения из cli.env прежней установки (PREV_CLI_ENV). -SUB_PORT="${SUB_PORT:-}" -SUB_TLS="${SUB_TLS:-}" -NODE_PORT="${NODE_PORT:-}" -if [[ "$ROLE" != "node" ]]; then - if [[ "$KEEP_DATA" == "y" ]]; then - [[ -z "$SUB_PORT" ]] && SUB_PORT="$(prev_val SUB_PORT)" - [[ -z "$SUB_TLS" ]] && SUB_TLS="$(prev_val SUB_TLS)" - [[ -z "$NODE_PORT" ]] && NODE_PORT="$(prev_val NODE_PORT)" - fi - if [[ -z "$SUB_PORT" ]]; then - ask SUB_PORT "Sub port для sen:// (Enter — случайный): " - while [[ -z "$SUB_PORT" || "$SUB_PORT" == "$UI_PORT" || "$SUB_PORT" == "$AWGCTRL_PORT" ]]; do - SUB_PORT=$(( (RANDOM % 22768) + 32768 )) - done - fi - if [[ -z "$SUB_TLS" ]]; then - ask ST "Подписка sen:// по HTTPS (самоподписанный сертификат, домен не нужен)? [Y/n]: " - [[ "${ST:-y}" =~ ^[Nn]$ ]] && SUB_TLS="off" || SUB_TLS="on" - fi - [[ "$SUB_TLS" == "on" ]] || SUB_TLS="off" - - # Приём нод: ноды сами подключаются к панели по WSS на этот порт. Сертификат хаба awg-ui - # создаёт при первом запуске, его отпечаток зашит в строки подключения. Панели без VPN - # приём нод обязателен (иначе ей нечем управлять), у «панели + VPN» — по желанию. - if [[ -z "$NODE_PORT" ]]; then - if [[ "$ROLE" == "core" ]]; then - ask NODE_PORT "Порт для подключения нод (Enter — случайный): " - else - ask NODE_PORT "Порт для подключения нод (Enter — не принимать ноды): " - fi - fi - if [[ -n "$NODE_PORT" ]] && ! [[ "$NODE_PORT" =~ ^[0-9]+$ && "$NODE_PORT" -gt 0 && "$NODE_PORT" -lt 65536 ]]; then - [[ "$ROLE" == "core" ]] && fail "«$NODE_PORT» — не номер порта" - warn "NODE_PORT «$NODE_PORT» не порт — приём нод выключен" - NODE_PORT="" - fi - if [[ "$ROLE" == "core" ]]; then - while [[ -z "$NODE_PORT" || "$NODE_PORT" == "$UI_PORT" || "$NODE_PORT" == "$SUB_PORT" ]]; do - NODE_PORT=$(( (RANDOM % 22768) + 32768 )) - done - fi -fi - -[[ "$ROLE" != "core" ]] && choose_awg_params - -if [[ "$ROLE" != "node" ]]; then - # Секретный путь панели: снаружи она отвечает только на http://IP:PORT/<путь>/, на остальное — - # 404, как пустой сервер. Без него сканер узнавал панель по странице логина одним GET. - # При переустановке путь прежний; UI_PREFIX=off — без пути, UI_PREFIX=<свой> — свой - # (8–64 символа A–Z a–z 0–9 _ -). - UI_PREFIX="${UI_PREFIX:-}" - UI_PREFIX_NEW="n" - if [[ "$UI_PREFIX" == "off" ]]; then - UI_PREFIX="" - elif [[ -z "$UI_PREFIX" ]]; then - [[ "$KEEP_DATA" == "y" ]] && UI_PREFIX="$(prev_val UI_PREFIX)" - if [[ -z "$UI_PREFIX" ]]; then - UI_PREFIX=$(rand_alnum 16) - [[ -n "$PREV_CLI_ENV" ]] && UI_PREFIX_NEW="y" - fi - fi - [[ -z "$UI_PREFIX" || "$UI_PREFIX" =~ ^[A-Za-z0-9_-]{8,64}$ ]] \ - || fail "UI_PREFIX «${UI_PREFIX}» — нужно 8–64 символа A–Z a–z 0–9 _ -" - # Путь переехавшей сюда панели (migrate) — её закладкам и API через ретранслятор. - UI_PREFIX_MOVED="" - [[ "$KEEP_DATA" == "y" ]] && UI_PREFIX_MOVED="$(prev_val UI_PREFIX_MOVED)" -fi - # Нода: к какой панели подключаться. Строку awgjoin://… даёт панель («Добавить сервер»): # base64url от JSON {v, h, p, pin, n, s}. Разбираем здесь, до установки, чтобы опечатка не # обнаружилась через десять минут сборки модуля. Значения в строке ограничены @@ -1006,6 +679,514 @@ core_pin() { | base64 | tr '+/' '-_' | tr -d '=\n' || true } +# ── AmneziaWG: PPA для Debian, проверки модуля и ядра ─────────────────────── +# Debian: add-apt-repository нет (software-properties-common из Debian 13 убран), +# а PPA под Debian-релизы не собирается — подключаем его вручную на Ubuntu-серию +# с glibc не новее дебиановской. Модуль приезжает DKMS-исходниками и от серии +# не зависит; серия важна только для бинарника amneziawg-tools. +# AMNEZIA_PPA_SUITE — задать серию вручную (focal/jammy/noble). +AMNEZIA_PPA_URL="https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu" +AMNEZIA_PPA_FPR="75C9DD72C799870E310542E24166F2C257290828" # Launchpad PPA for Iurii Egorov +AMNEZIA_KEYRING="/etc/apt/keyrings/amnezia-ppa.gpg" +add_amnezia_ppa_debian() { + local suite="${AMNEZIA_PPA_SUITE:-}" + if [[ -z "$suite" ]]; then + case "$OS_CODENAME" in + bullseye) suite="focal" ;; + bookworm) suite="jammy" ;; + trixie) suite="noble" ;; + *) suite="noble" + warn "Debian '${OS_CODENAME:-неизвестно}' не сопоставлен с серией PPA — берём noble (задать вручную: AMNEZIA_PPA_SUITE=…)" ;; + esac + fi + info "подключение PPA amnezia (Debian ${OS_CODENAME:-?} → серия $suite)" + + # Ключ берём по полному отпечатку и сверяем отпечаток после загрузки — + # apt-key в Debian 12+ уже нет, а доверять ответу keyserver вслепую нельзя. + local tmp gh fprs="" + tmp=$(mktemp); gh=$(mktemp -d) + if curl -fsSL --connect-timeout 15 \ + "https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0x${AMNEZIA_PPA_FPR}" \ + | gpg --dearmor > "$tmp" 2>/dev/null; then + fprs=$(GNUPGHOME="$gh" gpg --batch --show-keys --with-colons "$tmp" 2>/dev/null || true) + fi + if ! grep -q "^fpr:::::::::${AMNEZIA_PPA_FPR}:" <<< "$fprs"; then + rm -rf "$tmp" "$gh" + fail "Не удалось получить ключ PPA amnezia ($AMNEZIA_PPA_FPR) с keyserver.ubuntu.com" + fi + install -d -m 0755 /etc/apt/keyrings + install -m 0644 "$tmp" "$AMNEZIA_KEYRING" + rm -rf "$tmp" "$gh" + + echo "deb [signed-by=$AMNEZIA_KEYRING] $AMNEZIA_PPA_URL $suite main" \ + > /etc/apt/sources.list.d/amnezia-ppa.list + ok "PPA amnezia подключён: $suite, ключ $AMNEZIA_KEYRING" +} + +# Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5. +# - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0, +# поэтому сначала пробуем обновиться. +# - ядро: header protection использует библиотечный chacha-API +# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся +# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. +# При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий +# откат подсунул бы более слабую обфускацию незаметно для оператора. +# При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение +# по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно +# как раньше, а перейти на 3.1 можно потом из панели. + +kernel_lt_5_5() { + local maj min + maj=${KERNEL%%.*} + min=${KERNEL#*.}; min=${min%%.*} + [[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]] +} + +# 🛑 Версий у модуля две, и путать их нельзя: +# modinfo — что лежит на диске (после apt/DKMS); +# /sys/module/amneziawg/version — что реально загружено в ядро. +# `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный. +# Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый +# загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify +# interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля. +disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; } +loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; } + +# Закрывает ли голый iptables входящие: политика DROP или правило DROP/REJECT в INPUT. +iptables_input_closed() { + command -v iptables &>/dev/null || return 1 + iptables -S INPUT 2>/dev/null | grep -qE '^-P INPUT DROP|^-A INPUT .*-j (DROP|REJECT)' +} + +# Открыть OPEN_PORTS на фаерволе самого сервера: UFW, firewalld или голый iptables — что активно. +# Фаервол хостера (панель провайдера, security group) отсюда не виден — о нём напоминаем в итоге. +open_firewall() { + local P + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then + # UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец + # цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT + # дело не доходит и трафик клиентов наружу режется: в dmesg сыплется + # «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет». + # Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно. + info "UFW активен, открываем $([[ "$ROLE" != "core" ]] && echo "маршрут и ")порты" + if [[ "$ROLE" != "core" ]]; then + run ufw route allow in on "$IFACE" out on "$NET_IFACE" \ + || warn "ufw route allow не сработал — трафик клиентов может резаться" + fi + for P in "${OPEN_PORTS[@]}"; do + run ufw allow "$P" || warn "не удалось открыть $P" + done + run ufw reload || true + ok "UFW:$([[ "$ROLE" != "core" ]] && echo " форвардинг $IFACE → $NET_IFACE,") порты ${OPEN_PORTS[*]}" + elif command -v firewall-cmd &>/dev/null && firewall-cmd --state &>/dev/null; then + info "firewalld активен, открываем порты" + for P in "${OPEN_PORTS[@]}"; do + run firewall-cmd --permanent --add-port="$P" || warn "не удалось открыть $P" + done + run firewall-cmd --reload || true + ok "firewalld: порты ${OPEN_PORTS[*]}" + if [[ "$ROLE" != "core" ]]; then + warn "Если VPN подключается, а интернета нет — firewalld режет форвардинг:" + warn " firewall-cmd --permanent --zone=trusted --add-interface=${IFACE} && firewall-cmd --reload" + fi + elif iptables_input_closed; then + # Голый iptables без обёрток: вставляем ACCEPT в начало INPUT (повторно не дублируем). + info "iptables закрывает входящие, открываем порты" + for P in "${OPEN_PORTS[@]}"; do + iptables -C INPUT -p "${P#*/}" --dport "${P%/*}" -j ACCEPT 2>/dev/null \ + || run iptables -I INPUT -p "${P#*/}" --dport "${P%/*}" -j ACCEPT \ + || warn "не удалось открыть $P" + done + if command -v netfilter-persistent &>/dev/null; then + run netfilter-persistent save || warn "правила iptables не сохранены — после перезагрузки порты закроются" + ok "iptables: порты ${OPEN_PORTS[*]} (сохранено)" + else + ok "iptables: порты ${OPEN_PORTS[*]}" + warn "Правила iptables не переживут перезагрузку: apt install iptables-persistent && netfilter-persistent save" + fi + else + info "Фаервол на сервере входящие не закрывает — открывать нечего" + fi +} + +# Адрес панели для итога: с секретным путём. +panel_url() { echo "http://$1:$2/${3:+$3/}"; } + +# ═══ Сценарий ══════════════════════════════════════════════════════════════════ + +echo +echo -e " ${ACC}◆${NC} ${BLD}${BRAND}${NC} ${DIM}${CHANNEL} · ${VERSION}${NC}" +echo -e " ${DIM}Панель управления VPN на AmneziaWG — установка${NC}" +echo -e " ${DIM}Лог: $LOGFILE${NC}" +rule + +# Поверх существующей установки: обновить (настройки, пользователи и роль остаются), +# переустановить с нуля или удалить совсем (без меню — UNINSTALL=y). Спрашиваем первым — +# от ответа зависит, спрашивать ли роль. +INSTALL_MODE="fresh" +if [[ "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then + INSTALL_MODE="uninstall" +elif [[ -d "$PROJECT" ]]; then + echo + warn "Уже установлено в $PROJECT" + echo -e " ${ACC}${BLD}1${NC} Обновить ${DIM}до ${VERSION}; настройки, пользователи и роль сохранятся${NC}" + echo -e " ${ACC}${BLD}2${NC} Переустановить ${DIM}с нуля; данные можно будет сохранить${NC}" + echo -e " ${ACC}${BLD}3${NC} Удалить ${DIM}полностью: сервисы, данные, правила фаервола, по желанию AmneziaWG${NC}" + ask INST_CHOICE "Выбери [1–3, Enter — 1]: " + case "${INST_CHOICE:-1}" in + 1) INSTALL_MODE="update" ;; + 2) INSTALL_MODE="fresh" ;; + 3) INSTALL_MODE="uninstall" ;; + *) fail "Неверный выбор: введи 1, 2 или 3" ;; + esac + echo +fi + +# cli.env прежней установки читаем сразу: при переустановке каталог проекта удаляется +# раньше, чем до него доходит дело, а из него берутся роль и порты, зашитые в уже +# выданные ссылки (SUB_PORT, NODE_PORT) и в подключение ноды к панели. +PREV_CLI_ENV="" +[[ -f "$PROJECT/cli/cli.env" ]] && PREV_CLI_ENV=$(cat "$PROJECT/cli/cli.env") +PREV_ROLE="" +if [[ -n "$PREV_CLI_ENV" ]]; then + if [[ -n "$(prev_val CORE_HOST)" ]]; then PREV_ROLE="node" + elif [[ "$(prev_val LOCAL_NODE)" == "off" ]]; then PREV_ROLE="core" + else PREV_ROLE="standalone" + fi +fi + +if [[ "$INSTALL_MODE" == "uninstall" ]]; then + uninstall_all + exit 0 +fi +# Роль сервера (несколько серверов под одной панелью — README, «Несколько серверов»): +# standalone — панель + VPN на одной машине, как было всегда; может принимать ноды; +# core — только панель, без VPN на этой машине (LOCAL_NODE=off): AmneziaWG, модуль +# ядра и заголовки не ставятся, VPN-серверы подключаются к ней нодами; +# node — VPN без панели: awg-ctrl + awg-agent, который сам подключается к панели +# по строке awgjoin://… («Добавить сервер» в панели). Входящих портов, +# кроме UDP для клиентов, нода не открывает. +# Можно задать окружением: ROLE=node JOIN='awgjoin://…' bash install.sh +# При обновлении роль берётся из прежней установки: смена роли — только переустановкой. +ROLE="${ROLE:-}" +if [[ "$INSTALL_MODE" == "update" ]]; then + if [[ -n "$ROLE" && -n "$PREV_ROLE" && "$ROLE" != "$PREV_ROLE" ]]; then + fail "Смена роли ($PREV_ROLE → $ROLE) при обновлении невозможна — выбери полную переустановку" + fi + ROLE="${PREV_ROLE:-standalone}" +elif [[ -z "$ROLE" ]]; then + case "$PREV_ROLE" in node) ROLE_DEF=3 ;; core) ROLE_DEF=2 ;; *) ROLE_DEF=1 ;; esac + echo + echo -e " ${BLD}Роль сервера${NC}" + echo -e " ${ACC}${BLD}1${NC} Панель + VPN ${DIM}самостоятельный сервер; к нему можно подключать ноды${NC}" + echo -e " ${ACC}${BLD}2${NC} Только панель ${DIM}без VPN на этой машине; серверы подключаются нодами${NC}" + echo -e " ${ACC}${BLD}3${NC} Нода ${DIM}VPN без панели; подключается к уже установленной${NC}" + ask ROLE_CHOICE "Выбери [1–3, Enter — ${ROLE_DEF}]: " + case "${ROLE_CHOICE:-$ROLE_DEF}" in + 1) ROLE="standalone" ;; + 2) ROLE="core" ;; + 3) ROLE="node" ;; + *) fail "Неверный выбор: введи 1, 2 или 3" ;; + esac +fi +[[ "$ROLE" =~ ^(standalone|core|node)$ ]] || fail "ROLE должен быть standalone, core или node (задано: $ROLE)" +case "$ROLE" in + standalone) ROLE_LABEL="панель + VPN" ;; + core) ROLE_LABEL="только панель" ;; + node) ROLE_LABEL="нода" ;; +esac + +# Запускается ДО вопросов конфигурации и любого деструктива (rm -rf): на несовместимой +# машине лучше упасть сразу, а не после ввода данных или удаления каталога. +# Порты (UDP VPN / порт UI) здесь НЕ проверяем — это отдельно (фаервол/облако). +step "Проверка совместимости" + +KERNEL=$(uname -r) +VIRT=$(systemd-detect-virt 2>/dev/null || echo "unknown") + +# Семейство дистрибутива решает, как ставить AmneziaWG: +# ubuntu — add-apt-repository ppa:amnezia/ppa, заголовки linux-headers-generic; +# debian — ни software-properties-common (в Debian 13 его нет), ни +# linux-headers-generic там не существует: PPA подключаем вручную +# (ключ + signed-by), заголовки — только под текущее ядро. +# Производные (Mint, Pop!_OS, Kali…) определяются через ID_LIKE. +OS_ID=""; OS_LIKE=""; OS_CODENAME=""; OS_NAME="" +if [[ -r /etc/os-release ]]; then + OS_ID=$(. /etc/os-release; echo "${ID:-}") + OS_LIKE=$(. /etc/os-release; echo "${ID_LIKE:-}") + OS_CODENAME=$(. /etc/os-release; echo "${VERSION_CODENAME:-}") + OS_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-}") +fi +if [[ "$OS_ID" == "ubuntu" || " $OS_LIKE " == *" ubuntu "* ]]; then + OS_FAMILY="ubuntu" +elif [[ "$OS_ID" == "debian" || " $OS_LIKE " == *" debian "* ]]; then + OS_FAMILY="debian" +else + OS_FAMILY="" +fi +# Вариант ядра без версии: 6.12.85+deb13-cloud-amd64 → cloud-amd64, +# 6.1.0-18-cloud-amd64 → cloud-amd64. Нужен для мета-пакетов Debian +# (linux-image-<вариант> / linux-headers-<вариант>). +KFLAVOUR=$(sed -E 's/^[0-9][^-]*-([0-9]+-)?//' <<< "$KERNEL") +# Что советовать, если заголовков под текущее ядро нет. +if [[ "$OS_FAMILY" == "debian" ]]; then + HDR_FIX="apt-get update && apt-get install -y linux-image-$KFLAVOUR linux-headers-$KFLAVOUR && reboot" +else + HDR_FIX="apt-get install -y linux-generic && reboot" +fi + +echo -e " ${DIM}${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)} · ядро $KERNEL · виртуализация $VIRT · роль: $ROLE_LABEL${NC}" + +# Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист +# со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим +# с кодом 0 (чистый выход, без вида «упало с ошибкой»). +OS_OK=1; ARCH_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1 +OS_WHY=""; ARCH_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY="" + +# Архитектура: релиз собирается под amd64 и arm64. +if [[ -z "$GOARCH" ]]; then + ARCH_OK=0 + ARCH_WHY="архитектура '$(uname -m)' не поддерживается — сборки есть для x86_64 и aarch64" +fi + +# 0) Дистрибутив: установщик умеет только apt-семейство Ubuntu/Debian. +if [[ -z "$OS_FAMILY" ]]; then + OS_OK=0 + OS_WHY="дистрибутив '${OS_NAME:-${OS_ID:-неизвестно}}' не поддерживается — нужен Ubuntu или Debian" +fi + +# Виртуализация и заголовки нужны только для модуля ядра — панели без VPN (core) они не важны, +# её можно ставить и в контейнер. +if [[ "$ROLE" != "core" ]]; then + # 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом. + case "$VIRT" in + openvz|lxc|lxc-libvirt|docker|podman|wsl) + VIRT_OK=0 + VIRT_WHY="виртуализация '$VIRT' — ядро общее с хостом, kernel-модуль AmneziaWG не загрузить (нужен userspace amneziawg-go)" + ;; + esac + + # 2) Заголовки ядра: DKMS соберёт модуль только при их наличии под текущее ядро. + # Уже в системе или есть кандидат в apt — ок. «(none)» — кастомное ядро, не + # проходит. Пустой apt-индекс не валим: добьёт поздняя проверка на шаге AWG. + HDR_POLICY=$(apt-cache policy "linux-headers-$KERNEL" 2>/dev/null || true) + if [[ -d "/lib/modules/$KERNEL/build" ]]; then + : + elif echo "$HDR_POLICY" | grep -q 'Candidate: [^(]'; then + : + elif echo "$HDR_POLICY" | grep -q 'Candidate: (none)'; then + HDR_OK=0 + HDR_WHY="нет заголовков под ядро $KERNEL в apt. Решение: $HDR_FIX, затем запусти install.sh заново" + fi +fi + +# 3) Доступ в интернет: нужен для архива и пакетов AWG. Без -f: любой +# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS. +# Проверяем хосты, с которых реально качаем: хватает любого из двух — +# download_extract() умеет откатываться на запасной. +NET_HOSTS=("$(url_host "$REPO_BASE")") +[[ -n "$ARCHIVE_URL" ]] && NET_HOSTS=("$(url_host "$ARCHIVE_URL")") +[[ -z "$ARCHIVE_URL" && "$(url_host "$REPO_FALLBACK")" != "${NET_HOSTS[0]}" ]] \ + && NET_HOSTS+=("$(url_host "$REPO_FALLBACK")") +NET_OK=0 +for h in "${NET_HOSTS[@]}"; do + if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi +done +[[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS" + +mark "$OS_OK" "Дистрибутив" +mark "$ARCH_OK" "Архитектура" +if [[ "$ROLE" != "core" ]]; then + mark "$VIRT_OK" "Виртуализация" + mark "$HDR_OK" "Заголовки ядра" +fi +mark "$NET_OK" "Доступ в интернет" + +if [[ "$OS_OK" == 0 || "$ARCH_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then + echo + warn "Установка невозможна — не пройдены проверки:" + [[ "$OS_OK" == 0 ]] && echo -e " ${RED}•${NC} $OS_WHY" + [[ "$ARCH_OK" == 0 ]] && echo -e " ${RED}•${NC} $ARCH_WHY" + [[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY" + [[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY" + [[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY" + echo + trap - ERR + exit 0 +fi + +NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') +[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" + +# ── Обновление: новая версия поверх, настройки и данные на месте ────────────── +if [[ "$INSTALL_MODE" == "update" ]]; then + step "Обновление до версии ${VERSION} ($ROLE_LABEL)" + + info "останавливаем и убиваем все процессы awg-control" + kill_related + + download_extract + install_command + + # Установке без секретного пути панели — выдаём его (UI_PREFIX=off — оставить без пути): + # иначе обновлённый сервер по-прежнему узнаётся сканером по странице логина. cli.env + # обновление не переписывает, поэтому дописываем одну строку. + UI_PREFIX_NEW="n" + UI_PREFIX="${UI_PREFIX:-}" + if [[ "$ROLE" != "node" ]]; then + CLI_ENV="$PROJECT/cli/cli.env" + if ! grep -q '^UI_PREFIX=' "$CLI_ENV" 2>/dev/null && [[ "$UI_PREFIX" != "off" ]]; then + UI_PREFIX="${UI_PREFIX:-$(rand_alnum 16)}" + [[ "$UI_PREFIX" =~ ^[A-Za-z0-9_-]{8,64}$ ]] \ + || fail "UI_PREFIX «${UI_PREFIX}» — нужно 8–64 символа A–Z a–z 0–9 _ -" + printf 'UI_PREFIX=%s\n' "$UI_PREFIX" >> "$CLI_ENV" + UI_PREFIX_NEW="y" + ok "Панели выдан секретный путь" + fi + UI_PREFIX=$(sed -n 's/^UI_PREFIX=//p' "$CLI_ENV" 2>/dev/null | tail -1) + fi + + start_and_status + + rule + echo -e " ${GRN}${BLD}✓ Обновлено до ${VERSION}${NC} ${DIM}${ROLE_LABEL}${NC}" + rule + if [[ "$ROLE" != "node" ]]; then + HOST="$(prev_val SUB_HOST)"; HOST="${HOST:-$(prev_val SERVER_IP)}" + echo -e " ${DIM}Панель ${NC} ${BLD}$(panel_url "${HOST:-IP}" "$(prev_val UI_PORT)" "$UI_PREFIX")${NC}" + if [[ "$UI_PREFIX_NEW" == "y" ]]; then + echo -e " ${YLW}путь новый: без него панель отвечает 404 — обнови закладки и адрес${NC}" + echo -e " ${YLW}внешнего API (/${UI_PREFIX}/api/v1/…). Вернуть как было: UI_PREFIX= в cli.env${NC}" + fi + rule + fi + echo + exit 0 +fi + +step "Конфигурация" + +# Внешний IPv4 — через ifconfig.me, иначе адрес, с которого сервер ходит наружу. Не спрашиваем. +SERVER_IP=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || true) +if ! [[ "$SERVER_IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + SERVER_IP=$(ip route get 1.1.1.1 2>/dev/null | awk '{for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit }}') +fi +[[ -n "$SERVER_IP" ]] || fail "Не удалось определить адрес сервера" +ok "Адрес сервера: $SERVER_IP" + +# Что сохранять при переустановке — спрашиваем первым: от ответа зависят значения по умолчанию. +# С VPN (standalone, node): база пользователей и ключ сервера — только вместе: +# vpn:// ключи пользователей привязаны к ключу сервера. ui.db следует за ними. +# Панель без VPN (core): её данные — ui.db (серверы, ключи API, мастер-ключи). +KEEP_DATA="n" +if [[ "$ROLE" == "core" ]]; then + if [[ -f "$UI_DB_FILE" ]]; then + echo + warn "Найдены данные панели: $UI_DB_FILE" + ask KD "Сохранить серверы, ключи API и мастер-ключи? [Y/n]: " + [[ "${KD:-y}" =~ ^[Yy]$ ]] && KEEP_DATA="y" + fi +elif [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then + echo + warn "Найдена существующая установка:" + [[ -f "$DB_FILE" ]] && echo " база пользователей: $DB_FILE" + [[ -f "$PRIV_KEY_FILE" ]] && echo " ключ сервера: $PRIV_KEY_FILE" + + if [[ -f "$DB_FILE" && -f "$PRIV_KEY_FILE" && -f "$PUB_KEY_FILE" ]]; then + ask KD "Сохранить пользователей и ключ сервера? [Y/n]: " + [[ "${KD:-y}" =~ ^[Yy]$ ]] && KEEP_DATA="y" + else + warn "Для сохранения нужны и база, и оба ключа сервера — часть отсутствует." + warn "Пользователей не сохранить (vpn:// ключи стали бы невалидными)." + fi +fi + +# Панель (awg-ui), подписка sen:// и приём нод есть везде, кроме ноды. Значения из окружения +# (UI_PORT=…, SUB_PORT=…) важнее и не спрашиваются. +UI_PORT="${UI_PORT:-}"; UI_USER="${UI_USER:-}"; UI_PASS="${UI_PASS:-}" +SUB_PORT="${SUB_PORT:-}"; SUB_TLS="${SUB_TLS:-}"; NODE_PORT="${NODE_PORT:-}" +JWT_SECRET="" +if [[ "$ROLE" != "node" ]]; then + DEF=$(keep UI_PORT); DEF="${DEF:-$(gen_tcp_port)}" + ask_def UI_PORT "Порт панели [$DEF]: " "$DEF" + valid_port "$UI_PORT" || fail "Порт панели «$UI_PORT» — не номер порта" + + DEF=$(keep UI_USER); DEF="${DEF:-admin}" + ask_def UI_USER "Логин панели [$DEF]: " "$DEF" + + DEF=$(keep UI_PASS) + if [[ -n "$DEF" ]]; then + ask_def UI_PASS "Пароль панели [Enter — прежний]: " "$DEF" secret + else + DEF=$(rand_alnum 12) + ask_def UI_PASS "Пароль панели [$DEF]: " "$DEF" secret + fi + # Прежний секрет — чтобы открытые вкладки панели не разлогинило. + JWT_SECRET=$(keep JWT_SECRET); JWT_SECRET="${JWT_SECRET:-$(rand_alnum 32)}" + + SUB_PORT="${SUB_PORT:-$(keep SUB_PORT)}" + if [[ -z "$SUB_PORT" ]]; then + DEF=$(gen_tcp_port "$UI_PORT") + ask_def SUB_PORT "Порт подписки sen:// [$DEF]: " "$DEF" + fi + valid_port "$SUB_PORT" || fail "Порт подписки «$SUB_PORT» — не номер порта" + [[ "$SUB_PORT" != "$UI_PORT" ]] || fail "Порт подписки совпадает с портом панели ($UI_PORT)" + + SUB_TLS="${SUB_TLS:-$(keep SUB_TLS)}" + if [[ -z "$SUB_TLS" ]]; then + ask ST "Подписка sen:// по HTTPS (самоподписанный сертификат, домен не нужен)? [Y/n]: " + [[ "${ST:-y}" =~ ^[Nn]$ ]] && SUB_TLS="off" || SUB_TLS="on" + fi + [[ "$SUB_TLS" == "on" ]] || SUB_TLS="off" + + # Приём нод: ноды сами подключаются к панели по WSS на этот порт. Сертификат хаба awg-ui + # создаёт при первом запуске, его отпечаток зашит в строки подключения. Панели без VPN + # приём нод обязателен (иначе ей нечем управлять), у «панели + VPN» — по желанию. + NODE_PORT="${NODE_PORT:-$(keep NODE_PORT)}" + if [[ -z "$NODE_PORT" ]]; then + if [[ "$ROLE" == "core" ]]; then + DEF=$(gen_tcp_port "$UI_PORT" "$SUB_PORT") + ask_def NODE_PORT "Порт для подключения нод [$DEF]: " "$DEF" + else + ask NODE_PORT "Порт для подключения нод (Enter — не принимать ноды, «+» — случайный): " + [[ "$NODE_PORT" == "+" ]] && NODE_PORT=$(gen_tcp_port "$UI_PORT" "$SUB_PORT") + fi + fi + if [[ -n "$NODE_PORT" ]]; then + valid_port "$NODE_PORT" || fail "Порт для нод «$NODE_PORT» — не номер порта" + [[ "$NODE_PORT" != "$UI_PORT" && "$NODE_PORT" != "$SUB_PORT" ]] \ + || fail "Порт для нод совпадает с портом панели или подписки" + fi +fi +# awg-ctrl слушает только loopback; порт внутренний, при каждой установке новый. +AWGCTRL_PORT=$(gen_tcp_port "${UI_PORT:-}" "${SUB_PORT:-}" "${NODE_PORT:-}") + +# Порт, MTU и параметры обфускации VPN (choose_awg_params). +[[ "$ROLE" != "core" ]] && choose_awg_params + +if [[ "$ROLE" != "node" ]]; then + # Секретный путь панели: снаружи она отвечает только на http://IP:PORT/<путь>/, на остальное — + # 404, как пустой сервер. Без него сканер узнавал панель по странице логина одним GET. + # При переустановке путь прежний; UI_PREFIX=off — без пути, UI_PREFIX=<свой> — свой + # (8–64 символа A–Z a–z 0–9 _ -). + UI_PREFIX="${UI_PREFIX:-}" + UI_PREFIX_NEW="n" + if [[ "$UI_PREFIX" == "off" ]]; then + UI_PREFIX="" + elif [[ -z "$UI_PREFIX" ]]; then + UI_PREFIX=$(keep UI_PREFIX) + if [[ -z "$UI_PREFIX" ]]; then + UI_PREFIX=$(rand_alnum 16) + [[ -n "$PREV_CLI_ENV" ]] && UI_PREFIX_NEW="y" + fi + fi + [[ -z "$UI_PREFIX" || "$UI_PREFIX" =~ ^[A-Za-z0-9_-]{8,64}$ ]] \ + || fail "UI_PREFIX «${UI_PREFIX}» — нужно 8–64 символа A–Z a–z 0–9 _ -" + # Путь переехавшей сюда панели (migrate) — её закладкам и API через ретранслятор. + UI_PREFIX_MOVED=$(keep UI_PREFIX_MOVED) +fi + +# Нода: к какой панели подключаться (строка awgjoin://… из «Добавить сервер» в панели). CORE_HOST=""; CORE_PORT=""; CORE_PIN=""; NODE_ID=""; JOIN_SECRET="" if [[ "$ROLE" == "node" ]]; then JOIN="${JOIN:-}" @@ -1039,17 +1220,19 @@ if [[ "$ROLE" == "node" ]]; then fi fi -# Имя сервера — только там, где есть VPN. Если база уже существует (DB_EXISTS, проверено -# заранее в preflight) — имя берётся из неё, не спрашиваем. Если базы нет — спрашиваем. -SERVER_NAME="VPN" +# Имя сервера — только там, где есть VPN. При сохранённой базе имя хранится в ней (его меняют +# в панели) — не спрашиваем; иначе спрашиваем, по умолчанию прежнее. +SERVER_NAME="${SERVER_NAME:-}" if [[ "$ROLE" != "core" ]]; then - if [[ "$DB_EXISTS" == "y" ]]; then - echo " Server name: берётся из существующей базы (пропускаем)" + if [[ "$KEEP_DATA" == "y" ]]; then + SERVER_NAME="${SERVER_NAME:-$(prev_val SERVER_NAME)}" + info "Имя сервера — из сохранённой базы" else - ask SERVER_NAME "Server name [VPN]: " - SERVER_NAME="${SERVER_NAME:-VPN}" + DEF=$(prev_val SERVER_NAME); DEF="${DEF:-VPN}" + ask_def SERVER_NAME "Имя сервера [$DEF]: " "$DEF" fi fi +SERVER_NAME="${SERVER_NAME:-VPN}" # Сводка. Метки выровнены пробелами прямо в строках: printf в bash считает ширину в байтах, # и кириллица поехала бы. @@ -1063,7 +1246,7 @@ if [[ "$ROLE" != "core" ]]; then echo -e " ${DIM}VPN ${NC} ${AWG_PORT}/udp ${DIM}· MTU ${MTU}${NC}" fi if [[ "$ROLE" != "node" ]]; then - echo -e " ${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER${NC}" + echo -e " ${DIM}Панель ${NC} ${UI_PORT}/tcp ${DIM}· логин $UI_USER · $([[ -n "$UI_PREFIX" ]] && echo "секретный путь" || echo "без секретного пути")${NC}" echo -e " ${DIM}Подписка sen://${NC} ${SUB_PORT}/tcp ${DIM}· $([[ "$SUB_TLS" == "on" ]] && echo "HTTPS" || echo "HTTP, ответы подписаны")${NC}" if [[ -n "$NODE_PORT" ]]; then echo -e " ${DIM}Приём нод ${NC} ${NODE_PORT}/tcp" @@ -1088,6 +1271,14 @@ rule ask YN "Продолжить? [Y/n]: " [[ "${YN:-y}" =~ ^[Nn]$ ]] && { echo " Отменено."; exit 0; } +# Переустановка: прежние процессы и каталог убираем только после подтверждения — отказ на +# «Продолжить?» не должен оставлять сервер без установки. Данные (/etc/amnezia) на месте, +# cli.env прочитан в начале (PREV_CLI_ENV). +if [[ -d "$PROJECT" ]]; then + kill_related + rm -rf "${PROJECT:?}" +fi + # Совместимость (виртуализация, заголовки ядра, интернет) проверена выше в # секции «Проверка совместимости»; $KERNEL задан там же. step "1/6 AmneziaWG" @@ -1096,49 +1287,6 @@ if [[ "$ROLE" == "core" ]]; then skip "пропущено — у панели без VPN AmneziaWG не нужен" else - # Debian: add-apt-repository нет (software-properties-common из Debian 13 убран), - # а PPA под Debian-релизы не собирается — подключаем его вручную на Ubuntu-серию - # с glibc не новее дебиановской. Модуль приезжает DKMS-исходниками и от серии - # не зависит; серия важна только для бинарника amneziawg-tools. - # AMNEZIA_PPA_SUITE — задать серию вручную (focal/jammy/noble). - AMNEZIA_PPA_URL="https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu" - AMNEZIA_PPA_FPR="75C9DD72C799870E310542E24166F2C257290828" # Launchpad PPA for Iurii Egorov - AMNEZIA_KEYRING="/etc/apt/keyrings/amnezia-ppa.gpg" - add_amnezia_ppa_debian() { - local suite="${AMNEZIA_PPA_SUITE:-}" - if [[ -z "$suite" ]]; then - case "$OS_CODENAME" in - bullseye) suite="focal" ;; - bookworm) suite="jammy" ;; - trixie) suite="noble" ;; - *) suite="noble" - warn "Debian '${OS_CODENAME:-неизвестно}' не сопоставлен с серией PPA — берём noble (задать вручную: AMNEZIA_PPA_SUITE=…)" ;; - esac - fi - info "подключение PPA amnezia (Debian ${OS_CODENAME:-?} → серия $suite)" - - # Ключ берём по полному отпечатку и сверяем отпечаток после загрузки — - # apt-key в Debian 12+ уже нет, а доверять ответу keyserver вслепую нельзя. - local tmp gh fprs="" - tmp=$(mktemp); gh=$(mktemp -d) - if curl -fsSL --connect-timeout 15 \ - "https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0x${AMNEZIA_PPA_FPR}" \ - | gpg --dearmor > "$tmp" 2>/dev/null; then - fprs=$(GNUPGHOME="$gh" gpg --batch --show-keys --with-colons "$tmp" 2>/dev/null || true) - fi - if ! grep -q "^fpr:::::::::${AMNEZIA_PPA_FPR}:" <<< "$fprs"; then - rm -rf "$tmp" "$gh" - fail "Не удалось получить ключ PPA amnezia ($AMNEZIA_PPA_FPR) с keyserver.ubuntu.com" - fi - install -d -m 0755 /etc/apt/keyrings - install -m 0644 "$tmp" "$AMNEZIA_KEYRING" - rm -rf "$tmp" "$gh" - - echo "deb [signed-by=$AMNEZIA_KEYRING] $AMNEZIA_PPA_URL $suite main" \ - > /etc/apt/sources.list.d/amnezia-ppa.list - ok "PPA amnezia подключён: $suite, ключ $AMNEZIA_KEYRING" - } - if command -v awg &>/dev/null && command -v awg-quick &>/dev/null && modinfo amneziawg &>/dev/null; then ok "AWG уже установлен (модуль amneziawg: $(modinfo -F version amneziawg 2>/dev/null || echo present))" else @@ -1226,34 +1374,6 @@ else ok "AWG установлен, модуль amneziawg собран и загружается" fi - # Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5. - # - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0, - # поэтому сначала пробуем обновиться. - # - ядро: header protection использует библиотечный chacha-API - # (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся - # (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. - # При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий - # откат подсунул бы более слабую обфускацию незаметно для оператора. - # При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение - # по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно - # как раньше, а перейти на 3.1 можно потом из панели. - - kernel_lt_5_5() { - local maj min - maj=${KERNEL%%.*} - min=${KERNEL#*.}; min=${min%%.*} - [[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]] - } - - # 🛑 Версий у модуля две, и путать их нельзя: - # modinfo — что лежит на диске (после apt/DKMS); - # /sys/module/amneziawg/version — что реально загружено в ядро. - # `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный. - # Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый - # загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify - # interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля. - disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; } - loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; } if [[ "$AWG_GEN" == "2.0" ]]; then AWG3="n" @@ -1571,73 +1691,19 @@ else ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")" fi +step "5/6 Фаервол" + # Порты, которые должны быть доступны снаружи. Нода открывает только UDP для клиентов: к панели # она подключается сама. OPEN_PORTS=() [[ "$ROLE" != "core" ]] && OPEN_PORTS+=("${AWG_PORT}/udp") [[ "$ROLE" != "node" ]] && OPEN_PORTS+=("${UI_PORT}/tcp" "${SUB_PORT}/tcp") [[ "$ROLE" != "node" && -n "$NODE_PORT" ]] && OPEN_PORTS+=("${NODE_PORT}/tcp") - -# Закрывает ли голый iptables входящие: политика DROP или правило DROP/REJECT в INPUT. -iptables_input_closed() { - command -v iptables &>/dev/null || return 1 - iptables -S INPUT 2>/dev/null | grep -qE '^-P INPUT DROP|^-A INPUT .*-j (DROP|REJECT)' -} - -# Фаервол на самом сервере. Фаервол хостера (панель провайдера, security group) отсюда не -# виден — о нём напоминаем в итоге установки. -if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then - # UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец - # цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT - # дело не доходит и трафик клиентов наружу режется: в dmesg сыплется - # «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет». - # Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно. - info "UFW активен, открываем $([[ "$ROLE" != "core" ]] && echo "маршрут и ")порты" - if [[ "$ROLE" != "core" ]]; then - run ufw route allow in on "$IFACE" out on "$NET_IFACE" \ - || warn "ufw route allow не сработал — трафик клиентов может резаться" - fi - for P in "${OPEN_PORTS[@]}"; do - run ufw allow "$P" || warn "не удалось открыть $P" - done - run ufw reload || true - ok "UFW:$([[ "$ROLE" != "core" ]] && echo " форвардинг $IFACE → $NET_IFACE,") порты ${OPEN_PORTS[*]}" -elif command -v firewall-cmd &>/dev/null && firewall-cmd --state &>/dev/null; then - info "firewalld активен, открываем порты" - for P in "${OPEN_PORTS[@]}"; do - run firewall-cmd --permanent --add-port="$P" || warn "не удалось открыть $P" - done - run firewall-cmd --reload || true - ok "firewalld: порты ${OPEN_PORTS[*]}" - if [[ "$ROLE" != "core" ]]; then - warn "Если VPN подключается, а интернета нет — firewalld режет форвардинг:" - warn " firewall-cmd --permanent --zone=trusted --add-interface=${IFACE} && firewall-cmd --reload" - fi -elif iptables_input_closed; then - # Голый iptables без обёрток: вставляем ACCEPT в начало INPUT (повторно не дублируем). - info "iptables закрывает входящие, открываем порты" - for P in "${OPEN_PORTS[@]}"; do - iptables -C INPUT -p "${P#*/}" --dport "${P%/*}" -j ACCEPT 2>/dev/null \ - || run iptables -I INPUT -p "${P#*/}" --dport "${P%/*}" -j ACCEPT \ - || warn "не удалось открыть $P" - done - if command -v netfilter-persistent &>/dev/null; then - run netfilter-persistent save || warn "правила iptables не сохранены — после перезагрузки порты закроются" - ok "iptables: порты ${OPEN_PORTS[*]} (сохранено)" - else - ok "iptables: порты ${OPEN_PORTS[*]}" - warn "Правила iptables не переживут перезагрузку: apt install iptables-persistent && netfilter-persistent save" - fi -else - info "Фаервол на сервере входящие не закрывает — открывать нечего" -fi - -step "5/6 Команда awg-ctrl" - -install_command +open_firewall step "6/6 Запуск сервисов" +install_command # cli.env по роли. Роль CLI узнаёт отсюда же: CORE_HOST → нода (awg-ctrl + awg-agent), # LOCAL_NODE=off → панель без VPN (только awg-ui), иначе — awg-ctrl + awg-ui. { @@ -1743,7 +1809,7 @@ rule echo -e " ${GRN}${BLD}✓ Готово${NC} ${DIM}${BRAND} ${VERSION} · ${ROLE_LABEL}${NC}" rule if [[ "$ROLE" != "node" ]]; then - echo -e " ${DIM}Панель ${NC} ${BLD}http://${SERVER_IP}:${UI_PORT}/${UI_PREFIX:+${UI_PREFIX}/}${NC}" + echo -e " ${DIM}Панель ${NC} ${BLD}$(panel_url "$SERVER_IP" "$UI_PORT" "$UI_PREFIX")${NC}" if [[ -n "$UI_PREFIX" ]]; then echo -e " ${DIM}секретный путь: без него панель отвечает 404 — сохрани адрес целиком${NC}" [[ "$UI_PREFIX_NEW" == "y" ]] && \ @@ -1768,5 +1834,6 @@ echo -e " ${YLW}⚠${NC} Если у хостера свой фаервол echo -e " ${BLD}${OPEN_PORTS[*]}${NC} ${DIM}иначе клиенты не подключатся, а на сервере всё будет выглядеть исправным${NC}" rule echo -e " ${DIM}awg-ctrl ${NC} меню: запуск, статус, логин и пароль, join/unjoin" +echo -e " ${DIM}awg-ctrl status ${NC} состояние сервисов и адрес панели" echo -e " ${DIM}systemctl restart awg-control${NC} перезапуск ${DIM}(автозапуск на загрузке включён)${NC}" echo From ea0791424938214fd04162eee0201f1383c6cd2e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:50:00 +0000 Subject: [PATCH 10/12] awg-ctrl: update, reinstall and uninstall from the menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The awg-ctrl menu only started, stopped and changed the panel login; updating or removing an installation meant finding and running install.sh by hand. The menu gets "Обновить", "Переустановить" and "Удалить" (7–9), and the same as `awg-ctrl update | reinstall | uninstall`. The work stays in install.sh, which now takes ACTION=update|reinstall|uninstall to skip its own menu. The CLI fetches it — the latest from INSTALLER_URL (GitHub by default) for update and reinstall, the copy shipped in the release archive for uninstall, so removal works offline — writes it to a temp file and execs bash on it in the same terminal. It must not run from /opt/awg-control or via `bash -c`: kill_related kills every process whose command line holds the install path, and would kill the installer itself. The CLI passes its original environment, not the one with cli.env loaded, or the installer would take UI_PORT, SERVER_NAME and the rest as explicit overrides. The installer deletes its temp copy on start. The release archive now includes install.sh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 11 +++- install.sh | 23 +++++++- internal/cli/cli.go | 7 ++- internal/cli/cmds.go | 12 +++- internal/cli/installer.go | 118 ++++++++++++++++++++++++++++++++++++++ tools/release/package.sh | 5 +- 6 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 internal/cli/installer.go diff --git a/README.md b/README.md index c5db2cd..4f6d868 100644 --- a/README.md +++ b/README.md @@ -119,14 +119,16 @@ sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/ma | `UI_PREFIX` | свой секретный путь панели (8–64 символа `A–Z a–z 0–9 _ -`); `off` — без пути | | `SERVER_NAME` | имя VPN-сервера — без вопроса | | `SUB_PORT`, `SUB_TLS`, `NODE_PORT` | порт и HTTPS подписки, порт хаба нод — без вопросов | -| `UNINSTALL` | `y` — удалить, см. ниже | +| `ACTION` | `update`, `reinstall` или `uninstall` — действие без меню | +| `UNINSTALL` | `y` — удалить без меню и без подтверждения | | `AMNEZIA_PPA_SUITE` | серия PPA для Debian (`focal`/`jammy`/`noble`) | | `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `AWG_LOCAL_TARBALL` | откуда брать архив релиза (своё зеркало, локальный файл) | | `BRAND`, `CHANNEL` | имя продукта в баннерах и панели | ### Обновление и удаление -Повторный запуск установщика на сервере с установкой предлагает: +Повторный запуск установщика на сервере с установкой — или пункты **7–9** меню `awg-ctrl`, или +команды `awg-ctrl update | reinstall | uninstall` — предлагает: 1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. Установке без секретного пути панели он выдаётся, и установщик печатает новый адрес: закладки и адрес @@ -461,8 +463,12 @@ awg-ctrl monitor install|status|remove # встроенный Prometheus awg-ctrl migrate export [файл] # перенос панели (см. «Несколько серверов») awg-ctrl migrate import <архив> awg-ctrl migrate cancel +awg-ctrl update | reinstall | uninstall # обновить, переустановить, удалить (через install.sh) ``` +`update` и `reinstall` скачивают свежий установщик (`INSTALLER_URL` в `cli.env` — своё +зеркало), `uninstall` берёт его копию из установки и работает без сети. + Через systemd (автозапуск на загрузке): ```bash @@ -485,6 +491,7 @@ systemctl start|stop|restart awg-control | `TRUST_PROXY` | IP или сети reverse-proxy, кроме loopback: от них верим `X-Forwarded-For` | | `METRICS_PEERS` | `on` — метрики по отдельным пирам | | `AUDIT` | `off` — выключить периодический аудит пиров | +| `INSTALLER_URL` | откуда `awg-ctrl update`/`reinstall` берут установщик (своё зеркало) | ## Лицензия [MIT](LICENSE) © 2026 Ivan Vasilev diff --git a/install.sh b/install.sh index a8884f6..4f6be13 100644 --- a/install.sh +++ b/install.sh @@ -13,6 +13,12 @@ set -euo pipefail +# Запущен из меню awg-ctrl — с временной копии (internal/cli/installer.go): убираем её сразу, +# bash дочитает скрипт из уже открытого файла. +case "${BASH_SOURCE[0]:-}" in + */awg-install-[0-9]*.sh) rm -f -- "${BASH_SOURCE[0]}" ;; +esac + # Цвета — настоящие ESC-символы ($'…'), а не '\033' строкой: так они работают и в echo -e, # и в приглашениях read -p. ACC — фиолетовый акцент панели (design.md). Без терминала # (перенаправили в файл) или с NO_COLOR — без цветов. @@ -821,11 +827,22 @@ echo -e " ${DIM}Лог: $LOGFILE${NC}" rule # Поверх существующей установки: обновить (настройки, пользователи и роль остаются), -# переустановить с нуля или удалить совсем (без меню — UNINSTALL=y). Спрашиваем первым — -# от ответа зависит, спрашивать ли роль. +# переустановить с нуля или удалить совсем. Спрашиваем первым — от ответа зависит, спрашивать +# ли роль. Без меню: ACTION=update|reinstall|uninstall (так запускают пункты меню awg-ctrl) +# или UNINSTALL=y — удалить без вопроса-подтверждения. INSTALL_MODE="fresh" +ACTION="${ACTION:-}" +[[ "$ACTION" =~ ^(|update|reinstall|uninstall)$ ]] \ + || fail "ACTION должен быть update, reinstall или uninstall (задано: $ACTION)" if [[ "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then INSTALL_MODE="uninstall" +elif [[ -n "$ACTION" ]]; then + [[ -d "$PROJECT" || "$ACTION" == "uninstall" ]] || fail "Обновлять нечего: в $PROJECT ничего не установлено" + case "$ACTION" in + update) INSTALL_MODE="update" ;; + reinstall) INSTALL_MODE="fresh" ;; + uninstall) INSTALL_MODE="uninstall" ;; + esac elif [[ -d "$PROJECT" ]]; then echo warn "Уже установлено в $PROJECT" @@ -1833,7 +1850,7 @@ rule echo -e " ${YLW}⚠${NC} Если у хостера свой фаервол (панель провайдера, security group) — открой там:" echo -e " ${BLD}${OPEN_PORTS[*]}${NC} ${DIM}иначе клиенты не подключатся, а на сервере всё будет выглядеть исправным${NC}" rule -echo -e " ${DIM}awg-ctrl ${NC} меню: запуск, статус, логин и пароль, join/unjoin" +echo -e " ${DIM}awg-ctrl ${NC} меню: запуск, статус, логин и пароль, обновление и удаление" echo -e " ${DIM}awg-ctrl status ${NC} состояние сервисов и адрес панели" echo -e " ${DIM}systemctl restart awg-control${NC} перезапуск ${DIM}(автозапуск на загрузке включён)${NC}" echo diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1c70246..82acbe1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -638,10 +638,10 @@ func (c *CLI) confirmYes(q string) bool { // ── Точка входа ───────────────────────────────────────────────────────────── -const usage = "Usage: cli [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor |migrate |cancel>>" +const usage = "Usage: cli [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor |migrate |cancel>|update|reinstall|uninstall>" // Commands — команды оператора; main передаёт сюда всё, что не роль сервиса. -var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"} +var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall"} // Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню. func Main(args []string) { @@ -686,6 +686,9 @@ func Main(args []string) { c.peersCmd(sub, hasSub, a2, hasA2) case "monitor": c.monitorCmd(sub) + case "update", "reinstall", "uninstall": + // Из командной строки — без своего вопроса: установщик сам спросит, что нужно. + c.runInstaller(args[0], false) default: fmt.Println(usage) os.Exit(1) diff --git a/internal/cli/cmds.go b/internal/cli/cmds.go index 79d9f25..24bac25 100644 --- a/internal/cli/cmds.go +++ b/internal/cli/cmds.go @@ -491,9 +491,13 @@ func (c *CLI) interactiveMenu() { %s Сменить логин UI %s Сменить пароль UI %s + %s Обновить + %s Переустановить + %s Удалить + %s %s Выход -`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("0")) +`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("7"), bold("8"), bold("9"), sep, bold("0")) } for { printMenu() @@ -518,6 +522,12 @@ func (c *CLI) interactiveMenu() { c.setCredentials("user") case "6": c.setCredentials("pass") + case "7": + c.runInstaller("update", true) + case "8": + c.runInstaller("reinstall", true) + case "9": + c.runInstaller("uninstall", true) case "0": os.Exit(0) default: diff --git a/internal/cli/installer.go b/internal/cli/installer.go new file mode 100644 index 0000000..37e9d9e --- /dev/null +++ b/internal/cli/installer.go @@ -0,0 +1,118 @@ +package cli + +// Обновить, переустановить, удалить — из меню awg-ctrl и командами `awg-ctrl update|reinstall| +// uninstall`. Работу делает install.sh (ACTION=… — без его меню): CLI находит скрипт, кладёт во +// временный файл и передаёт ему управление (exec), чтобы диалог шёл в том же терминале. +// +// Скрипт — только из /tmp: install.sh перед заменой файлов убивает процессы, в командной строке +// которых есть путь установки (kill_related). Запущенный из /opt/awg-control/install.sh — или +// текстом через `bash -c` — он убил бы сам себя. + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +// Откуда брать свежий установщик; INSTALLER_URL в cli.env — своё зеркало. +const defaultInstallerURL = "https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh" + +var installerActions = map[string]struct{ title, what string }{ + "update": {"Обновить", "новая версия; настройки, пользователи и роль сохранятся"}, + "reinstall": {"Переустановить", "с нуля; данные можно будет сохранить"}, + "uninstall": {"Удалить", "полностью: сервисы, данные, правила фаервола; копию данных предложит сохранить"}, +} + +// fetchInstaller — текст install.sh: для обновления и переустановки — свежий из сети (иначе +// поставилась бы та же версия), для удаления — копия из установки (работает и без сети). +// Копия — запасной путь и для переустановки. +func (c *CLI) fetchInstaller(action string) ([]byte, string, error) { + local := filepath.Join(c.root, "install.sh") + readLocal := func() ([]byte, string, error) { + b, err := os.ReadFile(local) + return b, local, err + } + if action == "uninstall" { + if b, src, err := readLocal(); err == nil { + return b, src, nil + } + } + url := env("INSTALLER_URL", defaultInstallerURL) + cl := &http.Client{Timeout: 30 * time.Second} + resp, err := cl.Get(url) + if err == nil { + defer resp.Body.Close() + if resp.StatusCode == 200 { + b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err == nil && strings.HasPrefix(string(b), "#!") { + return b, url, nil + } + } + err = fmt.Errorf("%s: HTTP %d", url, resp.StatusCode) + } + if action == "reinstall" { + if b, src, lerr := readLocal(); lerr == nil { + fmt.Printf(" %s\n", dim("Установщик не скачался ("+err.Error()+") — берём копию из установки, версия та же")) + return b, src, nil + } + } + return nil, "", err +} + +// runInstaller — подтверждение и exec install.sh с ACTION. Возвращается только при отказе или +// ошибке: после exec процесс CLI заменён установщиком. +func (c *CLI) runInstaller(action string, confirm bool) { + a, ok := installerActions[action] + if !ok { + errorf(" Неизвестное действие: %s", action) + return + } + if os.Geteuid() != 0 { + errorf(" Нужен root: sudo awg-ctrl %s", action) + return + } + if confirm && !c.confirmYes(fmt.Sprintf(" %s — %s. Продолжить?", a.title, a.what)) { + fmt.Println(" Отменено") + return + } + script, src, err := c.fetchInstaller(action) + if err != nil { + errorf(" Не удалось получить установщик: %v", err) + errorf(" %s", dim("Запустите его вручную (README, «Установка») или задайте INSTALLER_URL в "+c.envFile)) + return + } + f, err := os.CreateTemp("", "awg-install-*.sh") + if err != nil { + errorf(" %v", err) + return + } + f.Write(script) + f.Close() + fmt.Printf(" %s\n\n", dim("Установщик: "+src)) + bash := "/bin/bash" + if p, err := exec.LookPath("bash"); err == nil { + bash = p + } + // Исходное окружение, без cli.env: иначе UI_PORT, SERVER_NAME и прочие сохранённые значения + // установщик принял бы за явно заданные параметры. + environ := append(withoutKey(c.origEnv, "ACTION"), "ACTION="+action) + if err := syscall.Exec(bash, []string{"bash", f.Name()}, environ); err != nil { + errorf(" Не удалось запустить установщик: %v", err) + } +} + +func withoutKey(environ []string, key string) []string { + out := make([]string, 0, len(environ)+1) + for _, kv := range environ { + if !strings.HasPrefix(kv, key+"=") { + out = append(out, kv) + } + } + return out +} diff --git a/tools/release/package.sh b/tools/release/package.sh index a99eb34..3af0593 100755 --- a/tools/release/package.sh +++ b/tools/release/package.sh @@ -1,6 +1,7 @@ #!/bin/sh # package.sh <версия> — архивы релиза: по одному на архитектуру, awgcontrol-<версия>-linux-.tar.gz. -# Внутри: awg-control (статический Go-бинарник: сервисы и CLI), awg-ui/dist (фронтенд), .env. +# Внутри: awg-control (статический Go-бинарник: сервисы и CLI), awg-ui/dist (фронтенд), .env и +# install.sh (им `awg-ctrl uninstall` удаляет установку без сети). # Фронтенд должен быть собран заранее (npm run build в awg-ui). Общий для GitHub и Gitea. set -eu VERSION="$1" @@ -11,7 +12,7 @@ for ARCH in amd64 arm64; do CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ -o "$D/awg-control" ./cmd/awg-control cp -r awg-ui/dist "$D/awg-ui/dist" - cp .env "$D/" + cp .env install.sh "$D/" tar -czf "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz" "$D/" rm -rf "$D" echo "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz" From 0a9eaa8c8e8c44d26efc15124904d5e201dadf37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:55:39 +0000 Subject: [PATCH 11/12] awg-ctrl: commands for the secret path, connection log, metrics, hub, check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new features were only reachable from the web panel. awg-ctrl now has: - panel [path new|off|] — the panel address; a new random, own or no secret path (cli.env, panel restarted, VPN untouched); - log [] [-n N] and log seen — the connection log of this server and who was online when, with sen:// device and master key names from ui.db when the panel is on the same machine; - metrics [token|revoke] — the metrics-only token for an external Prometheus, printed once with a ready prometheus.yml fragment that carries the path; - hub [legacy on|off|auto] — nodes with and without the pre-upgrade signature and the HUB_LEGACY mode; - check — what this server gives away: shared pre-randomization VPN values or no own I1, no secret path, plain-HTTP panel and subscription, the generic "sen"/"awg-node" certificate names, an open hub — each with the fix. The interactive menu gets items 10–13: panel address and path, connection log, who was online, and the check. Columns are padded by runes, not bytes, so Cyrillic names line up. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- README.md | 9 + internal/cli/cli.go | 14 +- internal/cli/cmds.go | 16 +- internal/cli/features.go | 565 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 601 insertions(+), 3 deletions(-) create mode 100644 internal/cli/features.go diff --git a/README.md b/README.md index 4f6d868..241af1f 100644 --- a/README.md +++ b/README.md @@ -464,8 +464,17 @@ awg-ctrl migrate export [файл] # перенос панели (с awg-ctrl migrate import <архив> awg-ctrl migrate cancel awg-ctrl update | reinstall | uninstall # обновить, переустановить, удалить (через install.sh) +awg-ctrl panel [path new|off|<путь>] # адрес панели; новый, свой или без секретного пути +awg-ctrl log [<поиск>] [-n N] # журнал VPN-подключений этого сервера +awg-ctrl log seen # кто когда был: онлайн, последний раз, не подключался +awg-ctrl metrics [token|revoke] # токен и prometheus.yml для своего Prometheus +awg-ctrl hub [legacy on|off|auto] # ноды хаба: кто с подписью, кто ещё старый +awg-ctrl check # что на сервере выдаёт его сканеру и DPI ``` +В меню `awg-ctrl` те же возможности — пункты 10–13: адрес и секретный путь панели, журнал, +«кто когда был» и проверка скрытности. + `update` и `reinstall` скачивают свежий установщик (`INSTALLER_URL` в `cli.env` — своё зеркало), `uninstall` берёт его копию из установки и работает без сети. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 82acbe1..d278ed5 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -638,10 +638,10 @@ func (c *CLI) confirmYes(q string) bool { // ── Точка входа ───────────────────────────────────────────────────────────── -const usage = "Usage: cli [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor |migrate |cancel>|update|reinstall|uninstall>" +const usage = "Usage: cli [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor |migrate |cancel>|update|reinstall|uninstall|panel [path new|off|<путь>]|log [seen] [поиск] [-n N]|metrics [token|revoke]|hub [legacy on|off|auto]|check>" // Commands — команды оператора; main передаёт сюда всё, что не роль сервиса. -var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall"} +var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall", "panel", "log", "metrics", "hub", "check"} // Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню. func Main(args []string) { @@ -686,6 +686,16 @@ func Main(args []string) { c.peersCmd(sub, hasSub, a2, hasA2) case "monitor": c.monitorCmd(sub) + case "panel": + c.panelCmd(args[1:]) + case "log": + c.logCmd(args[1:]) + case "metrics": + c.metricsCmd(args[1:]) + case "hub": + c.hubCmd(args[1:]) + case "check": + c.checkCmd() case "update", "reinstall", "uninstall": // Из командной строки — без своего вопроса: установщик сам спросит, что нужно. c.runInstaller(args[0], false) diff --git a/internal/cli/cmds.go b/internal/cli/cmds.go index 24bac25..feff845 100644 --- a/internal/cli/cmds.go +++ b/internal/cli/cmds.go @@ -495,9 +495,15 @@ func (c *CLI) interactiveMenu() { %s Переустановить %s Удалить %s + %s Адрес панели и секретный путь + %s Журнал подключений + %s Кто когда был в VPN + %s Проверка скрытности + %s %s Выход -`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("7"), bold("8"), bold("9"), sep, bold("0")) +`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("7"), bold("8"), bold("9"), sep, + bold("10"), bold("11"), bold("12"), bold("13"), sep, bold("0")) } for { printMenu() @@ -528,6 +534,14 @@ func (c *CLI) interactiveMenu() { c.runInstaller("reinstall", true) case "9": c.runInstaller("uninstall", true) + case "10": + c.panelMenu() + case "11": + c.logCmd(nil) + case "12": + c.logCmd([]string{"seen"}) + case "13": + c.checkCmd() case "0": os.Exit(0) default: diff --git a/internal/cli/features.go b/internal/cli/features.go new file mode 100644 index 0000000..a8d53eb --- /dev/null +++ b/internal/cli/features.go @@ -0,0 +1,565 @@ +package cli + +// Команды для новых возможностей панели — то же, что в веб-интерфейсе, но с сервера: +// panel [path new|off|<путь>] — адрес панели, секретный путь (UI_PREFIX) +// log [seen] [поиск] [-n N] — журнал VPN-подключений этого сервера, «кто когда был» +// metrics [token|revoke] — токен «только метрики» для своего Prometheus +// hub [legacy on|off|auto] — хаб нод: подпись до апгрейда, кто из нод ещё старый +// check — что на этом сервере выдаёт его сканеру и DPI + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "database/sql" + "encoding/pem" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/maeneko/forgetting/internal/migrate" +) + +// pad — дополнение пробелами по числу символов, не байт: в Printf %-Ns кириллица съезжает. +func pad(s string, n int) string { + if l := len([]rune(s)); l < n { + return s + strings.Repeat(" ", n-l) + } + return s +} + +func yellow(s string) string { return "\x1b[33m" + s + cReset } + +func (c *CLI) hasPanel() bool { return contains(c.all, "ui") || env("UI_PORT", "") != "" && !c.isNode } +func (c *CLI) hasVPN() bool { return contains(c.all, "awgctrl") } + +func (c *CLI) uiDB() (*sql.DB, error) { + f := c.svcEnv("ui", "UI_DB_FILE") + if !exists(f) { + return nil, fmt.Errorf("базы панели нет (%s)", f) + } + return migrate.OpenDB(f) +} + +// restartUI — cli.env изменён: панель перезапускается с новым значением, VPN не трогаем. +func (c *CLI) restartUI(key, value string) { + c.setSvcEnv("ui", key, value) + if c.isRunning("ui") { + c.stop("ui") + c.start("ui") + } +} + +func when(ts int64) string { + t := time.Unix(ts, 0) + now := time.Now() + switch { + case t.YearDay() == now.YearDay() && t.Year() == now.Year(): + return "сегодня " + t.Format("15:04") + case t.Year() == now.Year(): + return t.Format("02.01 15:04") + } + return t.Format("02.01.2006 15:04") +} + +func dur(s int64) string { + switch { + case s < 60: + return fmt.Sprintf("%d сек", s) + case s < 3600: + return fmt.Sprintf("%d мин", s/60) + case s < 86400: + return fmt.Sprintf("%d ч %d мин", s/3600, s%3600/60) + } + return fmt.Sprintf("%d дн %d ч", s/86400, s%86400/3600) +} + +func bytesStr(n float64) string { + switch { + case n < 1<<10: + return fmt.Sprintf("%.0f B", n) + case n < 1<<20: + return fmt.Sprintf("%.1f KB", n/(1<<10)) + case n < 1<<30: + return fmt.Sprintf("%.1f MB", n/(1<<20)) + } + return fmt.Sprintf("%.2f GB", n/(1<<30)) +} + +// ── panel ─────────────────────────────────────────────────────────────────── + +var rePrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) + +func randPrefix() string { + const abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 16) + rand.Read(b) + for i := range b { + b[i] = abc[int(b[i])%len(abc)] + } + return string(b) +} + +func (c *CLI) panelCmd(args []string) { + if !c.hasPanel() { + fmt.Println(" Панели на этой машине нет — это нода") + return + } + if len(args) == 0 { + c.showPanel() + return + } + if args[0] != "path" || len(args) < 2 { + errorf(" Usage: awg-ctrl panel [path new|off|<путь>]") + os.Exit(1) + } + c.setPanelPath(args[1]) +} + +func (c *CLI) showPanel() { + fmt.Printf("\n Панель %s\n", bold(panelURL())) + if p := env("UI_PREFIX", ""); p != "" { + fmt.Println(" " + dim("Секретный путь: снаружи без него — 404. Сменить: awg-ctrl panel path new")) + } else { + fmt.Println(" " + yellow("Без секретного пути: сканер узнаёт панель по странице логина.") + " " + dim("Включить: awg-ctrl panel path new")) + } + if m := env("UI_PREFIX_MOVED", ""); m != "" { + fmt.Println(" " + dim("Принимается и путь переехавшей панели: /"+m+"/")) + } + fmt.Println() +} + +// setPanelPath — new (случайный), off (без пути) или свой. Закладки и адрес внешнего API +// со старым путём перестанут работать — предупреждаем. +func (c *CLI) setPanelPath(arg string) { + v := arg + switch arg { + case "new": + v = randPrefix() + case "off": + v = "" + default: + if !rePrefix.MatchString(arg) { + errorf(" Путь: 8–64 символа A–Z a–z 0–9 _ -, или new / off") + os.Exit(1) + } + } + if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "UI_PREFIX", v)); err != nil { + errorf(" Не удалось записать %s: %v", c.envFile, err) + os.Exit(1) + } + os.Setenv("UI_PREFIX", v) + c.restartUI("UI_PREFIX", v) + fmt.Printf("\n %s Панель: %s\n", green("✓"), bold(panelURL())) + fmt.Println(" " + dim("Старый адрес больше не открывается: обновите закладки и адрес внешнего API и Prometheus.")) + fmt.Println() +} + +// ── log ───────────────────────────────────────────────────────────────────── + +var reOwner = regexp.MustCompile(`^(?:([a-z0-9]{8,16})/)?m(\d+)/d(\d+)$`) + +// ownerName — «устройство · ключ» для пира sen://, если его панель — на этой машине. +func (c *CLI) ownerName(db *sql.DB, owner string) string { + m := reOwner.FindStringSubmatch(owner) + if m == nil || db == nil || m[1] != "" && m[1] != c.localPanelID() { + return owner + } + var label, dev string + db.QueryRow("SELECT label FROM master_keys WHERE id = ?", m[2]).Scan(&label) + db.QueryRow("SELECT device_name FROM devices WHERE id = ?", m[3]).Scan(&dev) + switch { + case dev != "" && label != "": + return dev + " · «" + label + "»" + case label != "": + return "удалённое устройство · «" + label + "»" + } + return "удалённое устройство" +} + +func (c *CLI) logCmd(args []string) { + if !c.hasVPN() { + fmt.Println(" VPN на этой машине нет — журнал серверов во вкладке «Подключения» панели") + return + } + seen, limit, q := false, 50, "" + for i := 0; i < len(args); i++ { + switch a := args[i]; { + case a == "seen": + seen = true + case a == "-n" && i+1 < len(args): + n, err := strconv.Atoi(args[i+1]) + if err != nil || n <= 0 { + errorf(" -n: число событий") + os.Exit(1) + } + limit, i = min(n, 500), i+1 + default: + q = strings.TrimSpace(q + " " + a) + } + } + v := url.Values{"limit": {strconv.Itoa(limit)}} + if q != "" && !seen { + v.Set("q", q) + } + r, err := c.ctrlCall("GET", "/api/connlog?"+v.Encode(), nil) + if err != nil { + errorf(" awg-ctrl недоступен: %v", err) + os.Exit(1) + } + if r.status == 404 { + errorf(" awg-ctrl этой версии журнала не ведёт — обновите сервер: awg-ctrl update") + os.Exit(1) + } + if r.status != 200 { + errorf(" %v", ctrlError(r)) + os.Exit(1) + } + db, _ := c.uiDB() + if db != nil { + defer db.Close() + } + who := func(o any) (string, string) { + name, _ := get(o, "name").(string) + ip, _ := get(o, "ip").(string) + if name == "" { + if owner, _ := get(o, "owner").(string); owner != "" { + name = c.ownerName(db, owner) + } else { + pub, _ := get(o, "pub_key").(string) + name = "ключ " + pub[:min(10, len(pub))] + "…" + } + } + return name, ip + } + fmt.Println() + if since, ok := get(r.data, "since").(float64); ok { + fmt.Printf(" %s\n\n", dim(fmt.Sprintf("Журнал ведётся с %s, хранится %v дней. Время отключения — последняя активность (±30 с).", + when(int64(since)), get(r.data, "keep_days")))) + } + + if seen { + type row struct { + name, ip string + online bool + last int64 + } + var rows []row + peers, _ := get(r.data, "peers").([]any) + for _, p := range peers { + name, ip := who(p) + if q != "" && !strings.Contains(strings.ToLower(name+" "+ip), strings.ToLower(q)) { + continue + } + last, _ := get(p, "last_seen").(float64) + online, _ := get(p, "online").(bool) + rows = append(rows, row{name, ip, online, int64(last)}) + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].online != rows[j].online { + return rows[i].online + } + return rows[i].last > rows[j].last + }) + for _, x := range rows { + state := dim("не подключался с начала журнала") + switch { + case x.online: + state = green("онлайн") + case x.last > 0: + state = "был " + when(x.last) + dim(" ("+dur(time.Now().Unix()-x.last)+" назад)") + } + fmt.Printf(" %s %s %s\n", pad(x.name, 32), dim(pad(x.ip, 12)), state) + } + if len(rows) == 0 { + fmt.Println(" " + dim("Никого")) + } + fmt.Println() + return + } + + events, _ := get(r.data, "events").([]any) + if len(events) == 0 { + fmt.Println(" " + dim("Событий нет")) + } + // Старые сверху: читается как лента. + for i := len(events) - 1; i >= 0; i-- { + e := events[i] + at, _ := get(e, "at").(float64) + name, ip := who(e) + mark, tail := green("→ подключился"), "" + if get(e, "event") == "disconnect" { + mark = dim("← отключился ") + d, _ := get(e, "duration").(float64) + rx, _ := get(e, "rx").(float64) + tx, _ := get(e, "tx").(float64) + tail = dim(fmt.Sprintf(" %s · ↓ %s ↑ %s", dur(int64(d)), bytesStr(tx), bytesStr(rx))) + } + fmt.Printf(" %s %s %s %s%s\n", pad(when(int64(at)), 15), mark, name, dim(ip), tail) + } + fmt.Printf("\n %s\n\n", dim("Больше: awg-ctrl log -n 200 · по имени: awg-ctrl log <имя> · кто когда был: awg-ctrl log seen")) +} + +// ── metrics ───────────────────────────────────────────────────────────────── + +func (c *CLI) metricsCmd(args []string) { + if !c.hasPanel() { + fmt.Println(" Панели на этой машине нет — токен метрик выдаёт панель") + return + } + db, err := c.uiDB() + if err != nil { + errorf(" %v", err) + os.Exit(1) + } + defer db.Close() + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "": + var prefix string + var created int64 + var last sql.NullInt64 + if db.QueryRow("SELECT prefix, created_at, last_used FROM metrics_token WHERE id = 1").Scan(&prefix, &created, &last) != nil { + fmt.Print("\n Токена для внешнего Prometheus нет. Выдать: awg-ctrl metrics token\n\n") + return + } + used := "ещё не было" + if last.Valid && last.Int64 > 0 { + used = when(last.Int64) + } + fmt.Printf("\n Токен %s · выдан %s · последний скрейп %s\n", bold(prefix), when(created), used) + fmt.Print(" " + dim("Перевыпустить: awg-ctrl metrics token · отозвать: awg-ctrl metrics revoke") + "\n\n") + case "token": + b := make([]byte, 24) + rand.Read(b) + tok := "awgm_" + b64url.EncodeToString(b) + h := sha256.Sum256([]byte(tok)) + if _, err := db.Exec("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)", + h[:], tok[:13]+"…", time.Now().Unix()); err != nil { + errorf(" ui.db: %v (панель этой версии ещё не создала таблицу — запустите её)", err) + os.Exit(1) + } + u, _ := url.Parse(panelURL()) + fmt.Printf("\n %s Токен (показывается один раз, прежний больше не работает):\n\n %s\n\n", green("✓"), bold(tok)) + fmt.Print(" Фрагмент prometheus.yml:\n\n") + fmt.Printf(" scrape_configs:\n - job_name: vpn-panel\n metrics_path: %smetrics\n authorization:\n credentials: %s\n static_configs:\n - targets: ['%s']\n\n", + u.Path, tok, u.Host) + fmt.Print(" " + dim("По голому HTTP токен и метрики идут открытым текстом — скрейпьте через nginx с TLS или по VPN.") + "\n\n") + case "revoke": + db.Exec("DELETE FROM metrics_token WHERE id = 1") + fmt.Printf(" %s Токен отозван\n", green("✓")) + default: + errorf(" Usage: awg-ctrl metrics [token|revoke]") + os.Exit(1) + } +} + +// ── hub ───────────────────────────────────────────────────────────────────── + +func (c *CLI) hubCmd(args []string) { + if !c.hasPanel() || env("NODE_PORT", "") == "" { + fmt.Println(" Хаб нод на этой машине не включён (NODE_PORT)") + return + } + if len(args) > 0 { + if args[0] != "legacy" || len(args) < 2 || !regexp.MustCompile(`^(on|off|auto)$`).MatchString(args[1]) { + errorf(" Usage: awg-ctrl hub [legacy on|off|auto]") + os.Exit(1) + } + v := args[1] + if v == "auto" { + v = "" + } + if err := c.writeEnvFile(updateEnvVar(c.readEnvFile(), "HUB_LEGACY", v)); err != nil { + errorf(" %v", err) + os.Exit(1) + } + os.Setenv("HUB_LEGACY", v) + c.restartUI("HUB_LEGACY", v) + fmt.Printf(" %s HUB_LEGACY=%s\n", green("✓"), args[1]) + } + db, err := c.uiDB() + if err != nil { + errorf(" %v", err) + os.Exit(1) + } + defer db.Close() + rows, err := db.Query("SELECT id, name, COALESCE(version, ''), COALESCE(last_seen, 0), COALESCE(preauth, 0), pub IS NOT NULL FROM nodes ORDER BY id") + if err != nil { + errorf(" ui.db: %v", err) + os.Exit(1) + } + type node struct { + id int64 + name, version string + seen int64 + preauth, joined bool + } + var list []node + for rows.Next() { + var n node + if rows.Scan(&n.id, &n.name, &n.version, &n.seen, &n.preauth, &n.joined) == nil { + list = append(list, n) + } + } + rows.Close() + old := 0 + fmt.Printf("\n Хаб нод :%s\n\n", env("NODE_PORT", "")) + for _, n := range list { + state := green(pad("с подписью", 28)) + if !n.preauth { + state, old = yellow(pad("старый агент — без подписи", 28)), old+1 + if !n.joined { + state = dim(pad("ещё не подключалась", 28)) + } + } + seen := dim("не подключалась") + if n.seen > 0 { + seen = "был " + when(n.seen) + } + fmt.Printf(" #%-3d %s %s %s %s\n", n.id, pad(n.name, 20), dim(pad(n.version, 12)), state, seen) + } + if len(list) == 0 { + fmt.Println(" " + dim("Нод нет")) + } + mode := env("HUB_LEGACY", "") + fmt.Println() + switch { + case mode == "on": + fmt.Println(" " + yellow("HUB_LEGACY=on: хаб пускает агентов без подписи — сканер узнаёт его по /node/v1")) + case mode == "off": + fmt.Println(" HUB_LEGACY=off: только с подписью. Старые агенты не подключатся, пока ноду не обновят") + case old > 0: + fmt.Println(" " + yellow(fmt.Sprintf("Переходный режим: %d нод(ы) без подписи — хаб пускает и без неё. Обновите их: awg-ctrl update на ноде", old))) + default: + fmt.Println(" " + green("Все ноды с подписью — без неё хаб отвечает 404")) + } + fmt.Print(" " + dim("Принудительно: awg-ctrl hub legacy on|off|auto") + "\n\n") +} + +// ── check ─────────────────────────────────────────────────────────────────── + +// Прежние общие параметры установок до рандомизации — по ним сервер узнаётся DPI. +var legacyAWG = map[string]string{"ListenPort": "47619", "S1": "90", "S2": "45", "H1": "1224800044-2116730834"} + +func readConf(file string) map[string]string { + out := map[string]string{} + raw, err := os.ReadFile(file) + if err != nil { + return nil + } + for _, l := range strings.Split(strings.SplitN(string(raw), "[Peer]", 2)[0], "\n") { + if k, v, ok := strings.Cut(l, "="); ok { + out[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return out +} + +func certCN(file string) string { + raw, err := os.ReadFile(file) + if err != nil { + return "" + } + blk, _ := pem.Decode(raw) + if blk == nil { + return "" + } + crt, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + return "" + } + return crt.Subject.CommonName +} + +// checkCmd — сводка: что на этом сервере выдаёт его сканеру (активное зондирование) и DPI. +func (c *CLI) checkCmd() { + ok := func(s string) { fmt.Println(" " + green("✓") + " " + s) } + bad := func(s, fix string) { fmt.Println(" " + yellow("⚠") + " " + s + "\n " + dim(fix)) } + fmt.Println() + if c.hasVPN() { + conf := readConf(filepath.Join(env("AWG_CONF_DIR", "/etc/amnezia/amneziawg"), "awg1.conf")) + switch { + case conf == nil: + bad("awg1.conf не прочитан", "запустите от root") + default: + var same []string + for k, v := range legacyAWG { + if conf[k] == v { + same = append(same, k) + } + } + sort.Strings(same) + if len(same) > 0 { + bad("VPN: общие для старых установок значения ("+strings.Join(same, ", ")+") — по ним DPI находит все такие серверы сразу", + "переустановка без сохранения данных (awg-ctrl reinstall) и раздача новых ключей") + } else { + ok("VPN: порт и параметры обфускации свои у этого сервера") + } + if conf["I1"] == "" { + bad("VPN: нет своего I1 — ключи получают общий пакет-маскировку", "awg-ctrl reinstall с сохранением данных выдаст свой — старые ключи продолжат работать") + } + } + } + if c.hasPanel() { + if env("UI_PREFIX", "") != "" { + ok("Панель: секретный путь — снаружи без него 404") + } else { + bad("Панель без секретного пути — сканер узнаёт её по странице логина", "awg-ctrl panel path new") + } + bad("Панель по голому HTTP: пути, JSON и токены видны DPI", "nginx с TLS перед панелью (README, «Панель за nginx») или вход по VPN") + if strings.ToLower(env("SUB_TLS", "off")) == "on" { + ok("Подписка sen:// по HTTPS") + } else { + bad("Подписка sen:// по HTTP: ответы подписаны, но видны DPI", "переустановка с SUB_TLS=on — ссылки придётся выдать заново") + } + if cn := certCN(c.svcEnv("ui", "SUB_TLS_CERT_FILE")); cn == "sen" { + bad("Сертификат подписки с общим именем «sen»", "имя меняется только с ключом — при переустановке без сохранения данных") + } + if env("NODE_PORT", "") != "" { + if cn := certCN(c.svcEnv("ui", "NODE_TLS_CERT_FILE")); cn == "awg-node" { + bad("Сертификат хаба с общим именем «awg-node»", "имя меняется только с ключом — ноды придётся подключить заново") + } + switch strings.ToLower(env("HUB_LEGACY", "")) { + case "on": + bad("Хаб пускает агентов без подписи (HUB_LEGACY=on)", "awg-ctrl hub legacy auto") + case "off": + ok("Хаб: только агенты с подписью") + default: + ok("Хаб: подпись до апгрейда; кто из нод ещё без неё — awg-ctrl hub") + } + } + } + fmt.Print("\n " + dim("Фаервол хостера (панель провайдера) отсюда не виден — открыты ли там лишние порты, проверьте сами.") + "\n\n") +} + +// panelMenu — пункт меню: адрес панели и смена секретного пути. +func (c *CLI) panelMenu() { + if !c.hasPanel() { + fmt.Println(" Панели на этой машине нет — это нода") + return + } + c.showPanel() + fmt.Println(" 1) Новый случайный путь 2) Свой путь 3) Без пути Enter — назад") + switch strings.TrimSpace(c.ask(" › ")) { + case "1": + c.setPanelPath("new") + case "2": + if p := strings.TrimSpace(c.ask(" Путь (8–64 символа A–Z a–z 0–9 _ -): ")); p != "" { + c.setPanelPath(p) + } + case "3": + if c.confirmYes(" Без пути сканер снова узнает панель по странице логина. Убрать путь?") { + c.setPanelPath("off") + } + } +} From f0949c91367e1ae83514e6405f3bf7a2bd791f98 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:57:21 +0000 Subject: [PATCH 12/12] Bump to Beta 0.4.8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ --- .env | 2 +- README.md | 2 +- awg-agent/package.json | 2 +- awg-ctrl/package.json | 2 +- awg-ui/package.json | 2 +- cli/package.json | 2 +- install.sh | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.env b/.env index 2b2942b..6ff54bc 100644 --- a/.env +++ b/.env @@ -11,4 +11,4 @@ BRAND=Forgetting CHANNEL=Beta -VERSION=0.4.7 +VERSION=0.4.8 diff --git a/README.md b/README.md index 241af1f..b410d69 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \ | Метод | Ответ | |---|---| -| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.7" }` | +| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.4.8" }` | | `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` | | `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` | diff --git a/awg-agent/package.json b/awg-agent/package.json index f035b4e..2b2e4d4 100644 --- a/awg-agent/package.json +++ b/awg-agent/package.json @@ -1,6 +1,6 @@ { "name": "awg-agent", - "version": "0.4.7", + "version": "0.4.8", "private": true, "description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl", "scripts": { diff --git a/awg-ctrl/package.json b/awg-ctrl/package.json index bb3a481..336a1e3 100644 --- a/awg-ctrl/package.json +++ b/awg-ctrl/package.json @@ -1,6 +1,6 @@ { "name": "awg-control", - "version": "0.4.7", + "version": "0.4.8", "description": "", "main": "dist/index.js", "scripts": { diff --git a/awg-ui/package.json b/awg-ui/package.json index 6a478af..ccf5e82 100644 --- a/awg-ui/package.json +++ b/awg-ui/package.json @@ -1,6 +1,6 @@ { "name": "awg-ui", - "version": "0.4.7", + "version": "0.4.8", "private": true, "scripts": { "dev": "vite", diff --git a/cli/package.json b/cli/package.json index 855ea6a..1938ece 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "awg-cli", - "version": "0.4.7", + "version": "0.4.8", "private": true, "dependencies": { "tsx": "^4.7.0", diff --git a/install.sh b/install.sh index 4f6be13..3ebc776 100644 --- a/install.sh +++ b/install.sh @@ -128,7 +128,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # При бампе версии правь оба места. -VERSION="0.4.7" +VERSION="0.4.8" CHANNEL="${CHANNEL:-Beta}" # Откуда брать архив и как называется продукт. По умолчанию — релизы на GitHub; источник