Nothing about the install was overridable: the archive could only come from one GitHub repo, the connectivity precheck pinged github.com whatever the source was, and the product name was duplicated across install.sh, the CLI banner and two spots in App.tsx. install.sh now reads REPO_BASE (default the Gitea mirror), REPO_FALLBACK (default GitHub), ARCHIVE_URL and BRAND from the environment. Gitea and GitHub share the same release path, so one scheme covers both: download_extract tries the primary, warns and falls back to the secondary, and fails with both URLs listed. The fallback carries real weight today — the Gitea repo is a pull mirror that does not sync releases, so artifacts live only on GitHub until release sync is enabled there. The precheck now probes the hosts actually used. BRAND flows install.sh -> cli.env -> CLI banner and awg-ui, which exposes it via GET /ui/brand along with the package version. That route is unauthenticated because the wordmark is on the login screen, before a JWT exists; the SPA falls back to a constant if it fails. Renaming a fork is one environment variable, and no version string is hardcoded in the UI or the CLI any more. Also adds /ui to the Vite dev proxy (GET /ui/brand and the API-keys tab were unreachable in dev), documents the overrides in the README, fixes a typo in the API.md base URL and states there that the API is not bound to any host.
194 lines
8.8 KiB
TypeScript
194 lines
8.8 KiB
TypeScript
// 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 express, { Request, Response, NextFunction } from "express";
|
|
import path from "path";
|
|
import fs from "fs";
|
|
import crypto from "crypto";
|
|
import axios from "axios";
|
|
import { ma7 } from "./ma7";
|
|
|
|
const app = express();
|
|
const PORT = Number(process.env.PORT) || 8080;
|
|
|
|
const UI_USER = process.env.UI_USER ?? "admin";
|
|
const UI_PASS = process.env.UI_PASS ?? "";
|
|
const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex");
|
|
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
|
|
|
|
// Название продукта и версия для панели. BRAND приходит из cli.env (пишет
|
|
// install.sh), версия — из package.json: в semver она в форме 0.1.4+2, в
|
|
// баннерах и релизах — через точку.
|
|
const BRAND = process.env.BRAND || "Forgetting";
|
|
const VERSION = ((): string => {
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8"));
|
|
return String(pkg.version ?? "").replace("+", ".");
|
|
} catch { return ""; }
|
|
})();
|
|
|
|
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE
|
|
?? path.join(__dirname, "internal_auth_private.key");
|
|
let internalAuthKey: crypto.KeyObject | null = null;
|
|
try {
|
|
internalAuthKey = crypto.createPrivateKey(fs.readFileSync(INTERNAL_AUTH_KEY_FILE));
|
|
} catch {
|
|
console.warn("ui: приватный ключ внутренней авторизации не найден: " + INTERNAL_AUTH_KEY_FILE
|
|
+ " — запросы к awg-ctrl будут отклоняться");
|
|
}
|
|
|
|
function mintInternalToken(): string {
|
|
if (!internalAuthKey) throw new Error("приватный ключ внутренней авторизации не загружен");
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
|
|
const p = Buffer.from(JSON.stringify({ iss: "awg-ui", iat: now, exp: now + 60 })).toString("base64url");
|
|
const sig = crypto.sign(null, Buffer.from(`${h}.${p}`), internalAuthKey).toString("base64url");
|
|
return `${h}.${p}.${sig}`;
|
|
}
|
|
|
|
const revoked = new Set<string>();
|
|
|
|
function tokenSig(token: string): string { return token.split(".")[2] ?? token; }
|
|
|
|
function sign(): string {
|
|
const h = Buffer.from('{"alg":"HS256","typ":"JWT"}').toString("base64url");
|
|
const p = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86400 })).toString("base64url");
|
|
const s = crypto.createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest("base64url");
|
|
return `${h}.${p}.${s}`;
|
|
}
|
|
|
|
function verify(token: string): boolean {
|
|
try {
|
|
if (revoked.has(tokenSig(token))) return false;
|
|
const [h, p, s] = token.split(".");
|
|
if (!h || !p || !s) return false;
|
|
const expected = crypto.createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest();
|
|
const actual = Buffer.from(s, "base64url");
|
|
if (actual.length !== expected.length) return false;
|
|
if (!crypto.timingSafeEqual(actual, expected)) return false;
|
|
const { exp } = JSON.parse(Buffer.from(p, "base64url").toString()) as { exp: number };
|
|
return exp > Math.floor(Date.now() / 1000);
|
|
} catch { return false; }
|
|
}
|
|
|
|
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
const auth = (req.headers["authorization"] ?? "") as string;
|
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
if (!token || !verify(token)) { res.status(401).json({ error: "Unauthorized" }); return; }
|
|
next();
|
|
}
|
|
|
|
const RATE_LIMIT = 5;
|
|
const RATE_WINDOW = 15 * 60 * 1000;
|
|
|
|
interface RateEntry { count: number; resetAt: number; }
|
|
const loginAttempts = new Map<string, RateEntry>();
|
|
|
|
function checkRate(ip: string): { ok: boolean; retryAfter?: number } {
|
|
const now = Date.now();
|
|
const entry = loginAttempts.get(ip);
|
|
if (!entry || entry.resetAt < now) {
|
|
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_WINDOW });
|
|
return { ok: true };
|
|
}
|
|
if (entry.count >= RATE_LIMIT) {
|
|
return { ok: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) };
|
|
}
|
|
entry.count++;
|
|
return { ok: true };
|
|
}
|
|
|
|
app.use(express.json());
|
|
|
|
app.post("/login", (req: Request, res: Response) => {
|
|
const ip = req.socket.remoteAddress ?? "unknown";
|
|
const { ok, retryAfter } = checkRate(ip);
|
|
if (!ok) {
|
|
res.status(429).json({ error: `Слишком много попыток. Повтори через ${retryAfter} сек.` }); return;
|
|
}
|
|
const { user, pass } = req.body as { user?: string; pass?: string };
|
|
if (!UI_PASS || user !== UI_USER || pass !== UI_PASS) {
|
|
res.status(401).json({ error: "Неверный логин или пароль" }); return;
|
|
}
|
|
loginAttempts.delete(ip);
|
|
res.json({ token: sign() });
|
|
});
|
|
|
|
app.post("/logout", requireAuth, (req: Request, res: Response) => {
|
|
const token = (req.headers["authorization"] as string).slice(7);
|
|
revoked.add(tokenSig(token));
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Единственный роут без авторизации: вордмарк нужен уже на экране логина,
|
|
// до выдачи JWT. Наружу уходит только имя продукта и версия.
|
|
app.get("/ui/brand", (_req: Request, res: Response) => {
|
|
res.json({ brand: BRAND, version: VERSION });
|
|
});
|
|
|
|
async function proxy(req: Request, res: Response) {
|
|
try {
|
|
const r = await axios({
|
|
method: req.method,
|
|
url: CTRL + req.originalUrl,
|
|
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
|
|
data: req.body,
|
|
validateStatus: () => true,
|
|
});
|
|
res.status(r.status).json(r.data);
|
|
} catch {
|
|
res.status(502).json({ error: "awg-ctrl недоступен" });
|
|
}
|
|
}
|
|
|
|
async function ctrl(method: string, urlPath: string, body?: unknown) {
|
|
return axios({
|
|
method,
|
|
url: CTRL + urlPath,
|
|
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
|
|
data: body,
|
|
validateStatus: () => true,
|
|
});
|
|
}
|
|
|
|
// ── Интеграция с MA7 (вкладка «Профиль») ───────────────────────────────────
|
|
// Сервер персональный — панель показывает баланс своего единственного
|
|
// владельца, не список абонентов. Логин владельца (MA7_LOGIN) — часть
|
|
// конфигурации сервера, задаётся один раз при установке (cli.env), а НЕ
|
|
// вводится через UI: бот-токен MA7_JWT_SECRET умеет искать любого абонента
|
|
// MA7, и если бы логин можно было менять прямо в панели, любой, кто знает
|
|
// пароль от панели, мог бы подсматривать баланс чужих абонентов — панель
|
|
// защищена одним общим UI_USER/UI_PASS, отдельного «клиентского» входа нет.
|
|
const MA7_LOGIN = process.env.MA7_LOGIN ?? "";
|
|
|
|
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 недоступен" });
|
|
}
|
|
});
|
|
|
|
app.use(["/api", "/health", "/awg"], requireAuth, proxy);
|
|
|
|
app.use(express.static(path.join(__dirname, "dist")));
|
|
|
|
app.get("*", (_req: Request, res: Response) => {
|
|
res.sendFile(path.join(__dirname, "dist", "index.html"));
|
|
});
|
|
|
|
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); }); |