diff --git a/README.md b/README.md index f8553ee..c6bdf1d 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), прокси к awg-ctrl. Единственный сервис, доступный по сети. | +| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), прокси к awg-ctrl, публичный API `/api/v1` и своя база его ключей. Единственный сервис, доступный по сети. | | **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. | Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным. @@ -49,16 +49,53 @@ AWG_GEN=auto bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branc ## API -Отдельного публичного API с ключами нет: наружу смотрит только панель, и всё, -что она умеет, доступно по HTTP. Схема одна — логин отдаёт JWT на 24 часа, -дальше с ним дёргаются те же роуты, что и из браузера. Базовый URL — адрес -сервера и порт, выбранный при установке. +Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` — +стабильный контракт для сторонних программ, авторизация по API-ключу. +**Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый +URL в обоих случаях — адрес сервера и порт, выбранный при установке. > [!WARNING] -> Панель отдаёт приватные поля пиров (`vpn_key`, `psk_key`) — это не публичный -> контракт. Не выставляй её порт в интернет без TLS и ограничения по адресам. +> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не +> публичный контракт, в отличие от `/api/v1`. Не выставляй её порт в интернет +> без TLS и ограничения по адресам. -### Авторизация +### Публичный API (`/api/v1`) + +Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`. +Открытое значение показывается один раз при создании — на сервере лежит только +SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля: +`psk_key` и `pub_key` не покидают сервер. + +```bash +curl -s -X POST http://HOST:PORT/api/v1/users \ + -H "X-Api-Key: awgk_xxx" -H "Content-Type: application/json" \ + -d '{"name":"alice"}' | jq -r .vpn_key +``` + +| Метод | Что делает | +|---|---| +| `POST /api/v1/users` | Создаёт пира. Тело `{ "name": "alice" }`. `201` с `{ name, ip, gen, vpn_key }`; `400` — имя не прошло валидацию, `409` — уже есть | +| `GET /api/v1/users` | Список со счётчиками: `{ users: [{ name, ip, key_gen, online, lastHandshake, rx, tx }] }` | +| `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: у пира нет изменяемых полей, «перевыпустить» +ключ = удалить и создать заново. + +| Код | Когда | +|---|---| +| `401 {"error":"API key required"}` | заголовка нет или он не начинается с `awgk_` | +| `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` и +`DELETE /ui/apikeys/:id`. + +### Авторизация панели ```bash TOKEN=$(curl -s -X POST http://HOST:PORT/login \ @@ -71,7 +108,8 @@ 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`. +Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые +дёргает сама панель; в отличие от `/api/v1` их форма может меняться. ### Состояние diff --git a/awg-ui/package.json b/awg-ui/package.json index 7ee32b9..c0bc818 100644 --- a/awg-ui/package.json +++ b/awg-ui/package.json @@ -12,14 +12,14 @@ }, "dependencies": { "axios": "^1.7.0", - "jsonwebtoken": "^9.0.0", + "better-sqlite3": "^12.10.0", "qrcode": "^1.5.0", "react": "^18.3.0", "react-dom": "^18.3.0" }, "devDependencies": { "concurrently": "^9.0.0", - "@types/jsonwebtoken": "^9.0.0", + "@types/better-sqlite3": "^7.6.13", "@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 e980a81..eb493b7 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -6,6 +6,7 @@ import path from "path"; import fs from "fs"; import crypto from "crypto"; import axios from "axios"; +import Database from "better-sqlite3"; const app = express(); const PORT = Number(process.env.PORT) || 8080; @@ -50,6 +51,46 @@ function mintInternalToken(): string { const revoked = new Set(); +// ── Ключи внешнего API (/api/v1) ─────────────────────────────────────────── +// Живут в собственной БД awg-ui: awg-ctrl про них ничего не знает. Открытое +// значение ключа не хранится — только SHA-256 и префикс для показа в панели. +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 { @@ -128,6 +169,31 @@ app.get("/ui/brand", (_req: Request, res: Response) => { res.json({ brand: BRAND, channel: CHANNEL, version: VERSION }); }); +// Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут. +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({ @@ -143,6 +209,22 @@ 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, @@ -153,6 +235,50 @@ 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 } + +const ext = express.Router(); +ext.use(requireApiKey); + +// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не +// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ +// тот же, что у proxy() в такой ситуации. +type Handler = (req: Request, res: Response) => Promise; +const wrap = (fn: Handler) => (req: Request, res: Response) => { + fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); }); +}; + +ext.post("/users", wrap(async (req, res) => { + 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 }); +})); + +ext.get("/users", wrap(async (_req, res) => { + const r = await ctrl("GET", "/api/users/stats"); + res.status(r.status).json(r.data); +})); + +ext.get("/users/:name", wrap(async (req, res) => { + 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", wrap(async (req, res) => { + const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`); + res.status(r.status).json(r.data); +})); + +// ВАЖНО: /api/v1 монтируется ДО JWT-прокси на /api — иначе прокси перехватит +// его и потребует JWT вместо ключа. +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/lib/shared.ts b/awg-ui/src/lib/shared.ts index d7b0252..ec4473c 100644 --- a/awg-ui/src/lib/shared.ts +++ b/awg-ui/src/lib/shared.ts @@ -26,6 +26,16 @@ export interface ServerInfo { } // На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'. export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0'); +// Ключ внешнего API в списке вкладки: открытого значения тут нет — только +// префикс, сервер хранит лишь хэш. +export interface ApiKey { + id: number; + label: string; + prefix: string; + server_id: number; + created_at: number; + last_used: number | null; +} export interface PageProps { token: string; diff --git a/awg-ui/src/tabs/apikeys/apikeys.css b/awg-ui/src/tabs/apikeys/apikeys.css new file mode 100644 index 0000000..af9403a --- /dev/null +++ b/awg-ui/src/tabs/apikeys/apikeys.css @@ -0,0 +1,77 @@ +/* +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. +*/ +/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */ + +/* Открытый API-ключ (показывается один раз при создании) */ +.apikey-value { + font-family: var(--mono); + font-size: 13px; + color: var(--on-surface); + background: var(--surface-container); + border: 1px solid var(--outline-variant); + border-radius: 12px; + padding: 12px 14px; + width: 100%; + word-break: break-all; + user-select: all; +} + +.apikey-cards-list { display: none; flex-direction: column; gap: 12px; } + +.apikey-count { font-size: 12px; color: var(--neutral-text); } +.apikey-empty { + font-size: 14px; + color: var(--neutral-text); + text-align: center; + padding: 48px 0; +} +.apikey-card { + background: var(--surface-bright); + border: 1px solid var(--outline-variant); + border-radius: var(--r-card); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} +.apikey-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.apikey-card-title { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} +.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; } +.apikey-card-label { + font-size: 16px; + font-weight: 500; + color: var(--on-surface); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.apikey-card-prefix { + font-family: var(--mono); + font-size: 13px; + color: var(--neutral-text); + word-break: break-all; +} +.apikey-card-meta { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 12px; + color: var(--neutral-text); +} + +@media (max-width: 640px) { + .apikey-cards-list { display: flex; } +} diff --git a/awg-ui/src/tabs/apikeys/index.tsx b/awg-ui/src/tabs/apikeys/index.tsx new file mode 100644 index 0000000..af5f82a --- /dev/null +++ b/awg-ui/src/tabs/apikeys/index.tsx @@ -0,0 +1,148 @@ +// 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, type ApiKey, type PageProps } from '../../lib/shared'; +import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons'; +import './apikeys.css'; + +// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается +// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App. +// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список +// серверов — ключ будет привязываться к выбранному в сервер-баре. +export default function ApiKeysPage({ token, showMsg }: PageProps) { + const [keys, setKeys] = useState([]); + const [label, setLabel] = useState(''); + const [created, setCreated] = useState<{ label: string; key: string } | null>(null); + + const load = useCallback(async () => { + try { + const data = await apiFetch('GET', '/ui/apikeys', token); + setKeys(data.keys ?? []); + } catch { + showMsg('Ошибка загрузки ключей'); + } + }, [token, showMsg]); + + const createKey = useCallback(async () => { + if (!label.trim()) return; + const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 }); + if (r.error) { showMsg(r.error); return; } + setLabel(''); + setCreated({ label: r.label, key: r.key }); + await load(); + }, [label, token, load, showMsg]); + + const deleteKey = useCallback(async (id: number, lbl: string) => { + if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return; + await apiFetch('DELETE', '/ui/apikeys/' + id, token); + showMsg('Ключ удалён: ' + lbl); + await load(); + }, [token, load, showMsg]); + + useEffect(() => { if (token) load(); }, [token, load]); + + return ( + <> + {/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ. + TODO multi-server: server_id берётся из выбранной карточки (пока 0). */} +
+ setLabel(e.target.value)} + onKeyDown={e => e.key === 'Enter' && createKey()} + /> +
+ +
+
+ +
+ + + + + + + + + + + + + {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 new file mode 100644 index 0000000..83c1880 --- /dev/null +++ b/awg-ui/src/tabs/apikeys/metadata.json @@ -0,0 +1,6 @@ +{ + "id": "apikeys", + "label": "API-ключи", + "icon": "key", + "order": 2 +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 628c2fc..c41e38f 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -90,6 +90,9 @@ const SERVICES = { UI_USER: process.env.UI_USER ?? "admin", UI_PASS: process.env.UI_PASS ?? "", JWT_SECRET: process.env.JWT_SECRET ?? "", + // ui.db (ключи внешнего API) в каталоге данных AWG (вне PROJECT) — + // переживает переустановку. + UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db", }, pidFile: "/tmp/awg-ui.pid", logFile: path.join(LOGS, "awg-ui.log"), diff --git a/install.sh b/install.sh index a176762..44eec80 100644 --- a/install.sh +++ b/install.sh @@ -48,6 +48,7 @@ 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" @@ -658,6 +659,15 @@ 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)