com
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+30
-108
@@ -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<string>();
|
||||
|
||||
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")));
|
||||
|
||||
+2
-20
@@ -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; }
|
||||
|
||||
|
||||
@@ -246,7 +246,6 @@ export default function App() {
|
||||
serverOnline={isOnline}
|
||||
onRestartAwg={restartAwg}
|
||||
restarting={restarting}
|
||||
showAddServer={chrome.addServer !== false}
|
||||
showRestart={showRestart}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
||||
</div>
|
||||
)}
|
||||
{showAddServer && (
|
||||
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
|
||||
<button className="server-add" disabled>
|
||||
<IcoPlus /> Добавить сервер
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
|
||||
{showRestart && (
|
||||
<button
|
||||
|
||||
@@ -26,14 +26,7 @@ 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');
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
label: string;
|
||||
prefix: string;
|
||||
server_id: number;
|
||||
created_at: number;
|
||||
last_used: number | null;
|
||||
}
|
||||
|
||||
export interface PageProps {
|
||||
token: string;
|
||||
showMsg: (text: string) => void;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
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; }
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "apikeys",
|
||||
"label": "API-ключи",
|
||||
"icon": "key",
|
||||
"order": 2
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { ICONS } from '../components/icons';
|
||||
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
|
||||
export interface TabChrome {
|
||||
serverBar?: boolean;
|
||||
addServer?: boolean;
|
||||
restart?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -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"),
|
||||
|
||||
+56
-20
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user