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:
@@ -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<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 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<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(express.static(path.join(__dirname, "dist")));
|
||||
|
||||
Reference in New Issue
Block a user