Make the distribution source and brand configurable

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.
This commit is contained in:
2026-08-29 03:08:51 +03:00
parent 38e76da84f
commit 18972ed58f
7 changed files with 127 additions and 21 deletions
+23
View File
@@ -42,6 +42,29 @@ AWG_GEN=auto bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branc
Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели —
переустановка не нужна, пользователи сохраняются, ключи перевыпускаются
автоматически.
### Свой источник и своё имя
В коде не зашиты ни репозиторий, ни бренд — всё задаётся окружением до запуска:
| Переменная | По умолчанию | Зачем |
|---|---|---|
| `REPO_BASE` | `https://git.ma7neko.ru/maeneko/forgetting` | основной источник архива релиза |
| `REPO_FALLBACK` | `https://github.com/maeneko/forgetting` | запасной, если в основном релиза ещё нет |
| `ARCHIVE_URL` | — | полный URL архива, перекрывает оба варианта |
| `BRAND` | `Forgetting` | имя продукта в баннерах, systemd-юните и панели |
Путь релиза у Gitea и GitHub одинаковый (`<repo>/releases/download/v<версия>/awgcontrol-<версия>.tar.gz`),
так что подходит любой из них. Если архив не найден в основном источнике,
установщик предупреждает и берёт его из запасного.
```bash
REPO_BASE=https://git.example.org/me/vpn BRAND="My VPN" \
bash <(curl -Ls https://git.example.org/me/vpn/raw/branch/main/install.sh)
```
Сама панель и её API к домену не привязаны: работают по адресу того сервера,
куда установлены, внешних сервисов не требуют.
## Управление
```bash
+17
View File
@@ -16,6 +16,17 @@ 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;
@@ -109,6 +120,12 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
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({
+18 -4
View File
@@ -4,7 +4,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios';
import './App.css';
import { TOKEN_KEY, THEME_KEY, apiFetch, type ServerInfo } from './lib/shared';
import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import ServerBar from './components/ServerBar';
import { TABS } from './tabs';
@@ -22,6 +22,7 @@ export default function App() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id);
const [restarting, setRestarting] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, version: '' });
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
const touchStartX = useRef(0);
@@ -110,6 +111,17 @@ export default function App() {
}
}, [startSession]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант.
useEffect(() => {
axios.get('/ui/brand')
.then(({ data }) => setBrand({
brand: data.brand || BRAND_FALLBACK,
version: data.version || '',
}))
.catch(() => { /* остаётся фолбэк */ });
}, []);
// Применять и сохранять тему
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
@@ -163,7 +175,7 @@ export default function App() {
{!token ? (
<div className="login-screen">
<div className="login-card">
<span className="logo-name">Forgetting</span>
<span className="logo-name">{brand.brand}</span>
<p className="login-sub">Войдите, чтобы продолжить</p>
<input
className="field"
@@ -216,8 +228,10 @@ export default function App() {
onTouchEnd={handleTouchEnd}
>
<div className="sidebar-logo">
<span className="logo-name">Forgetting</span>
<span className="sidebar-version">Alpha 0.1.4.2</span>
<span className="logo-name">{brand.brand}</span>
<span className="sidebar-version">
{brand.version ? `Alpha ${brand.version}` : 'Alpha'}
</span>
</div>
<hr className="drawer-divider" />
{TABS.map(t => (
+6
View File
@@ -31,6 +31,12 @@ export interface PageProps {
token: string;
showMsg: (text: string) => void;
}
// Название продукта и версия приходят с сервера (GET /ui/brand, значения из
// BRAND в cli.env и package.json) — форк переименовывается в одном месте, в
// установщике. Эти константы — фолбэк на время загрузки и при недоступности.
export const BRAND_FALLBACK = 'Forgetting';
export interface Brand { brand: string; version: string }
export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme';
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:8080',
'/ui': 'http://localhost:8080',
'/health': 'http://localhost:8080',
'/login': 'http://localhost:8080',
'/logout': 'http://localhost:8080',
+12 -1
View File
@@ -28,6 +28,15 @@ if (existsSync(envFile)) {
}
}
// Название продукта — из окружения (BRAND пишет install.sh в cli.env), версия —
// из package.json: при переименовании форка или бампе версии строки в коде
// править не нужно. В package.json версия в semver-форме (0.1.4+2), в релизах
// и баннерах — через точку.
const BRAND = process.env.BRAND || "Forgetting";
const VERSION = (JSON.parse(
readFileSync(path.join(__dirname, "..", "package.json"), "utf8"),
).version as string).replace("+", ".");
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
const C = {
@@ -71,6 +80,8 @@ const SERVICES = {
// Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl).
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
// Бренд нужен панели для вордмарка (GET /ui/brand).
BRAND: BRAND,
UI_USER: process.env.UI_USER ?? "admin",
UI_PASS: process.env.UI_PASS ?? "",
JWT_SECRET: process.env.JWT_SECRET ?? "",
@@ -319,7 +330,7 @@ async function interactiveMenu() {
const printMenu = () => {
console.clear();
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.4.2 ──${C.reset}`)}\n`);
console.log(`\n${bold(`${C.blue}── ${BRAND} Alpha ${VERSION} ──${C.reset}`)}\n`);
for (const name of ALL) {
const running = isRunning(name);
+49 -15
View File
@@ -20,8 +20,20 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
VERSION="0.1.4.2"
GH_REPO="maeneko/forgetting"
BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}"
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
# ничего не зашито. У Gitea и GitHub путь релиза одинаковый
# (<repo>/releases/download/v<VERSION>/<file>), поэтому одна схема покрывает оба.
# REPO_BASE — основной источник (своё зеркало);
# REPO_FALLBACK — запасной, если в основном релиза ещё нет;
# ARCHIVE_URL — полный URL архива, перекрывает оба варианта;
# BRAND — имя продукта в баннерах, systemd-юните и панели.
BRAND="${BRAND:-Forgetting}"
REPO_BASE="${REPO_BASE:-https://git.ma7neko.ru/maeneko/forgetting}"
REPO_FALLBACK="${REPO_FALLBACK:-https://github.com/maeneko/forgetting}"
ARCHIVE_NAME="awgcontrol-${VERSION}.tar.gz"
ARCHIVE_URL="${ARCHIVE_URL:-}"
url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; }
PROJECT="/opt/awg-control"
AMNEZIA_DIR="/etc/amnezia"
AWG_DIR="$AMNEZIA_DIR/amneziawg"
@@ -42,7 +54,7 @@ MTU="1376"
TSX="$PROJECT/cli/node_modules/.bin/tsx"
CLI="$PROJECT/cli/src/index.ts"
echo -e "${BLD}Forgetting Alpha ${VERSION}${NC}"
echo -e "${BLD}${BRAND} Alpha ${VERSION}${NC}"
echo
# Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой
@@ -83,10 +95,17 @@ fi
# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой
# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS.
if ! curl -sS --connect-timeout 8 -o /dev/null "https://github.com" 2>/dev/null; then
# Проверяем хосты, с которых реально качаем: хватает любого из двух —
# download_extract() умеет откатываться на запасной.
NET_HOSTS=("$(url_host "$REPO_BASE")")
[[ -n "$ARCHIVE_URL" ]] && NET_HOSTS=("$(url_host "$ARCHIVE_URL")")
[[ -z "$ARCHIVE_URL" && "$(url_host "$REPO_FALLBACK")" != "${NET_HOSTS[0]}" ]] \
&& NET_HOSTS+=("$(url_host "$REPO_FALLBACK")")
NET_OK=0
NET_WHY="нет доступа к github.com — проверь интернет и DNS"
fi
for h in "${NET_HOSTS[@]}"; do
if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi
done
[[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS"
# Чеклист: ✓ — пройдено, ✗ — нет.
mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; }
@@ -190,13 +209,12 @@ start_logging() {
# Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые
# файлы на месте.
download_extract() {
local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz"
local tmp="/tmp/awgcontrol-${VERSION}.tar.gz"
local tmp="/tmp/${ARCHIVE_NAME}"
local keep_tarball=0
# AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах GitHub
# (или встать без сети). Архив должен быть той же формы, что делает CI:
# awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/, без node_modules.
# AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах (или
# встать без сети). Архив должен быть той же формы, что делает CI:
# awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/ + .env, без node_modules.
if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then
[[ -f "$AWG_LOCAL_TARBALL" ]] \
|| fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL"
@@ -205,10 +223,25 @@ download_extract() {
echo " → локальный архив: $tmp"
ok "Архив взят локально: $(du -sh "$tmp" | cut -f1)"
else
local urls=()
if [[ -n "$ARCHIVE_URL" ]]; then
urls=("$ARCHIVE_URL")
else
urls=("$REPO_BASE/releases/download/v${VERSION}/${ARCHIVE_NAME}")
[[ "$REPO_FALLBACK" != "$REPO_BASE" ]] \
&& urls+=("$REPO_FALLBACK/releases/download/v${VERSION}/${ARCHIVE_NAME}")
fi
echo " → версия: ${VERSION}"
curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \
|| fail "Не удалось скачать архив: $url"
ok "Архив скачан: $(du -sh "$tmp" | cut -f1)"
local url got=""
for url in "${urls[@]}"; do
if curl -fsSL --connect-timeout 15 "$url" -o "$tmp"; then got="$url"; break; fi
warn "Архив недоступен на $(url_host "$url") — пробуем следующий источник"
done
[[ -n "$got" ]] || fail "Не удалось скачать архив ни с одного источника:
$(printf ' %s\n' "${urls[@]}")
Укажи свой: ARCHIVE_URL=https://…/${ARCHIVE_NAME} bash install.sh"
ok "Архив скачан с $(url_host "$got"): $(du -sh "$tmp" | cut -f1)"
fi
mkdir -p "$PROJECT"
@@ -262,7 +295,7 @@ setup_service() {
node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)")
cat > "$SERVICE_UNIT" <<UNIT
[Unit]
Description=AWG Control — awg-ctrl + awg-ui (Forgetting)
Description=AWG Control — awg-ctrl + awg-ui (${BRAND})
After=network-online.target
Wants=network-online.target
@@ -852,6 +885,7 @@ INTERNAL_AUTH_KEY_FILE=${INTERNAL_AUTH_PRIV}
INTERNAL_AUTH_PUB_FILE=${INTERNAL_AUTH_PUB}
# ── ui (Ring 4) ───────────────────────────────────────────────────────────
BRAND=${BRAND}
UI_PORT=${UI_PORT}
UI_USER=${UI_USER}
UI_PASS=${UI_PASS}