From 0dbb13f120d588dcb2a26223abefe7bd0b5a2ece Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Thu, 27 Aug 2026 01:39:06 +0300 Subject: [PATCH] com --- README.md | 117 +++++--------------- awg-ui/package.json | 4 +- awg-ui/server.ts | 138 ++++++------------------ awg-ui/src/App.css | 22 +--- awg-ui/src/App.tsx | 1 - awg-ui/src/components/ServerBar.tsx | 20 +--- awg-ui/src/lib/shared.ts | 9 +- awg-ui/src/tabs/apikeys/apikeys.css | 77 -------------- awg-ui/src/tabs/apikeys/index.tsx | 148 -------------------------- awg-ui/src/tabs/apikeys/metadata.json | 6 -- awg-ui/src/tabs/index.ts | 1 - cli/src/index.ts | 9 +- install.sh | 76 +++++++++---- 13 files changed, 129 insertions(+), 499 deletions(-) delete mode 100644 awg-ui/src/tabs/apikeys/apikeys.css delete mode 100644 awg-ui/src/tabs/apikeys/index.tsx delete mode 100644 awg-ui/src/tabs/apikeys/metadata.json diff --git a/README.md b/README.md index 397f1fe..1898dd2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ | Компонент | Роль | |---|---| | **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. | -| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), внешний API (`/api/v1`), прокси к awg-ctrl. Единственный сервис, доступный по сети. | +| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), интеграция с API MA7, прокси к awg-ctrl. Единственный сервис, доступный по сети. | | **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. | Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным. @@ -42,103 +42,38 @@ awg-ctrl systemctl start|stop|restart awg-control ``` -## 1. Публичный API (`/api/v1`) +## Интеграция с MA7 -Авторизация по API-ключу. Ответы содержат только публичные поля — приватные -ключи и PSK наружу не отдаются. +Этот сервер персональный — принадлежит одному абоненту MA7 (сервиса +VPN-подписки). Панель ничем не управляет в MA7 и не отдаёт наружу свой API: +вкладка «Профиль» только читает баланс своего владельца. Секрет MA7 живёт +только в `awg-ui` (`cli.env`) и в браузер не попадает — фронт ходит на свой +бэкенд, тот читает MA7. -### Авторизация +Нужные переменные (задаются при установке или руками в `cli.env`): -Каждый запрос обязан содержать заголовок: - -``` -X-Api-Key: awgk_<...> -``` - -Ключ создаётся в панели (вкладка «API-ключи») или через `POST /ui/apikeys` -(раздел 3). Открытое значение ключа показывается **один раз** при создании — -сохрани его сразу, дальше доступен только хэш. - -Ответы при проблемах с ключом: - -| Код | Когда | +| Переменная | Назначение | |---|---| -| `401 {"error":"API key required"}` | заголовок отсутствует или не начинается с `awgk_` | -| `401 {"error":"Invalid API key"}` | ключ не найден (удалён/неверный) | +| `MA7_API_BASE_URL` | адрес API MA7, например `https://amnesia.ma7neko.ru` | +| `MA7_JWT_SECRET` | секрет, которым панель сама подписывает `X-Bot-Token` (эндпоинта, выдающего токен, в MA7 нет) | +| `MA7_LOGIN` | логин владельца этого сервера в MA7 (`ma7_xxxxxx`) | -> Ключ привязан к серверу (`server_id`). Сейчас сервер один — поле всегда `0` +Вкладка «Профиль» просто показывает текущий баланс `MA7_LOGIN`. При установке +`MA7_LOGIN` заодно предлагается как логин панели (`UI_USER`) — сервер +персональный, и заказчик входит тем же именем, что знает по MA7; при желании +логин панели можно задать свой. Это разные переменные: `UI_USER` решает, кого +пускать в панель, `MA7_LOGIN` — чей баланс показывать. -### `POST /api/v1/users` — создать пользователя +`MA7_LOGIN` задаётся один раз при установке и **не редактируется из панели**: у панели +один общий пароль (`UI_USER`/`UI_PASS`) без отдельного клиентского входа, а +`MA7_JWT_SECRET` умеет искать в MA7 любого абонента — редактируемый в браузере +логин превратил бы личный баланс в инструмент подглядывания за чужими. -Создаёт VPN-пира и возвращает готовый `vpn://` ключ. +> [!WARNING] +> `MA7_JWT_SECRET` — мастер-ключ ко всему MA7: с ним можно читать данные любых +> пользователей MA7, не только этого сервера. Токены MA7 без срока действия, +> отзыва нет — «отобрать доступ» значит одновременно сменить `MA7_JWT_SECRET` +> во всех сервисах MA7 и здесь, в `cli.env`. -**Тело:** `{ "name": "<имя>" }` -Имя: `a–z A–Z 0–9 _ -`, 1–32 символа. - -**`201 Created`:** -```json -{ - "name": "alice", - "ip": "10.9.0.2", - "vpn_key": "vpn://AAAI..." -} -``` -| Код ошибки | Причина | -|---|---| -| `400` | имя не прошло валидацию | -| `409` | пользователь с таким именем уже есть | -```bash -curl -X POST http://HOST:PORT/api/v1/users \ - -H "X-Api-Key: awgk_xxx" \ - -H "Content-Type: application/json" \ - -d '{"name":"alice"}' -``` -### `GET /api/v1/users` — список пользователей -Пиры с трафиком и статусом онлайн. -**`200 OK`:** -```json -{ - "users": [ - { "name": "alice", "ip": "10.9.0.2", "online": true, "lastHandshake": 1718800000, "rx": 142400000, "tx": 9800000 }, - { "name": "bob", "ip": "10.9.0.3", "online": false, "lastHandshake": 0, "rx": 0, "tx": 0 } - ] -} -``` -- `lastHandshake` — unix-секунды последнего рукопожатия (`0` — не было). -- `rx`/`tx` — байты принято/отдано. - -```bash -curl http://HOST:PORT/api/v1/users -H "X-Api-Key: awgk_xxx" -``` - -### `GET /api/v1/users/:name` — получить ключ пользователя - -**`200 OK`:** -```json -{ "name": "alice", "ip": "10.9.0.2", "vpn_key": "vpn://AAAI..." } -``` - -| Код ошибки | Причина | -|---|---| -| `404` | пользователь не найден | - -```bash -curl http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx" -``` - -> Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс -> `vpn://`, base64url-декодировать, отбросить первые 4 байта (длина), распаковать -> `zlib inflate` → JSON; текст конфига лежит в `last_config.config`. - -### `DELETE /api/v1/users/:name` — удалить пользователя - -**`200 OK`:** -```json -{ "success": true, "name": "alice" } -``` - -```bash -curl -X DELETE http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx" -``` ## Лицензия [MIT](LICENSE) © 2026 Ivan Vasilev diff --git a/awg-ui/package.json b/awg-ui/package.json index ad87746..4c5b81a 100644 --- a/awg-ui/package.json +++ b/awg-ui/package.json @@ -12,14 +12,14 @@ }, "dependencies": { "axios": "^1.7.0", - "better-sqlite3": "^12.10.0", + "jsonwebtoken": "^9.0.0", "qrcode": "^1.5.0", "react": "^18.3.0", "react-dom": "^18.3.0" }, "devDependencies": { "concurrently": "^9.0.0", - "@types/better-sqlite3": "^7.6.13", + "@types/jsonwebtoken": "^9.0.0", "@types/qrcode": "^1.5.0", "@types/express": "^4.17.0", "@types/node": "^22.0.0", diff --git a/awg-ui/server.ts b/awg-ui/server.ts index 594176b..f0a072a 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -6,7 +6,7 @@ import path from "path"; import fs from "fs"; import crypto from "crypto"; import axios from "axios"; -import Database from "better-sqlite3"; +import { ma7 } from "./ma7"; const app = express(); const PORT = Number(process.env.PORT) || 8080; @@ -37,43 +37,6 @@ function mintInternalToken(): string { const revoked = new Set(); -interface ApiKeyRow { - id: number; label: string; key_hash: string; prefix: string; - server_id: number; created_at: number; last_used: number | null; -} - -const UI_DB_FILE = process.env.UI_DB_FILE || path.join(__dirname, "ui.db"); -const uidb = new Database(UI_DB_FILE); -uidb.pragma("journal_mode = WAL"); -uidb.exec(` - CREATE TABLE IF NOT EXISTS api_keys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - label TEXT NOT NULL, - key_hash TEXT NOT NULL UNIQUE, - prefix TEXT NOT NULL, - server_id INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - last_used INTEGER - ) -`); - -const keyStmts = { - list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"), - insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"), - delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"), - byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"), - touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"), -}; - -function hashKey(key: string): string { - return crypto.createHash("sha256").update(key).digest("hex"); -} - -function genApiKey(): { key: string; hash: string; prefix: string } { - const key = "awgk_" + crypto.randomBytes(24).toString("base64url"); - return { key, hash: hashKey(key), prefix: key.slice(0, 13) + "…" }; -} - function tokenSig(token: string): string { return token.split(".")[2] ?? token; } function sign(): string { @@ -146,29 +109,6 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => { res.json({ ok: true }); }); -app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => { - res.json({ keys: keyStmts.list.all() }); -}); - -app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => { - const { label, server_id } = req.body as { label?: string; server_id?: number }; - if (!label || !/^[\w \-]{1,40}$/.test(label)) { - res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return; - } - // TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится - // список серверов — валидировать его против реального реестра серверов. - const { key, hash, prefix } = genApiKey(); - const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000)); - res.status(201).json({ id: info.lastInsertRowid, label, prefix, key }); -}); - -app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => { - const id = Number(req.params.id); - if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; } - keyStmts.delete.run(id); - res.json({ success: true, id }); -}); - async function proxy(req: Request, res: Response) { try { const r = await axios({ @@ -184,22 +124,6 @@ async function proxy(req: Request, res: Response) { } } -// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий -// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный -// awg-ctrl по (req as ExtRequest).apiKey.server_id. - -interface ExtRequest extends Request { apiKey?: ApiKeyRow; } - -function requireApiKey(req: Request, res: Response, next: NextFunction) { - const key = (req.headers["x-api-key"] as string) ?? ""; - if (!key.startsWith("awgk_")) { res.status(401).json({ error: "API key required" }); return; } - const row = keyStmts.byHash.get(hashKey(key)) as ApiKeyRow | undefined; - if (!row) { res.status(401).json({ error: "Invalid API key" }); return; } - keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id); - (req as ExtRequest).apiKey = row; - next(); -} - async function ctrl(method: string, urlPath: string, body?: unknown) { return axios({ method, @@ -210,40 +134,38 @@ async function ctrl(method: string, urlPath: string, body?: unknown) { }); } -// Внешний контракт: наружу отдаём только name/ip/gen/vpn_key — psk_key и pub_key -// остаются внутри. gen — поколение AmneziaWG, на параметрах которого выдан ключ. -interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string } +// ── Интеграция с MA7 (вкладка «Профиль») ─────────────────────────────────── +// Сервер персональный — панель показывает баланс своего единственного +// владельца, не список абонентов. Логин владельца (MA7_LOGIN) — часть +// конфигурации сервера, задаётся один раз при установке (cli.env), а НЕ +// вводится через UI: бот-токен MA7_JWT_SECRET умеет искать любого абонента +// MA7, и если бы логин можно было менять прямо в панели, любой, кто знает +// пароль от панели, мог бы подсматривать баланс чужих абонентов — панель +// защищена одним общим UI_USER/UI_PASS, отдельного «клиентского» входа нет. +const MA7_LOGIN = process.env.MA7_LOGIN ?? ""; -const ext = express.Router(); -ext.use(requireApiKey); - -ext.post("/users", async (req: Request, res: Response) => { - const { name } = (req.body ?? {}) as { name?: string }; - const r = await ctrl("POST", "/api/users", { name }); - if (r.status >= 400) { res.status(r.status).json(r.data); return; } - const u = r.data as ExtUser; - res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); +app.get("/ui/ma7/profile", requireAuth, async (_req: Request, res: Response) => { + if (!/^[a-zA-Z0-9_-]{1,32}$/.test(MA7_LOGIN)) { + res.status(500).json({ error: "MA7_LOGIN не задан — укажи его в cli.env" }); return; + } + try { + const r = await ma7.get("/api/admin/users", { params: { q: MA7_LOGIN, limit: 1 } }); + if (r.status >= 400) { + const msg = r.status === 401 ? "MA7: неверный MA7_JWT_SECRET" + : r.status === 403 ? "MA7: не та роль в токене" + : (r.data as { error?: string })?.error ?? "Ошибка MA7"; + res.status(r.status === 401 || r.status === 403 ? 502 : r.status).json({ error: msg }); + return; + } + const user = (r.data.users as { login: string; balance: number }[] ?? []) + .find(u => u.login === MA7_LOGIN); + if (!user) { res.status(404).json({ error: "MA7_LOGIN не найден в MA7" }); return; } + res.json({ login: user.login, balance: user.balance }); + } catch { + res.status(502).json({ error: "MA7 недоступен" }); + } }); -ext.get("/users", async (_req: Request, res: Response) => { - const r = await ctrl("GET", "/api/users/stats"); - res.status(r.status).json(r.data); -}); - -ext.get("/users/:name", async (req: Request, res: Response) => { - const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`); - if (r.status >= 400) { res.status(r.status).json(r.data); return; } - const u = r.data as ExtUser; - res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); -}); - -ext.delete("/users/:name", async (req: Request, res: Response) => { - const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`); - res.status(r.status).json(r.data); -}); - -app.use("/api/v1", ext); - app.use(["/api", "/health", "/awg"], requireAuth, proxy); app.use(express.static(path.join(__dirname, "dist"))); diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index 7759ddb..54ebcfe 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -473,22 +473,6 @@ body { } .tip-wrap:hover::after { opacity: 1; } -.server-add { - display: inline-flex; - align-items: center; - gap: 6px; - height: 44px; - padding: 0 16px; - border-radius: var(--r-pill); - border: 1.5px dashed var(--outline); - background: transparent; - font-family: var(--font); - font-size: 13px; - color: var(--neutral-dot); - cursor: not-allowed; - white-space: nowrap; -} - .toolbar { display: flex; align-items: center; @@ -673,11 +657,9 @@ tbody td:last-child { padding-right: 20px; } .server-toggle { display: flex; } .server-online-chip { display: none; } - /* Кнопка «Добавить сервер» — полная ширина */ - .tip-wrap { width: 100%; } - .server-add { width: 100%; justify-content: center; } + .tip-wrap { width: 100%; } - /* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */ + /* На мобильной: угловую кнопку прячем, показываем перезапуск в сервер-баре */ .awg-restart { display: none; } .awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; } diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index 2cef3ed..a25cf7c 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -246,7 +246,6 @@ export default function App() { serverOnline={isOnline} onRestartAwg={restartAwg} restarting={restarting} - showAddServer={chrome.addServer !== false} showRestart={showRestart} /> )} diff --git a/awg-ui/src/components/ServerBar.tsx b/awg-ui/src/components/ServerBar.tsx index 7562bb2..64fca2c 100644 --- a/awg-ui/src/components/ServerBar.tsx +++ b/awg-ui/src/components/ServerBar.tsx @@ -2,22 +2,19 @@ // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. import { type ServerInfo, genLabel } from '../lib/shared'; -import { IcoPlus, IcoRefresh } from './icons'; +import { IcoRefresh } from './icons'; -// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной) -// перезапуск AWG под ними. Общий для вкладок — показывает (и в будущем выбирает) -// сервер, к которому относится содержимое вкладки (пиры / API-ключи). -// TODO multi-server: сейчас сервер один и всегда активен. Когда серверов -// станет несколько — карточки станут кликабельными, активная = выбранная. +// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под +// ней. Общий для вкладок — показывает сервер, к которому относится содержимое +// вкладки (пиры / абоненты). export default function ServerBar({ serverInfo, serverOnline, onRestartAwg, restarting, - showAddServer = true, showRestart = true, + showRestart = true, }: { serverInfo: ServerInfo | null; serverOnline: boolean; onRestartAwg: () => void; restarting: boolean; - showAddServer?: boolean; showRestart?: boolean; }) { return ( @@ -52,13 +49,6 @@ export default function ServerBar({ {serverInfo.peers} peers )} - {showAddServer && ( - - - - )} {/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */} {showRestart && ( - - - -
- - - - - - - - - - - - - {keys.length === 0 ? ( - - ) : keys.map((k, i) => ( - - - - - - - - - ))} - -
#МеткаКлючСозданПоследний раз
Нет ключей
{i + 1}{k.label}{k.prefix}{new Date(k.created_at * 1000).toLocaleDateString()}{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'} -
- -
-
-
- - {/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */} -
-

{keys.length} ключей

- {keys.length === 0 ? ( -

Нет ключей

- ) : keys.map((k, i) => ( -
-
-
- #{i + 1} - {k.label} -
- -
- {k.prefix} -
- Создан {new Date(k.created_at * 1000).toLocaleDateString()} - {k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'} -
-
- ))} -
- - {created && ( -
setCreated(null)}> -
e.stopPropagation()}> -

Ключ «{created.label}» создан

-

- Скопируй ключ сейчас — он показывается один раз и больше не будет доступен. -

- {created.key} -
- - -
-
-
- )} - - ); -} \ No newline at end of file diff --git a/awg-ui/src/tabs/apikeys/metadata.json b/awg-ui/src/tabs/apikeys/metadata.json deleted file mode 100644 index 83c1880..0000000 --- a/awg-ui/src/tabs/apikeys/metadata.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "id": "apikeys", - "label": "API-ключи", - "icon": "key", - "order": 2 -} diff --git a/awg-ui/src/tabs/index.ts b/awg-ui/src/tabs/index.ts index 270af99..daa907e 100644 --- a/awg-ui/src/tabs/index.ts +++ b/awg-ui/src/tabs/index.ts @@ -18,7 +18,6 @@ import { ICONS } from '../components/icons'; // Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true. export interface TabChrome { serverBar?: boolean; - addServer?: boolean; restart?: boolean; } diff --git a/cli/src/index.ts b/cli/src/index.ts index 3917793..b7abf5d 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -74,8 +74,13 @@ const SERVICES = { UI_USER: process.env.UI_USER ?? "admin", UI_PASS: process.env.UI_PASS ?? "", JWT_SECRET: process.env.JWT_SECRET ?? "", - // ui.db в каталоге данных AWG (вне PROJECT) — переживает переустановку. - UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db", + // Интеграция с API MA7 (см. awg-ui/ma7.ts) — секрет чужого сервиса, + // не совпадает с UI_* / JWT_SECRET этой панели. MA7_LOGIN — логин + // владельца этого персонального сервера в MA7 ('ma7_xxxxxx'): + // задаётся один раз при установке, в панели не редактируется. + MA7_API_BASE_URL: process.env.MA7_API_BASE_URL ?? "", + MA7_JWT_SECRET: process.env.MA7_JWT_SECRET ?? "", + MA7_LOGIN: process.env.MA7_LOGIN ?? "", }, pidFile: "/tmp/awg-ui.pid", logFile: path.join(LOGS, "awg-ui.log"), diff --git a/install.sh b/install.sh index d0e2a07..f2b8544 100644 --- a/install.sh +++ b/install.sh @@ -29,7 +29,6 @@ PRIV_KEY_FILE="$AMNEZIA_DIR/server_private.key" PUB_KEY_FILE="$AWG_DIR/server_public.key" AWG_CONF="$AWG_DIR/awg1.conf" DB_FILE="$AWG_DIR/users.db" -UI_DB_FILE="$AWG_DIR/ui.db" # своя БД awg-ui (API-ключи); вне PROJECT — переживает переустановку # Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl. INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key" INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key" @@ -192,15 +191,29 @@ start_logging() { download_extract() { local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz" local tmp="/tmp/awgcontrol-${VERSION}.tar.gz" - echo " → версия: ${VERSION}" - curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \ - || fail "Не удалось скачать архив: $url" - ok "Архив скачан: $(du -sh "$tmp" | cut -f1)" + local keep_tarball=0 + + # AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах GitHub + # (или встать без сети). Архив должен быть той же формы, что делает CI: + # awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/, без node_modules. + if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then + [[ -f "$AWG_LOCAL_TARBALL" ]] \ + || fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL" + tmp="$AWG_LOCAL_TARBALL" + keep_tarball=1 # чужой файл — после распаковки не удаляем + echo " → локальный архив: $tmp" + ok "Архив взят локально: $(du -sh "$tmp" | cut -f1)" + else + echo " → версия: ${VERSION}" + curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \ + || fail "Не удалось скачать архив: $url" + ok "Архив скачан: $(du -sh "$tmp" | cut -f1)" + fi mkdir -p "$PROJECT" tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \ || fail "Не удалось распаковать архив" - rm -f "$tmp" + [[ "$keep_tarball" == 1 ]] || rm -f "$tmp" ok "Распакован → $PROJECT" local f @@ -344,8 +357,26 @@ AWGCTRL_PORT=$(( (RANDOM % 22768) + 32768 )) read -rp " UI port (Enter — случайный): " UI_PORT UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}" -read -rp " UI логин [admin]: " UI_USER -UI_USER="${UI_USER:-admin}" +# Интеграция с API MA7 (см. README.md, раздел «Интеграция с MA7») — этот +# сервер персональный, панель показывает баланс своего единственного +# владельца. Спрашивается ДО логина панели: логин MA7 по умолчанию становится +# и логином панели (см. ниже). Пустые значения допустимы — вкладку «Профиль» +# можно настроить позже вручную в cli.env. +echo +echo -e "${BLD}Интеграция с MA7 (Enter — оставить пустым и настроить позже):${NC}" +read -rp " MA7 API base URL [https://amnesia.ma7neko.ru]: " MA7_API_BASE_URL +MA7_API_BASE_URL="${MA7_API_BASE_URL:-https://amnesia.ma7neko.ru}" +read -rsp " MA7 JWT_SECRET (мастер-ключ MA7, см. предупреждение в README): " MA7_JWT_SECRET; echo +read -rp " Логин заказчика в MA7 (ma7_xxxxxx, владелец этого сервера): " MA7_LOGIN + +echo +# Логин панели по умолчанию = логин заказчика в MA7: сервер персональный, и +# заказчик входит тем же именем, что знает по MA7. Если MA7_LOGIN не задан — +# откатываемся на admin. UI_USER и MA7_LOGIN остаются разными переменными: +# первая решает, кого пускать в панель, вторая — чей баланс показывать. +UI_USER_DEFAULT="${MA7_LOGIN:-admin}" +read -rp " UI логин [$UI_USER_DEFAULT]: " UI_USER +UI_USER="${UI_USER:-$UI_USER_DEFAULT}" SUGGESTED_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12 2>/dev/null || openssl rand -hex 6) read -rsp " UI пароль [$SUGGESTED_PASS]: " UI_PASS; echo @@ -434,9 +465,18 @@ else fi ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build" - echo " → add-apt-repository ppa:amnezia/ppa" - add-apt-repository -y ppa:amnezia/ppa \ - || fail "Не удалось добавить PPA ppa:amnezia/ppa" + # Если репозиторий amnezia уже настроен (например, ключ и источник заведены + # руками — add-apt-repository ходит за ключом в Launchpad, а тот периодически + # отвечает GPGKeyTemporarilyNotFoundError), второй раз его добавлять не нужно. + # Проверяем именно файлы источников, а не `apt-cache policy`: сразу после + # apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту. + if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then + ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен" + else + echo " → add-apt-repository ppa:amnezia/ppa" + add-apt-repository -y ppa:amnezia/ppa \ + || fail "Не удалось добавить PPA ppa:amnezia/ppa" + fi echo " → apt-get update (после PPA)" apt-get update \ @@ -592,15 +632,6 @@ else mv "$DB_FILE" "$DB_BAK" warn "Старая база пользователей сохранена: $DB_BAK" fi - # ui.db (API-ключи awg-ui) — тоже в бэкап, чтобы awg-ui создал чистую БД. - if [[ -f "$UI_DB_FILE" ]]; then - UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)" - mv "$UI_DB_FILE" "$UI_DB_BAK" - # WAL-сайдкары удаляем — к новой БД они неприменимы. - rm -f "${UI_DB_FILE}-wal" "${UI_DB_FILE}-shm" - warn "Старая база API-ключей сохранена: $UI_DB_BAK" - fi - PRIV_KEY=$(umask 077 && awg genkey) PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey) @@ -820,6 +851,11 @@ UI_PORT=${UI_PORT} UI_USER=${UI_USER} UI_PASS=${UI_PASS} JWT_SECRET=${JWT_SECRET} + +# ── интеграция с MA7 (секрет чужого сервиса, не путать с JWT_SECRET выше) ── +MA7_API_BASE_URL=${MA7_API_BASE_URL} +MA7_JWT_SECRET=${MA7_JWT_SECRET} +MA7_LOGIN=${MA7_LOGIN} ENV chmod 600 "$PROJECT/cli/cli.env"