diff --git a/README.md b/README.md index c313896..a2c9c0a 100644 --- a/README.md +++ b/README.md @@ -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 одинаковый (`/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 diff --git a/awg-ui/server.ts b/awg-ui/server.ts index f0a072a..d7a9078 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -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({ diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index a25cf7c..46092cf 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -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_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 ? (
- Forgetting + {brand.brand}

Войдите, чтобы продолжить

- Forgetting - Alpha 0.1.4.2 + {brand.brand} + + {brand.version ? `Alpha ${brand.version}` : 'Alpha'} +

{TABS.map(t => ( diff --git a/awg-ui/src/lib/shared.ts b/awg-ui/src/lib/shared.ts index 49c5c8c..1808a76 100644 --- a/awg-ui/src/lib/shared.ts +++ b/awg-ui/src/lib/shared.ts @@ -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 { diff --git a/awg-ui/vite.config.ts b/awg-ui/vite.config.ts index 7a7b199..58c0753 100644 --- a/awg-ui/vite.config.ts +++ b/awg-ui/vite.config.ts @@ -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', diff --git a/cli/src/index.ts b/cli/src/index.ts index b7abf5d..0cd5162 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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(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); diff --git a/install.sh b/install.sh index 916a7fe..41d2b88 100644 --- a/install.sh +++ b/install.sh @@ -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 путь релиза одинаковый +# (/releases/download/v/), поэтому одна схема покрывает оба. +# 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 - NET_OK=0 - NET_WHY="нет доступа к github.com — проверь интернет и DNS" -fi +# Проверяем хосты, с которых реально качаем: хватает любого из двух — +# 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 +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" <