Bring back the external API keys
Commit0dbb13fremoved the whole feature: the apikeys tab, the /ui/apikeys routes, requireApiKey, the /api/v1 router, the ApiKey type, the better-sqlite3 dependency and UI_DB_FILE in the CLI and the installer. Only the untracked awg-ui/API.md survived, so the README went on to claim there was no key-based API at all. Restores all of it: ui.db with the api_keys table, awgk_ key generation storing only the SHA-256 and a display prefix, the JWT-guarded CRUD, and the /api/v1 router mounted before the JWT proxy on /api so the key auth is not swallowed. The tab comes back as its own folder and the registry picks it up on its own. UI_DB_FILE points at /etc/amnezia/amneziawg/ui.db, outside the install tree, so reinstalling keeps the keys; a fresh install moves the old one aside with its WAL sidecars. better-sqlite3 takes the slot of jsonwebtoken, which arrived with the MA7 integration and has been unused since068cab4removed it. One defect is not reproduced: the /api/v1 handlers called ctrl() without a try/catch, and Express 4 does not catch async rejections, so a single request with awg-ctrl down killed the panel process. They now answer 502 like proxy() already did. The API contract is documented in the README rather than linked: .gitignore excludes every markdown but README.md, so awg-ui/API.md is not in the repo and the link would break on a clone. Checked against a running server: login, key create/list/delete, 400 on a bad label, 401 without a JWT, "API key required" rather than "Unauthorized" on /api/v1, "Invalid API key" for a deleted key, last_used updating, and all three /api/v1 routes returning 502 with the process still alive.
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
| Компонент | Роль |
|
| Компонент | Роль |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
|
| **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-меню. |
|
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
|
||||||
|
|
||||||
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
|
Внутренняя связь 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
|
||||||
|
|
||||||
Отдельного публичного API с ключами нет: наружу смотрит только панель, и всё,
|
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` —
|
||||||
что она умеет, доступно по HTTP. Схема одна — логин отдаёт JWT на 24 часа,
|
стабильный контракт для сторонних программ, авторизация по API-ключу.
|
||||||
дальше с ним дёргаются те же роуты, что и из браузера. Базовый URL — адрес
|
**Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый
|
||||||
сервера и порт, выбранный при установке.
|
URL в обоих случаях — адрес сервера и порт, выбранный при установке.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Панель отдаёт приватные поля пиров (`vpn_key`, `psk_key`) — это не публичный
|
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не
|
||||||
> контракт. Не выставляй её порт в интернет без TLS и ограничения по адресам.
|
> публичный контракт, в отличие от `/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
|
```bash
|
||||||
TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
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": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
|
| `POST /login` | `{ "token": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
|
||||||
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
|
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
|
||||||
|
|
||||||
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`.
|
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые
|
||||||
|
дёргает сама панель; в отличие от `/api/v1` их форма может меняться.
|
||||||
|
|
||||||
### Состояние
|
### Состояние
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -12,14 +12,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"qrcode": "^1.5.0",
|
"qrcode": "^1.5.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0"
|
"react-dom": "^18.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.0",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/qrcode": "^1.5.0",
|
"@types/qrcode": "^1.5.0",
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from "path";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = Number(process.env.PORT) || 8080;
|
const PORT = Number(process.env.PORT) || 8080;
|
||||||
@@ -50,6 +51,46 @@ function mintInternalToken(): string {
|
|||||||
|
|
||||||
const revoked = new Set<string>();
|
const revoked = new Set<string>();
|
||||||
|
|
||||||
|
// ── Ключи внешнего 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 tokenSig(token: string): string { return token.split(".")[2] ?? token; }
|
||||||
|
|
||||||
function sign(): string {
|
function sign(): string {
|
||||||
@@ -128,6 +169,31 @@ app.get("/ui/brand", (_req: Request, res: Response) => {
|
|||||||
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
|
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) {
|
async function proxy(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const r = await axios({
|
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) {
|
async function ctrl(method: string, urlPath: string, body?: unknown) {
|
||||||
return axios({
|
return axios({
|
||||||
method,
|
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<void>;
|
||||||
|
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(["/api", "/health", "/awg"], requireAuth, proxy);
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "dist")));
|
app.use(express.static(path.join(__dirname, "dist")));
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ export interface ServerInfo {
|
|||||||
}
|
}
|
||||||
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
|
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
|
||||||
export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 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 {
|
export interface PageProps {
|
||||||
token: string;
|
token: string;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<ApiKey[]>([]);
|
||||||
|
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). */}
|
||||||
|
<div className="toolbar">
|
||||||
|
<input
|
||||||
|
className="field"
|
||||||
|
placeholder="Метка ключа (напр. ci-bot)"
|
||||||
|
value={label}
|
||||||
|
onChange={e => setLabel(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && createKey()}
|
||||||
|
/>
|
||||||
|
<div className="toolbar-btns">
|
||||||
|
<button className="btn btn--primary" onClick={createKey}>
|
||||||
|
<IcoPlus /> Создать ключ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Метка</th>
|
||||||
|
<th>Ключ</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th>Последний раз</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
|
||||||
|
) : keys.map((k, i) => (
|
||||||
|
<tr key={k.id}>
|
||||||
|
<td className="td-num">{i + 1}</td>
|
||||||
|
<td>{k.label}</td>
|
||||||
|
<td className="td-mono">{k.prefix}</td>
|
||||||
|
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||||
|
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||||
|
<td className="td-actions">
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
className="btn-icon btn-icon--danger"
|
||||||
|
title="Удалить ключ"
|
||||||
|
onClick={() => deleteKey(k.id, k.label)}
|
||||||
|
><IcoTrash /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
|
||||||
|
<div className="apikey-cards-list">
|
||||||
|
<p className="apikey-count">{keys.length} ключей</p>
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<p className="apikey-empty">Нет ключей</p>
|
||||||
|
) : keys.map((k, i) => (
|
||||||
|
<div className="apikey-card" key={k.id}>
|
||||||
|
<div className="apikey-card-header">
|
||||||
|
<div className="apikey-card-title">
|
||||||
|
<span className="apikey-card-num">#{i + 1}</span>
|
||||||
|
<span className="apikey-card-label">{k.label}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn--danger btn--sq"
|
||||||
|
aria-label="Удалить ключ"
|
||||||
|
onClick={() => deleteKey(k.id, k.label)}
|
||||||
|
><IcoTrash /></button>
|
||||||
|
</div>
|
||||||
|
<code className="apikey-card-prefix">{k.prefix}</code>
|
||||||
|
<div className="apikey-card-meta">
|
||||||
|
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
|
||||||
|
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{created && (
|
||||||
|
<div className="qr-backdrop" onClick={() => setCreated(null)}>
|
||||||
|
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||||
|
<p className="qr-name">Ключ «{created.label}» создан</p>
|
||||||
|
<p className="qr-hint">
|
||||||
|
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
|
||||||
|
</p>
|
||||||
|
<code className="apikey-value">{created.key}</code>
|
||||||
|
<div className="qr-actions">
|
||||||
|
<button className="btn btn--primary" onClick={async () => {
|
||||||
|
await copyText(created.key);
|
||||||
|
showMsg('Ключ скопирован');
|
||||||
|
}}><IcoCopy /> Скопировать</button>
|
||||||
|
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "apikeys",
|
||||||
|
"label": "API-ключи",
|
||||||
|
"icon": "key",
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
@@ -90,6 +90,9 @@ const SERVICES = {
|
|||||||
UI_USER: process.env.UI_USER ?? "admin",
|
UI_USER: process.env.UI_USER ?? "admin",
|
||||||
UI_PASS: process.env.UI_PASS ?? "",
|
UI_PASS: process.env.UI_PASS ?? "",
|
||||||
JWT_SECRET: process.env.JWT_SECRET ?? "",
|
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",
|
pidFile: "/tmp/awg-ui.pid",
|
||||||
logFile: path.join(LOGS, "awg-ui.log"),
|
logFile: path.join(LOGS, "awg-ui.log"),
|
||||||
|
|||||||
+10
@@ -48,6 +48,7 @@ PRIV_KEY_FILE="$AMNEZIA_DIR/server_private.key"
|
|||||||
PUB_KEY_FILE="$AWG_DIR/server_public.key"
|
PUB_KEY_FILE="$AWG_DIR/server_public.key"
|
||||||
AWG_CONF="$AWG_DIR/awg1.conf"
|
AWG_CONF="$AWG_DIR/awg1.conf"
|
||||||
DB_FILE="$AWG_DIR/users.db"
|
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.
|
# Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl.
|
||||||
INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key"
|
INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key"
|
||||||
INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key"
|
INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key"
|
||||||
@@ -658,6 +659,15 @@ else
|
|||||||
mv "$DB_FILE" "$DB_BAK"
|
mv "$DB_FILE" "$DB_BAK"
|
||||||
warn "Старая база пользователей сохранена: $DB_BAK"
|
warn "Старая база пользователей сохранена: $DB_BAK"
|
||||||
fi
|
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)
|
PRIV_KEY=$(umask 077 && awg genkey)
|
||||||
PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey)
|
PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user