commit 3b4a1f53884a223a47e2b32b81758f8a6cddfa9c Author: ayurishchev Date: Sat Jul 18 10:02:43 2026 +0300 init commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0a37a01 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebSearch" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bc86320 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Keep the build context minimal — only source is needed; the binary is rebuilt +# inside the image and would otherwise force glibc/musl mismatches. +awg_profiler +awg_config +awg_state.json +data/ +awg_clients/ +*.md +Dockerfile +Dockerfile.mint +.dockerignore +docker-compose.yml +docker-compose.mint.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..792902f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,87 @@ +# syntax=docker/dockerfile:1 + +############################################################################### +# awg_profiler — container image +# +# AmneziaWG is NOT packaged in Alpine's repos, and a container cannot load the +# kernel module. So the data plane is the userspace implementation +# `amneziawg-go`: `awg-quick` automatically falls back to it (via /dev/net/tun) +# when /sys/module/amneziawg is absent — exactly the container case. +# +# Three builder stages compile everything statically, then a tiny Alpine +# runtime carries only the finished binaries + a handful of CLI helpers the +# tool shells out to (awg / awg-quick / amneziawg-go / nft / qrencode). +############################################################################### + +# Pin the versions of the AmneziaWG userspace components for reproducible builds. +ARG AWG_TOOLS_REF=master +ARG AWG_GO_REF=master + +# ─── Stage 1: build the profiler binary (static, CGO off → runs on musl) ────── +FROM golang:1.26-alpine AS app-builder +WORKDIR /src +# Assets in webui/ are compiled into the binary via //go:embed, so the whole +# module source is needed but nothing has to be shipped alongside the binary. +COPY go.mod ./ +COPY *.go ./ +COPY webui/ ./webui/ +COPY webui_glass/ ./webui_glass/ +ENV CGO_ENABLED=0 +RUN go build -trimpath -ldflags="-s -w" -o /out/awg_profiler . + +# ─── Stage 2: build amneziawg-go (userspace WireGuard/AmneziaWG data plane) ─── +FROM golang:1.26-alpine AS awggo-builder +ARG AWG_GO_REF +RUN apk add --no-cache git make +WORKDIR /src +RUN git clone --depth=1 --branch "${AWG_GO_REF}" \ + https://github.com/amnezia-vpn/amneziawg-go . \ + && CGO_ENABLED=0 make \ + && install -Dm0755 amneziawg-go /out/amneziawg-go + +# ─── Stage 3: build amneziawg-tools (the `awg` + `awg-quick` C tools) ───────── +FROM alpine:3.20 AS tools-builder +ARG AWG_TOOLS_REF +RUN apk add --no-cache git build-base linux-headers bash +WORKDIR /src +RUN git clone --depth=1 --branch "${AWG_TOOLS_REF}" \ + https://github.com/amnezia-vpn/amneziawg-tools . \ + && make -C src \ + && make -C src install \ + WITH_WGQUICK=yes WITH_BASHCOMPLETION=no WITH_SYSTEMDUNITS=no \ + DESTDIR=/out PREFIX=/usr + +# ─── Stage 4: runtime ───────────────────────────────────────────────────────── +FROM alpine:3.20 + +# awg-quick is a bash script and drives `ip` (iproute2); the profiler shells out +# to nft (firewall/NAT rules) and qrencode (client QR codes). ca-certificates is +# needed for the public-IP HTTPS lookup. Everything else it needs (sysctl, grep, +# mktemp, …) is already provided by busybox. +RUN apk add --no-cache \ + bash \ + iproute2 \ + nftables \ + libqrencode-tools \ + ca-certificates + +# AmneziaWG userspace stack + the profiler itself. +COPY --from=tools-builder /out/usr/ /usr/ +COPY --from=awggo-builder /out/amneziawg-go /usr/bin/amneziawg-go +COPY --from=app-builder /out/awg_profiler /usr/local/bin/awg_profiler +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# All mutable profiler state (config, client registry, generated profiles, the +# deps-installed flag) lives here; the interface .conf + nft ruleset live in +# /etc/amnezia/amneziawg. Mount volumes on both to persist across restarts. +ENV AWG_PROFILER_DIR=/data +VOLUME ["/data", "/etc/amnezia/amneziawg"] + +# WireGuard/AmneziaWG listen port (UDP) and the management web UI (TCP). +EXPOSE 51820/udp +EXPOSE 8080/tcp + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +# Default to the web UI on all interfaces; override with any profiler subcommand. +CMD ["web", "--addr", "0.0.0.0:8080"] diff --git a/Dockerfile.mint b/Dockerfile.mint new file mode 100644 index 0000000..4f7adfc --- /dev/null +++ b/Dockerfile.mint @@ -0,0 +1,112 @@ +# syntax=docker/dockerfile:1 + +############################################################################### +# awg_profiler — alternative container image, Linux Mint base +# +# The default image (Dockerfile) uses Alpine, which has no AmneziaWG package +# at all, so amneziawg-tools has to be compiled from source and the profiler +# is steered onto its OpenRC code path (direct `awg-quick`, no init system) +# to avoid systemd. That path works, but it is a workaround: it ships +# self-built binaries instead of the vendor-maintained ones, and it exists +# only because Alpine isn't a distro AmneziaWG actually supports. +# +# Linux Mint (Ubuntu-based) IS officially supported: the project's own docs +# describe installing it via `ppa:amnezia/ppa` + `apt-get install amneziawg`. +# This image follows that exact documented flow to obtain `awg`/`awg-quick` +# as prebuilt, vendor-maintained packages instead of compiling them here. +# +# Two things still don't change, for the same reason as the Alpine image: +# - No kernel module. A container can't load one (and doing so would need +# the much larger CAP_SYS_MODULE, not just NET_ADMIN), so only +# `amneziawg-tools` is installed — never the `amneziawg` DKMS package. +# awg-quick (same upstream script either way) auto-falls-back to the +# userspace `amneziawg-go` data plane when /sys/module/amneziawg is +# absent, exactly like on Alpine, so amneziawg-go is still built from +# source in its own stage below. +# - No systemd as PID 1 inside the container. osdetect.go now detects this +# at runtime (absence of /run/systemd/system) and falls back to driving +# awg-quick directly, the same way the Alpine/OpenRC path always has. +# +# The Alpine build (Dockerfile) is unchanged and remains the default/smaller +# option; this is an alternative for cases where the Alpine workarounds are +# themselves the problem (e.g. wanting vendor-built awg/awg-quick binaries). +############################################################################### + +ARG AWG_GO_REF=master +# Pin to a specific Linux Mint release image; "latest" tracks whatever the +# linuxmintd maintainer currently publishes. +ARG MINT_IMAGE=linuxmintd/mint22-amd64:latest + +# ─── Stage 1: build the profiler binary (static, CGO off) ──────────────────── +FROM golang:1.26-alpine AS app-builder +WORKDIR /src +# Assets in webui/ are compiled into the binary via //go:embed, so the whole +# module source is needed but nothing has to be shipped alongside the binary. +COPY go.mod ./ +COPY *.go ./ +COPY webui/ ./webui/ +COPY webui_glass/ ./webui_glass/ +ENV CGO_ENABLED=0 +RUN go build -trimpath -ldflags="-s -w" -o /out/awg_profiler . + +# ─── Stage 2: build amneziawg-go (userspace data plane, not packaged anywhere) ─ +FROM golang:1.26-alpine AS awggo-builder +ARG AWG_GO_REF +RUN apk add --no-cache git make +WORKDIR /src +RUN git clone --depth=1 --branch "${AWG_GO_REF}" \ + https://github.com/amnezia-vpn/amneziawg-go . \ + && CGO_ENABLED=0 make \ + && install -Dm0755 amneziawg-go /out/amneziawg-go + +# ─── Stage 3: runtime — Linux Mint, AmneziaWG via the official PPA ──────────── +FROM ${MINT_IMAGE} +ENV DEBIAN_FRONTEND=noninteractive + +# The official docs add this PPA via `add-apt-repository ppa:amnezia/ppa` +# (Software Sources → PPAs, after enabling "Source code repositories"). That +# tool fails in this base image with "OS codename: 'noble'. This codename +# isn't currently supported" — a codename-database bug in the bundled +# software-properties-common/python3-launchpadlib, unrelated to AmneziaWG +# (the PPA itself does publish for noble). Same end result — packages from +# ppa:amnezia/ppa — added the way most Dockerfiles add a PPA non-interactively: +# fetch its signing key and write the sources.list entry directly, keyed off +# the base image's own Ubuntu codename so it still works if MINT_IMAGE points +# at a different Mint/Ubuntu release. +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends ca-certificates gnupg curl \ + && install -d -m 0755 /etc/apt/keyrings \ + && curl -fsSL 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x75C9DD72C799870E310542E24166F2C257290828' \ + | gpg --dearmor -o /etc/apt/keyrings/amnezia.gpg \ + && . /etc/os-release \ + && echo "deb [signed-by=/etc/apt/keyrings/amnezia.gpg] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu ${VERSION_CODENAME} main" \ + > /etc/apt/sources.list.d/amnezia-ppa.list \ + && apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + amneziawg-tools iproute2 nftables qrencode procps \ + && rm -rf /var/lib/apt/lists/* +# Note: deliberately no `apt-get purge --auto-remove` cleanup pass here — the +# base image ships mintsources with an already-broken dependency +# (python3-repolib, unrelated to AmneziaWG) that makes the resolver bail out +# on *any* autoremove/purge. Leaving gnupg/curl installed costs a few MB, +# which is immaterial next to the size of the Mint base image itself. + +# AmneziaWG userspace data plane (built above) + the profiler itself. +COPY --from=awggo-builder /out/amneziawg-go /usr/bin/amneziawg-go +COPY --from=app-builder /out/awg_profiler /usr/local/bin/awg_profiler +COPY entrypoint.mint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# All mutable profiler state (config, client registry, generated profiles, the +# deps-installed flag) lives here; the interface .conf + nft ruleset live in +# /etc/amnezia/amneziawg. Mount volumes on both to persist across restarts. +ENV AWG_PROFILER_DIR=/data +VOLUME ["/data", "/etc/amnezia/amneziawg"] + +# WireGuard/AmneziaWG listen port (UDP) and the management web UI (TCP). +EXPOSE 51820/udp +EXPOSE 8080/tcp + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +# Default to the web UI on all interfaces; override with any profiler subcommand. +CMD ["web", "--addr", "0.0.0.0:8080"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..66b6942 --- /dev/null +++ b/README.md @@ -0,0 +1,315 @@ +# awg_profiler + +Инструмент на Go для развёртывания и управления сервером **AmneziaWG** +(форк WireGuard с обфускацией трафика): установка зависимостей, +инициализация сервера, управление клиентами (CLI и веб-UI) и сбор +статистики трафика, переживающей перезапуски. + +Проект самодостаточен: единственный бинарник `awg_profiler`, встроенный +веб-интерфейс (без внешних зависимостей в браузере) и Docker-образы для +запуска без установки чего-либо на хост. + +--- + +# Quick Start + +Ниже — два независимых пути развёртывания «с нуля» (Green Field): в +контейнере (рекомендуется, не требует ничего кроме Docker) или напрямую на +хосте. + +## Вариант A — в контейнере (рекомендуется) + +Требования: Docker + Docker Compose, ядро Linux с включённым модулем `tun` +(есть на любом современном дистрибутиве). + +Контейнер запускает встроенный веб-интерфейс на `:8080` (WEB UI) и слушает +VPN-трафик на UDP `:51820`. AmneziaWG-стек (`awg`, `awg-quick`, +`amneziawg-go`) собирается внутри образа — на хосте ничего ставить не нужно. + +```bash +git clone awg_profiler && cd awg_profiler + +# Сборка и запуск (образ на базе Alpine) +docker compose up -d --build + +# Логи / статус +docker compose logs -f +``` + +По умолчанию порт `8080` публикуется только на `127.0.0.1` хоста (см. +`docker-compose.yml`) — веб-UI недоступен по сети, пока вы явно не расширите +доступ. На самой машине откройте `http://127.0.0.1:8080`, либо для доступа с +другого компьютера прокиньте порт по SSH: `ssh -L 8080:127.0.0.1:8080 +user@server` и откройте `http://127.0.0.1:8080` локально. + +Веб-UI покажет мастер настройки: + +1. **install-deps** — пропускается автоматически (зависимости уже в образе). +2. **init-server** — заполните форму (сеть, порт, DNS, MTU — можно оставить + значения по умолчанию) и отправьте. Сервер сгенерирует ключи, параметры + обфускации и запустится. +3. Создавайте клиентов на вкладке **Clients**, скачивайте `.conf` / QR-код. + +Чтобы открыть UI на всех интерфейсах (LAN/интернет), **сначала** включите +Basic-аутентификацию, иначе панель управления сервером останется без пароля: + +```bash +# в docker-compose.yml: +# 1. раскомментировать блок environment: и задать +AWG_WEB_USER=admin +AWG_WEB_PASS=длинный-пароль +# 2. заменить порт "127.0.0.1:8080:8080" на "8080:8080" +docker compose up -d --build +``` + +Все изменяемые данные (конфиг, реестр клиентов, профили, статистика) живут в +именованных томах `awg-data` и `awg-etc` — переживают `docker compose down` +без `-v`. + +**Альтернативный образ (Linux Mint база, вендорские пакеты AmneziaWG вместо +собранных из исходников):** + +```bash +docker compose -f docker-compose.mint.yml up -d --build +``` + +## Вариант B — на хосте (без контейнера) + +Требования: Linux (Ubuntu, Debian, Linux Mint или Alpine), Go ≥ 1.26, +root/sudo. `install-deps` сам ставит AmneziaWG: на apt-based дистрибутивах — +пакеты `amneziawg`/`amneziawg-tools` из `ppa:amnezia/ppa`; на Alpine, где +готового пакета `amneziawg-tools` нет, — собирает `awg`/`awg-quick` из +исходников (нужен интернет для `git clone` на этапе install-deps). На Alpine +kernel-модуль `amneziawg` при этом не ставится — его нужно предоставить +отдельно (DKMS/akmods или готовый модуль под ваше ядро), `install-deps` +только предупредит об этом. + +```bash +git clone awg_profiler && cd awg_profiler + +# 1. Сборка бинарника +export PATH=$PATH:/usr/local/go/bin +go build -o awg_profiler . + +# 2. Установка зависимостей (AmneziaWG, qrencode, nftables, jq) — один раз +sudo ./awg_profiler install-deps + +# 3. Интерактивная инициализация сервера (ключи, обфускация, конфиг, служба) +sudo ./awg_profiler init-server + +# 4. Первый клиент +sudo ./awg_profiler create phone + +# 5. (опционально) веб-UI поверх той же установки +sudo ./awg_profiler web --addr 0.0.0.0:8080 +``` + +`install-deps` и `init-server` — обязательно раздельные шаги и в этом +порядке: `init-server` проверяет флаг, оставленный `install-deps`, и +отказывается работать, если пакеты ещё не установлены. + +На вопрос «VPN network CIDR» принимается **только `/24`** (например +`10.0.0.0/24` или `192.168.5.0/24`) — весь остальной код (адрес сервера, +адреса клиентов, запись в конфиг) жёстко расчитан на /24, другой префикс +`init-server` отклонит с ошибкой. + +Дальше — управление через CLI (`server-status`, `create`, `list`, …) или +запущенный `web`; см. полный список команд в [CLI-командах](#cli-команды). + +--- + +# Detailed Info + +## Расположение файлов + +Пути привязаны к каталогу исполняемого файла (``, переопределяется +переменной `AWG_PROFILER_DIR` — так собран Docker-образ, где `=/data`): + +| Назначение | Путь | +|---|---| +| Конфиг профилировщика | `/awg_config` (формат shell `KEY="value"`) | +| Реестр клиентов | `/data/awg_clients.json` | +| Накопленная статистика трафика | `/data/awg_stats.json` | +| Профили клиентов | `/awg_clients/.conf` + `.png` (QR) | +| Флаг «зависимости установлены» | `/awg_state.json` | +| Interface conf / nft-правила | `/etc/amnezia/amneziawg/.conf`, `-rules.nft` | + +## CLI-команды + +``` +SERVER SETUP + install-deps Установить AmneziaWG, jq, qrencode для текущей ОС + и записать флаг deps_installed (выполнить ПЕРВЫМ) + init-server Интерактивная настройка сервера: проверяет флаг + зависимостей, затем генерирует ключи + параметры + обфускации, пишет конфиги, включает IP-forwarding + и запускает службу (пакеты НЕ ставит) + +SERVER MANAGEMENT + server-status Статус интерфейса и список пиров + server-start Запустить службу AmneziaWG + server-stop Остановить службу AmneziaWG + server-restart Перезапустить службу AmneziaWG + show-config Показать текущий конфиг (приватные ключи скрыты) + sync-config Пересобрать /.conf из реестра + клиентов и (по подтверждению) перезапустить службу + +CLIENT MANAGEMENT + create Создать клиента: ключи, .conf, QR-код + delete Удалить клиента (файлы + запись в реестре) + disable Перевести клиента в статус DISABLED + enable Перевести клиента в статус ACTIVE + list Список всех зарегистрированных клиентов + +WEB UI + web [--addr host:port] Запустить веб-UI (по умолчанию 127.0.0.1:8080) + [--theme classic|glass] + [--ui-mode dark|light|auto] +``` + +Поддерживаемые ОС: **Ubuntu, Debian, Linux Mint, Alpine Linux** +(автоопределение по `/etc/os-release`; на apt-based используется systemd, на +Alpine — OpenRC; если систем systemd не является PID 1 — например, в +контейнере — используется прямое управление через `awg-quick`). + +## Разделение install-deps и init-server + +Установка пакетов полностью отделена от настройки сервера: + +1. **`install-deps`** ставит AmneziaWG и тулинг под текущую ОС и **пишет + флаг** `deps_installed=true` в `awg_state.json` (с временем и версией ОС). +2. **`init-server`** пакеты не ставит. Сначала **проверяет флаг**: если + `install-deps` не запускался, завершается ошибкой `Dependencies not + installed — run 'install-deps' first`. При установленном флаге переходит к + генерации ключей, параметров обфускации, конфигов, включению + IP-forwarding и запуску службы. + +В контейнерных образах этот флаг сеется автоматически при старте +(`entrypoint.sh` / `entrypoint.mint.sh`), так как AmneziaWG-стек уже +запечён в образ на этапе сборки — шаг `install-deps` в UI/CLI внутри +контейнера не требуется. + +## Web-UI + +Команда `web` поднимает встроенный веб-интерфейс управления. Он использует ту +же логику, что и CLI (общий Go-пакет), поэтому реестр/конфиг/служба остаются +совместимыми. Статические ассеты (`webui/`, `webui_glass/`) вшиты в бинарник +через `go:embed` — дополнительных файлов при развёртывании не нужно. + +```bash +sudo ./awg_profiler web # 127.0.0.1:8080 (по умолчанию) +sudo ./awg_profiler web --addr 0.0.0.0:8080 # на всех интерфейсах +sudo ./awg_profiler web --theme glass # альтернативный дизайн (glassmorphism) +sudo ./awg_profiler web --ui-mode auto # следовать светлой/тёмной теме ОС +``` + +Дизайн выбирается флагом `--theme classic|glass` (env `AWG_WEB_THEME`, по +умолчанию `classic`), цвет-режим — `--ui-mode dark|light|auto` (env +`AWG_WEB_MODE`, по умолчанию **`dark`** — тёмная форсируется). `glass` — +dark-only, `--ui-mode` для неё игнорируется. + +Возможности UI: + +- **Дашборд** — статус интерфейса (UP/DOWN), эндпоинт, сеть, публичный ключ, + счётчики «всего / активных / онлайн», кнопки `start/stop/restart` и `sync`. +- **Клиенты** — список с индикатором онлайна и накопленным трафиком; создание, + включение/выключение, удаление, скачивание `.conf` и просмотр QR-кода. +- **Статистика по пользователю** — по каждому клиенту показываются накопленные + байты (↓ rx / ↑ tx), отметка «Stats since», последний handshake, эндпоинт и + признак «онлайн» (handshake ≤ 150 c), а также кнопка **Reset stats** для + быстрой очистки. Живые данные берутся из `awg show dump` и + сопоставляются с реестром по публичному ключу. +- **Мастер настройки** — если сервер ещё не инициализирован, UI показывает + форму `install-deps` → `init-server` (неинтерактивные аналоги CLI-команд). + +UI построен как одностраничное приложение на «ванильном» JS/CSS (без внешних +зависимостей — работает офлайн), mobile-first, с обновлением статистики каждые +10 c. По умолчанию всегда тёмная тема (см. `--ui-mode` выше). + +### Накопление статистики (переживает перезапуск) + +`awg show dump` отдаёт счётчики rx/tx, которые **обнуляются при каждом +перезапуске** интерфейса — а в контейнере userspace-data-plane `amneziawg-go` +рестартует вместе с приложением, поэтому «сырое» чтение после рестарта +показывает ноль. Чтобы этого не происходило, трафик накапливается инкрементально +и **отдельно по каждому клиенту** в `data/awg_stats.json`: + +- Значения складываются как **дельты** между замерами. Если счётчик «ушёл назад» + (rx стал меньше предыдущего) — это трактуется как сброс, и всё текущее значение + засчитывается как новый трафик. Дельты всегда неотрицательны, поэтому итог + может только расти. +- В каждой записи хранится **базовая точка** (`last_rx/last_tx`) — она тоже + пишется на диск. После рестарта итог берётся с диска, а маленькое пост-рестарт + чтение корректно распознаётся как сброс. **Перезапуск программы не может + уменьшить или обнулить уже накопленную статистику.** +- У каждого клиента есть отметка `since` — момент, с которого идёт накопление + (при первом появлении пира или после очистки). Трафик, накопленный интерфейсом + до начала отслеживания, задним числом не засчитывается. +- Замер выполняется фоном (раз в 20 c) и попутно при опросе API, под отдельным + мьютексом; запись — атомарно (temp + rename). Битый файл сохраняется как + `awg_stats.json.bad`, чтобы ошибка парсинга не затёрла данные. +- **`POST /api/clients/{id}/stats/reset`** (кнопка *Reset stats*) обнуляет + накопленное для клиента и заново выставляет `since`. Удаление клиента удаляет и + его запись статистики. + +### Безопасность + +- Приватные и preshared-ключи **не** передаются в браузер в JSON-списках — + секреты покидают сервер только в файле `.conf` при явном скачивании. +- По умолчанию сервер слушает `127.0.0.1`. Для доступа извне включите + HTTP Basic-аутентификацию, задав переменные окружения: + +```bash +export AWG_WEB_USER=admin +export AWG_WEB_PASS='длинный-пароль' +sudo -E ./awg_profiler web --addr 0.0.0.0:8080 +``` + + (за TLS/публичный доступ отвечает обратный прокси, например nginx/caddy). +- Каждая операция сериализуется мьютексом; в веб-режиме внутренние ошибки + перехватываются и возвращаются как HTTP-ответ, а не роняют сервер. + +## Docker-образы + +Проект поставляет два независимых образа — оба запускают тот же бинарник и +веб-UI, различается только то, откуда берётся сам AmneziaWG-стек: + +| | `Dockerfile` (по умолчанию) | `Dockerfile.mint` (альтернатива) | +|---|---|---| +| Базовый образ | `alpine:3.20` (рантайм) | Linux Mint 22 (`linuxmintd/mint22-amd64`) | +| `awg` / `awg-quick` | собираются из исходников (`amneziawg-tools`) | пакет из официального `ppa:amnezia/ppa` | +| `amneziawg-go` (userspace data-plane) | собирается из исходников | собирается из исходников | +| compose-файл | `docker-compose.yml` | `docker-compose.mint.yml` | + +Оба варианта: + +- используют **userspace data-plane `amneziawg-go`** вместо kernel-модуля — + контейнер не может загрузить модуль ядра, `awg-quick` автоматически + переключается на userspace через `/dev/net/tun`, когда `/sys/module/amneziawg` + отсутствует; +- требуют `cap_add: NET_ADMIN` и проброс `/dev/net/tun` (уже прописано в + compose-файлах); +- сохраняют состояние в volume'ах: `/data` (конфиг, реестр, профили, + статистика) и `/etc/amnezia/amneziawg` (interface `.conf` + nft-правила); +- внутри контейнера слушают `51820/udp` (VPN) и `8080/tcp` (веб-UI), команда + по умолчанию — `web --addr 0.0.0.0:8080` (переопределяется через `command:` + в compose или аргументом `docker run`); наружу же порт `8080` по умолчанию + публикуется только на `127.0.0.1` хоста (см. compose-файлы) — расширяйте + его на все интерфейсы только вместе с `AWG_WEB_USER`/`AWG_WEB_PASS`. + +## Технические особенности реализации + +- Работа с JSON-реестром клиентов и статистикой — нативно (`encoding/json`), + без внешнего `jq`. `jq` всё ещё ставится `install-deps` (используется в + ручной отладке конфигов), но в рантайме профилировщика не требуется. +- `awg` (genkey/pubkey/genpsk/set/show) и `qrencode` вызываются как внешние + бинарники. +- Случайные значения (ключи, параметры обфускации) берутся из `crypto/rand`. +- Определение публичного IP — нативный HTTP-клиент (IPv4-only), аналог + `curl -sf4`. + +## Тесты + +```bash +go test ./... +``` diff --git a/awg.go b/awg.go new file mode 100644 index 0000000..dace56c --- /dev/null +++ b/awg.go @@ -0,0 +1,241 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ─── service helpers ───────────────────────────────────────────────────────────── + +func serviceEnable(os *OSInfo, iface string) { + switch os.Init { + case "systemd": + runOrDie("systemctl", "enable", "awg-quick@"+iface) + case "openrc": + if err := run("rc-update", "add", "awg-quick."+iface, "default"); err != nil { + warn("Could not register awg-quick.%s — may need manual setup", iface) + } + } +} + +func serviceStart(os *OSInfo, iface string) { + switch os.Init { + case "systemd": + runOrDie("systemctl", "start", "awg-quick@"+iface) + case "openrc": + runOrDie("awg-quick", "up", iface) + } +} + +func serviceStop(os *OSInfo, iface string) { + switch os.Init { + case "systemd": + runOrDie("systemctl", "stop", "awg-quick@"+iface) + case "openrc": + runOrDie("awg-quick", "down", iface) + } +} + +func serviceRestart(os *OSInfo, iface string) { + serviceStop(os, iface) + serviceStart(os, iface) +} + +func serviceStatus(os *OSInfo, iface string) { + switch os.Init { + case "systemd": + // Best-effort; a non-zero status is not an error here. + run("systemctl", "status", "awg-quick@"+iface, "--no-pager") + case "openrc": + run("rc-service", "awg-quick."+iface, "status") + } +} + +// ─── live peer management ──────────────────────────────────────────────────────── + +// awgIfaceUp reports whether the AmneziaWG interface is currently up. +func awgIfaceUp(iface string) bool { + return silent("awg", "show", iface) +} + +// awgPeerAdd hot-adds a peer to the running interface (no restart needed). +func awgPeerAdd(iface, pubkey, psk, ip string) { + if !awgIfaceUp(iface) { + warn("Interface %s is down — peer will be active on next start", iface) + return + } + // awg reads the preshared key from a file; use a short-lived temp file. + pskFile, err := os.CreateTemp("", "awg-psk-*") + if err != nil { + die("Failed to create temp psk file: %v", err) + } + defer os.Remove(pskFile.Name()) + if _, err := pskFile.WriteString(psk); err != nil { + pskFile.Close() + die("Failed to write temp psk file: %v", err) + } + pskFile.Close() + + if err := run("awg", "set", iface, "peer", pubkey, + "preshared-key", pskFile.Name(), + "allowed-ips", ip+"/32"); err != nil { + // Non-fatal: by this point the client is already saved in the registry + // and appended to .conf (createClient calls confAppendPeer first), + // so the peer will pick up on the next restart/sync-config even if the + // live hot-add fails. Dying here would report client creation as failed + // when it actually succeeded, just without taking effect immediately. + warn("awg set (peer add) failed: %v — peer saved, will apply on next restart/sync", err) + return + } + info("Peer added live to %s (%s)", iface, ip) +} + +// awgPeerRemove hot-removes a peer from the running interface. +func awgPeerRemove(iface, pubkey string) { + if !awgIfaceUp(iface) { + return + } + if err := run("awg", "set", iface, "peer", pubkey, "remove"); err != nil { + die("awg set (peer remove) failed: %v", err) + } + info("Peer removed live from %s", iface) +} + +// ─── config-file assembly ──────────────────────────────────────────────────────── + +func awgConfPath(iface string) string { + return filepath.Join(awgConfDir, iface+".conf") +} + +// awgConfHeader builds the [Interface] block including obfuscation parameters. +// PostUp loads the pre-generated nft ruleset; PostDown drops the table. +func awgConfHeader(c *Config, priv, srvIP, port, mtu string) string { + return fmt.Sprintf(`[Interface] +PrivateKey = %s +Address = %s/24 +ListenPort = %s +MTU = %s +SaveConfig = false + +Jc = %s +Jmin = %s +Jmax = %s +S1 = %s +S2 = %s +H1 = %s +H2 = %s +H3 = %s +H4 = %s + +PostUp = nft delete table inet awg_%%i 2>/dev/null || true +PostUp = nft -f %s/%%i-rules.nft +PostDown = nft delete table inet awg_%%i +`, priv, srvIP, port, mtu, + c.Jc, c.Jmin, c.Jmax, c.S1, c.S2, c.H1, c.H2, c.H3, c.H4, + awgConfDir) +} + +// peerBlock renders a single [Peer] section for the interface config. +func peerBlock(name, pubkey, psk, ip string) string { + return "\n[Peer]\n# " + name + + "\nPublicKey = " + pubkey + + "\nPresharedKey = " + psk + + "\nAllowedIPs = " + ip + "/32\n" +} + +// confAppendPeer appends a single [Peer] block to /.conf. +func confAppendPeer(iface, name, pubkey, psk, ip string) { + confPath := awgConfPath(iface) + if _, err := os.Stat(confPath); err != nil { + warn("%s not found — skipping conf update", confPath) + return + } + f, err := os.OpenFile(confPath, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + warn("%s not found — skipping conf update", confPath) + return + } + defer f.Close() + if _, err := f.WriteString(peerBlock(name, pubkey, psk, ip)); err != nil { + die("Failed to append peer to %s: %v", confPath, err) + } + info("Peer appended to %s", confPath) +} + +// confRebuild silently regenerates /.conf from the registry +// (ACTIVE peers only) and rewrites the companion nft ruleset. Used by +// delete/enable/disable to keep the conf in sync without a restart prompt. +func confRebuild(c *Config, osInfo *OSInfo, clients []Client) { + iface := c.Interface + confPath := awgConfPath(iface) + if _, err := os.Stat(confPath); err != nil { + warn("%s not found — skipping conf rebuild", confPath) + return + } + + srvIP := serverIP(c.Network) + defIface := defaultRouteIface() + mtu := c.mtuOr("1420") + + var b strings.Builder + b.WriteString(awgConfHeader(c, c.ServerPriv, srvIP, c.Port, mtu)) + for _, cl := range clients { + if cl.IsEnabled == "ACTIVE" { + b.WriteString(peerBlock(cl.Name, cl.PublicKey, cl.PSKKey, cl.IP)) + } + } + if err := os.WriteFile(confPath, []byte(b.String()), 0600); err != nil { + die("Failed to write %s: %v", confPath, err) + } + writeNftRules(iface, defIface, mtu) +} + +// serverIP derives the server's .1 host address from the network CIDR. +func serverIP(network string) string { + base := networkBase(network) + parts := strings.Split(base, ".") + if len(parts) != 4 { + die("Invalid network: %s", network) + } + return fmt.Sprintf("%s.%s.%s.1", parts[0], parts[1], parts[2]) +} + +// ─── nftables ruleset ──────────────────────────────────────────────────────────── + +// writeNftRules writes /-rules.nft, loaded by PostUp via `nft -f`. +func writeNftRules(iface, defIface, mtu string) { + mss := atoiOrDie(mtu) - 40 + nftPath := filepath.Join(awgConfDir, iface+"-rules.nft") + + content := fmt.Sprintf(`table inet awg_%s { + # Allow forwarded traffic through the WG tunnel in both directions + chain forward { + type filter hook forward priority 0; policy accept; + iif "%s" accept + oif "%s" accept + } + + # Masquerade only WG→external flows (more targeted than a blanket POSTROUTING rule) + chain postrouting { + type nat hook postrouting priority 100; + iif "%s" oif "%s" masquerade + } + + # MSS clamping keeps TCP segments within the WG tunnel MTU; + # TTL normalisation hides the extra forwarding hop from remote hosts. + chain mangle { + type filter hook forward priority -150; + iif "%s" tcp flags syn / syn,rst tcp option maxseg size set %d + oif "%s" tcp flags syn / syn,rst tcp option maxseg size set %d + iif "%s" oif "%s" ip ttl set 64 + } +} +`, iface, iface, iface, iface, defIface, iface, mss, iface, mss, iface, defIface) + + if err := os.WriteFile(nftPath, []byte(content), 0600); err != nil { + die("Failed to write nft ruleset: %v", err) + } + info("NFT ruleset written: %s", nftPath) +} diff --git a/awg_profiler b/awg_profiler new file mode 100755 index 0000000..749ce82 Binary files /dev/null and b/awg_profiler differ diff --git a/awg_profiler_test.go b/awg_profiler_test.go new file mode 100644 index 0000000..857b510 --- /dev/null +++ b/awg_profiler_test.go @@ -0,0 +1,265 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestRandMagicRange(t *testing.T) { + for i := 0; i < 100000; i++ { + v := randMagic() + if v < 5 || v > 2147483647 { + t.Fatalf("randMagic out of range: %d", v) + } + } +} + +func TestRandRange(t *testing.T) { + for i := 0; i < 100000; i++ { + v := randRange(8, 32) + if v < 8 || v > 32 { + t.Fatalf("randRange out of [8,32]: %d", v) + } + } + // single-value range + if v := randRange(15, 15); v != 15 { + t.Fatalf("randRange(15,15)=%d, want 15", v) + } + // inverted range returns min (with a warning) + if v := randRange(50, 10); v != 50 { + t.Fatalf("randRange(50,10)=%d, want 50", v) + } +} + +func TestIncrementIP(t *testing.T) { + cases := []struct{ cur, net, want string }{ + {"", "10.0.0.0/24", "10.0.0.2"}, + {"10.0.0.2", "10.0.0.0/24", "10.0.0.3"}, + {"192.168.5.9", "192.168.5.0/24", "192.168.5.10"}, + } + for _, c := range cases { + if got := incrementIP(c.cur, c.net); got != c.want { + t.Errorf("incrementIP(%q,%q)=%q, want %q", c.cur, c.net, got, c.want) + } + } +} + +func TestServerAndFirstIP(t *testing.T) { + if got := serverIP("10.110.90.0/24"); got != "10.110.90.1" { + t.Errorf("serverIP=%q", got) + } + if got := getFirstClientIP("10.110.90.0/24"); got != "10.110.90.2" { + t.Errorf("getFirstClientIP=%q", got) + } +} + +func TestSanitize(t *testing.T) { + cases := map[string]string{ + "phone": "phone", + "my client!": "my_client_", + "a/b\\c": "a_b_c", + "ok.name-1_2": "ok.name-1_2", + } + for in, want := range cases { + if got := sanitize(in); got != want { + t.Errorf("sanitize(%q)=%q, want %q", in, got, want) + } + } +} + +func TestConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + configFile = filepath.Join(dir, "awg_config") + + in := &Config{ + Network: "10.0.0.0/24", Interface: "awg0", Port: "51820", + PublicIP: "203.0.113.9", ServerPriv: "PRIV==", ServerPub: "PUB==", + DNS: "1.1.1.1", MTU: "1420", + Jc: "6", Jmin: "20", Jmax: "120", S1: "90", S2: "100", + H1: "11", H2: "22", H3: "33", H4: "44", + } + writeConfig(in) + + out := loadConfig() + if *out != *in { + t.Fatalf("round-trip mismatch:\n in=%+v\nout=%+v", *in, *out) + } + + // The written file must remain shell-sourceable (KEY="value" form). + data, _ := os.ReadFile(configFile) + if want := `SERVER_INTERFACE="awg0"`; !contains(string(data), want) { + t.Errorf("config missing %q", want) + } +} + +func TestHidePrivateKey(t *testing.T) { + in := "[Interface]\nPrivateKey = SECRETKEY==\nAddress = 10.0.0.1/24\n" + out := hidePrivateKey(in) + if contains(out, "SECRETKEY") { + t.Errorf("private key not hidden: %q", out) + } + if !contains(out, "") { + t.Errorf("expected marker: %q", out) + } +} + +func TestStateRoundTrip(t *testing.T) { + dir := t.TempDir() + stateFile = filepath.Join(dir, "awg_state.json") + + if loadState().DepsInstalled { + t.Fatal("fresh state should not report deps installed") + } + saveState(&State{DepsInstalled: true, OSID: "debian", OSVersion: "12"}) + got := loadState() + if !got.DepsInstalled || got.OSID != "debian" { + t.Fatalf("state round-trip failed: %+v", got) + } +} + +// TestStatsAccumulation exercises the durable, per-peer traffic accumulation: +// normal deltas, counter-reset detection, peers missing from a dump, survival of +// a restart (re-read from disk), and reset. Each sampleStats call round-trips +// through statsFile, so persistence is tested implicitly. +func TestStatsAccumulation(t *testing.T) { + dir := t.TempDir() + dataDir = dir + statsFile = filepath.Join(dir, "awg_stats.json") + + const k = "PEERKEY=" + live := func(rx, tx int64) map[string]PeerStat { + return map[string]PeerStat{k: {PublicKey: k, TransferRx: rx, TransferTx: tx}} + } + rec := func() StatRecord { + statsLock.Lock() + defer statsLock.Unlock() + return loadStatsLocked().Peers[k] + } + + // First sighting: counts from now, so totals start at 0 with the raw counter + // captured as the baseline. + sampleStats(live(100, 40)) + if r := rec(); r.TotalRx != 0 || r.TotalTx != 0 || r.LastRx != 100 || r.LastTx != 40 { + t.Fatalf("first sighting: got %+v", r) + } + if rec().Since == 0 { + t.Fatal("first sighting must set Since") + } + + // Normal growth: +200 rx, +60 tx. + sampleStats(live(300, 100)) + if r := rec(); r.TotalRx != 200 || r.TotalTx != 60 || r.LastRx != 300 { + t.Fatalf("delta: got %+v", r) + } + + // Counter reset (reading dropped): the whole current value is new traffic. + sampleStats(live(40, 10)) + if r := rec(); r.TotalRx != 240 || r.TotalTx != 70 || r.LastRx != 40 { + t.Fatalf("reset branch: got %+v", r) + } + + // Peer missing from a non-empty dump must not touch the record. + sampleStats(map[string]PeerStat{"OTHER=": {PublicKey: "OTHER="}}) + if r := rec(); r.TotalRx != 240 || r.LastRx != 40 { + t.Fatalf("missing peer changed record: got %+v", r) + } + + // Simulate an app restart where the interface counter restarted near zero: + // the persisted total must keep growing, never drop. + before := rec().TotalRx + sampleStats(live(15, 5)) + if r := rec(); r.TotalRx != before+15 { + t.Fatalf("post-restart total must not drop: before=%d got %+v", before, r) + } + + // Reset rebaselines to the current live counter and zeroes the totals. + resetStats(k, 15, 5) + if r := rec(); r.TotalRx != 0 || r.TotalTx != 0 || r.LastRx != 15 || r.LastTx != 5 { + t.Fatalf("reset: got %+v", r) + } + // A subsequent unchanged reading adds nothing. + sampleStats(live(15, 5)) + if r := rec(); r.TotalRx != 0 || r.TotalTx != 0 { + t.Fatalf("post-reset unchanged sample added traffic: got %+v", r) + } + + // Delete removes the record. + deleteStats(k) + if _, ok := func() (StatRecord, bool) { + statsLock.Lock() + defer statsLock.Unlock() + r, ok := loadStatsLocked().Peers[k] + return r, ok + }(); ok { + t.Fatal("deleteStats left the record behind") + } +} + +// TestRequireSlash24 checks the CIDR guard added after review.md flagged that +// serverIP/incrementIP/awgConfHeader all hardcode /24 regardless of what the +// operator types in — so anything but a /24 must be rejected up front. +func TestRequireSlash24(t *testing.T) { + for _, v := range []string{"10.0.0.0/24", "192.168.5.0/24"} { + requireSlash24(v) // must not die + } + + for _, v := range []string{ + "10.0.0.0/16", // wrong prefix length + "10.0.0.5/24", // not the network base address + "not-a-cidr", // unparseable + "2001:db8::/24", // IPv6, not IPv4 + } { + func() { + webMode = true // turns die() into a recoverable panic + defer func() { webMode = false; recover() }() + requireSlash24(v) + t.Errorf("requireSlash24(%q) should have been rejected", v) + }() + } +} + +// TestCsrfSafe checks the CSRF guard added after review.md flagged that +// Basic-auth-only endpoints were reachable via a blind cross-site
+// POST/DELETE. HTML forms can never set Content-Type: application/json, so +// requiring it on every state-changing request blocks exactly that attack. +func TestCsrfSafe(t *testing.T) { + get := httptest.NewRequest(http.MethodGet, "/api/status", nil) + if !csrfSafe(get) { + t.Error("GET must always be csrf-safe") + } + + postNoCT := httptest.NewRequest(http.MethodPost, "/api/server/stop", nil) + if csrfSafe(postNoCT) { + t.Error("POST without Content-Type must be rejected") + } + + postForm := httptest.NewRequest(http.MethodPost, "/api/server/stop", nil) + postForm.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if csrfSafe(postForm) { + t.Error("POST with a form content-type (the only kind an HTML form can send) must be rejected") + } + + postJSON := httptest.NewRequest(http.MethodPost, "/api/server/stop", nil) + postJSON.Header.Set("Content-Type", "application/json") + if !csrfSafe(postJSON) { + t.Error("POST with application/json must be accepted") + } + + del := httptest.NewRequest(http.MethodDelete, "/api/clients/1", nil) + del.Header.Set("Content-Type", "application/json") + if !csrfSafe(del) { + t.Error("DELETE with application/json must be accepted") + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..0e17595 --- /dev/null +++ b/client.go @@ -0,0 +1,242 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "text/tabwriter" +) + +// clientKeys holds a freshly generated key triple. +type clientKeys struct { + priv string + pub string + psk string +} + +// generateClientKeys produces the client's private/public/preshared keys. +func generateClientKeys() clientKeys { + priv, err := output("awg", "genkey") + if err != nil { + die("awg genkey failed: %v", err) + } + pub, err := outputWithInput(priv, "awg", "pubkey") + if err != nil { + die("awg pubkey failed: %v", err) + } + psk, err := output("awg", "genpsk") + if err != nil { + die("awg genpsk failed: %v", err) + } + return clientKeys{priv: priv, pub: pub, psk: psk} +} + +// createClientConfig writes .conf plus a scannable .png QR code. +func createClientConfig(c *Config, id int64, name, ip string, keys clientKeys) { + filename := filepath.Join(clientDir, name+".conf") + + content := fmt.Sprintf(`# Client: %s +# ID: %d + +[Interface] +PrivateKey = %s +Address = %s/32 +DNS = %s +MTU = %s + +Jc = %s +Jmin = %s +Jmax = %s +S1 = %s +S2 = %s +H1 = %s +H2 = %s +H3 = %s +H4 = %s + +[Peer] +PublicKey = %s +PresharedKey = %s +AllowedIPs = 0.0.0.0/0 +Endpoint = %s:%s +PersistentKeepalive = 25 +`, name, id, + keys.priv, ip, c.DNS, c.MTU, + c.Jc, c.Jmin, c.Jmax, c.S1, c.S2, c.H1, c.H2, c.H3, c.H4, + c.ServerPub, keys.psk, + c.PublicIP, c.Port) + + if err := os.WriteFile(filename, []byte(content), 0600); err != nil { + die("Failed to write client config: %v", err) + } + + pngPath := filepath.Join(clientDir, name+".png") + if err := run("qrencode", "-s", "8", "-o", pngPath, "-r", filename); err != nil { + die("qrencode failed: %v", err) + } + // The QR code encodes the full .conf, private key included; qrencode + // creates it with the process umask (typically 0644). Lock it down to + // match the .conf it was generated from. + if err := os.Chmod(pngPath, 0600); err != nil { + die("Failed to secure QR code permissions: %v", err) + } + + info("Client config created: %s.conf", name) +} + +// createClient generates keys, config, QR, registers the client and hot-adds +// the peer to the running interface. +func createClient(c *Config, osInfo *OSInfo, args []string) { + if len(args) == 0 || args[0] == "" { + die("Client name required") + } + if len(args) > 1 { + die("Unexpected argument: %s", args[1]) + } + + name := sanitize(args[0]) + if name == "" { + die("Sanitized name is empty") + } + + clients := loadRegistry() + id := getNextID(clients) + lastIP := getLastIP(clients) + nextIP := incrementIP(lastIP, c.Network) + + keys := generateClientKeys() + createClientConfig(c, id, name, nextIP, keys) + + clients = append(clients, Client{ + ID: id, + Name: name, + IP: nextIP, + PublicKey: keys.pub, + PrivateKey: keys.priv, + PSKKey: keys.psk, + IsEnabled: "ACTIVE", + CreatedAt: nowUnix(), + }) + saveRegistry(clients) + + info("Client registered: id=%d name=%s ip=%s", id, name, nextIP) + + // Activate peer on the running server immediately — no restart needed. + confAppendPeer(c.Interface, name, keys.pub, keys.psk, nextIP) + awgPeerAdd(c.Interface, keys.pub, keys.psk, nextIP) +} + +// listClients prints an aligned table of all registered clients. +func listClients() { + clients := loadRegistry() + if len(clients) == 0 { + fmt.Println("No clients registered") + return + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tIP\tSTATUS") + for _, cl := range clients { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", cl.ID, cl.Name, cl.IP, cl.IsEnabled) + } + w.Flush() +} + +// parseID validates and parses a numeric client ID argument. +func parseID(args []string) int64 { + if len(args) == 0 { + die("Invalid ID") + } + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || id < 0 { + die("Invalid ID") + } + return id +} + +func disableClient(c *Config, osInfo *OSInfo, args []string) { + id := parseID(args) + clients := loadRegistry() + cl := findClient(clients, id) + if cl == nil { + die("Client with ID %d not found", id) + } + if cl.IsEnabled != "ACTIVE" { + die("Client %d (%s) is already DISABLED", id, cl.Name) + } + + pubkey := cl.PublicKey + name := cl.Name + cl.IsEnabled = "DISABLED" + saveRegistry(clients) + + awgPeerRemove(c.Interface, pubkey) + confRebuild(c, osInfo, clients) + + info("Client %d (%s): ACTIVE → DISABLED", id, name) +} + +func enableClient(c *Config, osInfo *OSInfo, args []string) { + id := parseID(args) + clients := loadRegistry() + cl := findClient(clients, id) + if cl == nil { + die("Client with ID %d not found", id) + } + if cl.IsEnabled != "DISABLED" { + die("Client %d (%s) is already ACTIVE", id, cl.Name) + } + + pubkey, psk, ip, name := cl.PublicKey, cl.PSKKey, cl.IP, cl.Name + cl.IsEnabled = "ACTIVE" + saveRegistry(clients) + + awgPeerAdd(c.Interface, pubkey, psk, ip) + confRebuild(c, osInfo, clients) + + info("Client %d (%s): DISABLED → ACTIVE", id, name) +} + +// setClientComment updates a client's free-form note in the registry. It touches +// neither the interface config nor the running peers — the comment is metadata +// only — so no rebuild/restart is needed. +func setClientComment(id int64, comment string) *Client { + clients := loadRegistry() + cl := findClient(clients, id) + if cl == nil { + die("Client with ID %d not found", id) + } + cl.Comment = comment + saveRegistry(clients) + info("Client %d (%s): comment updated", id, cl.Name) + return cl +} + +func deleteClient(c *Config, osInfo *OSInfo, args []string) { + id := parseID(args) + clients := loadRegistry() + cl := findClient(clients, id) + if cl == nil { + die("Client with ID %d not found", id) + } + + name := cl.Name + pubkey := cl.PublicKey + + remaining := make([]Client, 0, len(clients)) + for _, x := range clients { + if x.ID != id { + remaining = append(remaining, x) + } + } + saveRegistry(remaining) + + os.Remove(filepath.Join(clientDir, name+".conf")) + os.Remove(filepath.Join(clientDir, name+".png")) + + awgPeerRemove(c.Interface, pubkey) + confRebuild(c, osInfo, remaining) + deleteStats(pubkey) // drop the peer's accumulated traffic record + + info("Client %d (%s) removed", id, name) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..c38cb3b --- /dev/null +++ b/config.go @@ -0,0 +1,187 @@ +package main + +import ( + "bufio" + "encoding/json" + "os" + "regexp" + "strings" + "time" +) + +// Config mirrors the shell-sourced awg_config file. The on-disk format is kept +// byte-compatible with the original bash profiler so the two tools can share it. +type Config struct { + Network string // SERVER_NETWORK + Interface string // SERVER_INTERFACE + Port string // SERVER_PUBLIC_PORT + PublicIP string // SERVER_PUBLIC_IP + ServerPriv string // SERVER_PRIVATE_KEY + ServerPub string // SERVER_PUBLIC_KEY + DNS string // DNS_SERVER + MTU string // SERVER_MTU + + // AmneziaWG obfuscation parameters (shared by server and every client). + Jc string // AWG_JC + Jmin string // AWG_JMIN + Jmax string // AWG_JMAX + S1 string // AWG_S1 + S2 string // AWG_S2 + H1 string // AWG_H1 + H2 string // AWG_H2 + H3 string // AWG_H3 + H4 string // AWG_H4 +} + +// mtuOr returns the configured MTU or the given fallback (bash: ${SERVER_MTU:-1420}). +func (c *Config) mtuOr(def string) string { + if c.MTU == "" { + return def + } + return c.MTU +} + +var shellAssignRe = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)=(.*)$`) + +// loadConfig parses the profiler config file, aborting if it is missing. +func loadConfig() *Config { + f, err := os.Open(configFile) + if err != nil { + die("Config not found: run 'init-server' first") + } + defer f.Close() + + vals := map[string]string{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + m := shellAssignRe.FindStringSubmatch(line) + if m == nil { + continue + } + vals[m[1]] = unquoteShell(m[2]) + } + + return &Config{ + Network: vals["SERVER_NETWORK"], + Interface: vals["SERVER_INTERFACE"], + Port: vals["SERVER_PUBLIC_PORT"], + PublicIP: vals["SERVER_PUBLIC_IP"], + ServerPriv: vals["SERVER_PRIVATE_KEY"], + ServerPub: vals["SERVER_PUBLIC_KEY"], + DNS: vals["DNS_SERVER"], + MTU: vals["SERVER_MTU"], + Jc: vals["AWG_JC"], + Jmin: vals["AWG_JMIN"], + Jmax: vals["AWG_JMAX"], + S1: vals["AWG_S1"], + S2: vals["AWG_S2"], + H1: vals["AWG_H1"], + H2: vals["AWG_H2"], + H3: vals["AWG_H3"], + H4: vals["AWG_H4"], + } +} + +// unquoteShell strips a single layer of surrounding single/double quotes. +func unquoteShell(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// writeConfig serialises the config in the original shell-sourceable format. +func writeConfig(c *Config) { + now := time.Now().UTC().Format("2006-01-02 15:04:05") + " UTC" + content := `# AmneziaWG Profiler Configuration +# Generated: ` + now + ` + +# Server network (CIDR) — server takes .1, clients get .2+ +SERVER_NETWORK="` + c.Network + `" + +# AmneziaWG interface name +SERVER_INTERFACE="` + c.Interface + `" + +# Server listen port +SERVER_PUBLIC_PORT="` + c.Port + `" + +# Server public IP or hostname (used in client configs) +SERVER_PUBLIC_IP="` + c.PublicIP + `" + +# Server keys (generated by init-server) +SERVER_PRIVATE_KEY="` + c.ServerPriv + `" +SERVER_PUBLIC_KEY="` + c.ServerPub + `" + +# Client DNS +DNS_SERVER="` + c.DNS + `" + +# MTU +SERVER_MTU="` + c.MTU + `" + +# ─── AmneziaWG obfuscation parameters ─────────────────────────────── +# These MUST be identical on server and every client to interoperate. +# Jc : junk packet count (1-128, recommended 4-12) +# Jmin : min junk packet size (< Jmax, < 1280) +# Jmax : max junk packet size (> Jmin, <= 1280) +# S1 : init packet junk size (<= 1132) +# S2 : response packet junk size (<= 1188, and S1 + 56 != S2) +# H1-H4: magic header values (5..2147483647, all distinct) +AWG_JC="` + c.Jc + `" +AWG_JMIN="` + c.Jmin + `" +AWG_JMAX="` + c.Jmax + `" +AWG_S1="` + c.S1 + `" +AWG_S2="` + c.S2 + `" +AWG_H1="` + c.H1 + `" +AWG_H2="` + c.H2 + `" +AWG_H3="` + c.H3 + `" +AWG_H4="` + c.H4 + `" +` + if err := os.WriteFile(configFile, []byte(content), 0600); err != nil { + die("Failed to write config: %v", err) + } + info("Config written: %s", configFile) +} + +// ─── deps-installed state (new: separates install-deps from init-server) ───────── + +// State records whether the dependency-installation step has been completed. +// It lives in its own file so `install-deps` can run before any server config +// exists, and `init-server` can verify the flag without re-installing anything. +type State struct { + DepsInstalled bool `json:"deps_installed"` + InstalledAt string `json:"installed_at,omitempty"` + OSID string `json:"os_id,omitempty"` + OSVersion string `json:"os_version,omitempty"` +} + +// loadState reads the state file, returning a zero value if it is absent. +func loadState() *State { + data, err := os.ReadFile(stateFile) + if err != nil { + return &State{} + } + var s State + if err := json.Unmarshal(data, &s); err != nil { + warn("State file %s is invalid — treating as empty", stateFile) + return &State{} + } + return &s +} + +// saveState persists the state file with restrictive permissions. +func saveState(s *State) { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + die("Failed to encode state: %v", err) + } + if err := os.WriteFile(stateFile, append(data, '\n'), 0600); err != nil { + die("Failed to write state file: %v", err) + } +} diff --git a/deps.go b/deps.go new file mode 100644 index 0000000..3c26460 --- /dev/null +++ b/deps.go @@ -0,0 +1,94 @@ +package main + +import ( + "os" + "path/filepath" + "time" +) + +// installDeps installs the AmneziaWG stack and userspace tooling for the host +// OS, then records completion in the state file. This step is now fully +// decoupled from init-server: it must be run first, and init-server only +// verifies the recorded flag rather than installing anything itself. +func installDeps() { + os := detectOS() + info("Installing AmneziaWG dependencies for %s %s...", os.ID, os.Version) + + switch os.ID { + case "ubuntu", "debian", "linuxmint": + env := []string{"DEBIAN_FRONTEND=noninteractive"} + runOrDie2(env, "apt-get", "update", "-qq") + // Prerequisites for adding the PPA and building the kernel module. + runOrDie2(env, "apt-get", "install", "-y", + "software-properties-common", "python3-launchpadlib", "gnupg2", + "linux-headers-"+unameRelease(), "curl") + if os.ID == "linuxmint" { + // Mint's official docs require "Source code repositories" to be + // enabled (Software Sources → Optional Sources) before adding a + // PPA; the CLI equivalent is uncommenting the deb-src lines that + // Mint ships disabled by default. + run("sed", "-i", "s/^# deb-src/deb-src/", + "/etc/apt/sources.list.d/official-package-repositories.list") + runOrDie2(env, "apt-get", "update", "-qq") + } + // AmneziaWG kernel module + userspace tools via the official PPA. + runOrDie2(env, "add-apt-repository", "-y", "ppa:amnezia/ppa") + runOrDie2(env, "apt-get", "update", "-qq") + runOrDie2(env, "apt-get", "install", "-y", + "amneziawg", "amneziawg-tools", + "jq", "qrencode", "nftables") + case "alpine": + runOrDie("apk", "update") + runOrDie("apk", "add", + "jq", "libqrencode-tools", "nftables", "curl", "util-linux") + buildAmneziawgToolsFromSource() + warn("On Alpine the AmneziaWG kernel module may need to be provided") + warn("separately (DKMS/akmods or a prebuilt module for your kernel)") + } + + // Record that dependency installation completed so init-server can verify it. + saveState(&State{ + DepsInstalled: true, + InstalledAt: time.Now().UTC().Format(time.RFC3339), + OSID: os.ID, + OSVersion: os.Version, + }) + + info("Dependencies installed") + info("State recorded: %s (deps_installed=true)", stateFile) + info("Next step: run 'init-server' to configure and start the server") +} + +// runOrDie2 is runEnv + abort-on-failure (env-aware variant of runOrDie). +func runOrDie2(env []string, name string, args ...string) { + if err := runEnv(env, name, args...); err != nil { + die("%s failed: %v", name, err) + } +} + +// buildAmneziawgToolsFromSource builds and installs `awg`/`awg-quick` on an +// Alpine host. Unlike Ubuntu/Debian/Mint (served by the official +// ppa:amnezia/ppa), amneziawg-tools has no Alpine apk package — the container +// image already builds it from source for exactly this reason (see the +// tools-builder stage in Dockerfile); this mirrors that same build for a bare +// Alpine host. +func buildAmneziawgToolsFromSource() { + info("No Alpine package exists for amneziawg-tools — building from source...") + runOrDie("apk", "add", "git", "build-base", "linux-headers", "bash") + + dir, err := os.MkdirTemp("", "amneziawg-tools-*") + if err != nil { + die("Failed to create temp build dir: %v", err) + } + defer os.RemoveAll(dir) + + runOrDie("git", "clone", "--depth=1", + "https://github.com/amnezia-vpn/amneziawg-tools", dir) + src := filepath.Join(dir, "src") + runOrDieIn(src, "make") + runOrDieIn(src, "make", "install", + "WITH_WGQUICK=yes", "WITH_BASHCOMPLETION=no", "WITH_SYSTEMDUNITS=no", + "PREFIX=/usr") + + info("amneziawg-tools built and installed (awg, awg-quick)") +} diff --git a/docker-compose.mint.yml b/docker-compose.mint.yml new file mode 100644 index 0000000..cac94a0 --- /dev/null +++ b/docker-compose.mint.yml @@ -0,0 +1,39 @@ +services: + awg-profiler-mint: + build: + context: . + dockerfile: Dockerfile.mint + image: awg-profiler:mint + container_name: awg-profiler-mint + restart: unless-stopped + + # ── Networking capabilities ────────────────────────────────────────────── + # NET_ADMIN: create/configure the WG interface and load nft rules. + # /dev/net/tun: the amneziawg-go userspace data plane needs a TUN device + # (no kernel module is loaded — see Dockerfile.mint header comment). + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + net.ipv4.ip_forward: "1" + + ports: + - "51820:51820/udp" # AmneziaWG listen port (match SERVER_PUBLIC_PORT) + - "127.0.0.1:8080:8080/tcp" # management web UI — localhost only by default; + # widen to "8080:8080" only after setting + # AWG_WEB_USER/AWG_WEB_PASS below + + # ── Persistence ────────────────────────────────────────────────────────── + volumes: + - awg-data-mint:/data # config, client registry, profiles, state + - awg-etc-mint:/etc/amnezia/amneziawg # interface .conf + nft ruleset + + # ── Web UI auth (uncomment for anything beyond localhost/LAN) ───────────── + # environment: + # AWG_WEB_USER: admin + # AWG_WEB_PASS: "change-me-to-a-long-password" + +volumes: + awg-data-mint: + awg-etc-mint: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3834287 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + awg-profiler: + build: + context: . + image: awg-profiler:latest + container_name: awg-profiler + restart: unless-stopped + + # ── Networking capabilities ────────────────────────────────────────────── + # NET_ADMIN: create/configure the WG interface and load nft rules. + # /dev/net/tun: the amneziawg-go userspace data plane needs a TUN device. + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + net.ipv4.ip_forward: "1" + + ports: + - "51820:51820/udp" # AmneziaWG listen port (match SERVER_PUBLIC_PORT) + - "127.0.0.1:8080:8080/tcp" # management web UI — localhost only by default; + # widen to "8080:8080" only after setting + # AWG_WEB_USER/AWG_WEB_PASS below + + # ── Persistence ────────────────────────────────────────────────────────── + volumes: + - awg-data:/data # config, client registry, profiles, state + - awg-etc:/etc/amnezia/amneziawg # interface .conf + nft ruleset + + # ── Web UI auth (uncomment for anything beyond localhost/LAN) ───────────── + # environment: + # AWG_WEB_USER: admin + # AWG_WEB_PASS: "change-me-to-a-long-password" + +volumes: + awg-data: + awg-etc: diff --git a/entrypoint.mint.sh b/entrypoint.mint.sh new file mode 100644 index 0000000..a4f64cf --- /dev/null +++ b/entrypoint.mint.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Container entrypoint for awg_profiler — Linux Mint image (Dockerfile.mint). +# +# Identical role to entrypoint.sh (Alpine): the AmneziaWG stack is baked into +# the image at build time, so "install-deps" is a no-op here. We seed the +# deps-installed flag that init-server / the web setup wizard require, enable +# IP forwarding, then hand off to the profiler with whatever command was +# passed (defaults to `web`). Split into its own file only because the +# seeded os_id/os_version below differ from the Alpine image. +set -e + +: "${AWG_PROFILER_DIR:=/data}" +export AWG_PROFILER_DIR + +mkdir -p "$AWG_PROFILER_DIR" /etc/amnezia/amneziawg + +# Seed the deps-installed state so init-server doesn't demand `install-deps` +# (packages are already present in the image). Written only if absent so it +# never clobbers real state on a persistent volume. +STATE_FILE="$AWG_PROFILER_DIR/awg_state.json" +if [ ! -f "$STATE_FILE" ]; then + OS_VERSION="$(. /etc/os-release && echo "$VERSION_ID")" + cat > "$STATE_FILE" </dev/null 2>&1 || true + +exec /usr/local/bin/awg_profiler "$@" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..68b1b87 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Container entrypoint for awg_profiler. +# +# The AmneziaWG stack is baked into the image at build time, so the profiler's +# "install-deps" step is a no-op here. We seed the deps-installed flag that +# init-server / the web setup wizard require, enable IP forwarding, then hand +# off to the profiler with whatever command was passed (defaults to `web`). +set -e + +: "${AWG_PROFILER_DIR:=/data}" +export AWG_PROFILER_DIR + +mkdir -p "$AWG_PROFILER_DIR" /etc/amnezia/amneziawg + +# Seed the deps-installed state so init-server doesn't demand `install-deps` +# (packages are already present in the image). Written only if absent so it +# never clobbers real state on a persistent volume. +STATE_FILE="$AWG_PROFILER_DIR/awg_state.json" +if [ ! -f "$STATE_FILE" ]; then + cat > "$STATE_FILE" </dev/null 2>&1 || true + +exec /usr/local/bin/awg_profiler "$@" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b5c3591 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module awgprofiler + +go 1.26 diff --git a/main.go b/main.go new file mode 100644 index 0000000..e01f3f1 --- /dev/null +++ b/main.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +// Paths mirror the bash profiler's layout so both tools can share on-disk +// state. They are resolved relative to the executable's directory (overridable +// with AWG_PROFILER_DIR), the Go analogue of the script's SCRIPT_DIR. +var ( + scriptDir string + configFile string + dataDir string + clientDir string + registryFile string + statsFile string + stateFile string + + // Directory awg-quick reads interface configs from (AmneziaWG default). + awgConfDir = "/etc/amnezia/amneziawg" +) + +func resolveScriptDir() string { + if d := os.Getenv("AWG_PROFILER_DIR"); d != "" { + return d + } + exe, err := os.Executable() + if err != nil { + die("Cannot determine executable path: %v", err) + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return filepath.Dir(exe) +} + +func initPaths() { + scriptDir = resolveScriptDir() + configFile = filepath.Join(scriptDir, "awg_config") + dataDir = filepath.Join(scriptDir, "data") + clientDir = filepath.Join(scriptDir, "awg_clients") + registryFile = filepath.Join(dataDir, "awg_clients.json") + statsFile = filepath.Join(dataDir, "awg_stats.json") + stateFile = filepath.Join(scriptDir, "awg_state.json") +} + +func usage() { + fmt.Print(`AmneziaWG Profiler — server + client management tool (AmneziaWG variant) + +Usage: awg_profiler [options] + +SERVER SETUP + install-deps Install AmneziaWG, jq, qrencode for this OS and + record a deps-installed flag (run this FIRST) + init-server Interactive server setup: verifies dependencies are + installed, then generates keys + obfuscation + parameters, writes config, enables IP forwarding and + starts the service (does NOT install packages) + +SERVER MANAGEMENT + server-status Show interface stats and peer list + server-start Start AmneziaWG service + server-stop Stop AmneziaWG service + server-restart Restart AmneziaWG service + show-config Print current config (private keys hidden) + sync-config Rebuild /.conf from client + registry and optionally restart the service + +CLIENT MANAGEMENT + create Create new VPN client; generates keys, .conf and QR + code + delete Remove client by ID (deletes files + registry entry) + disable Set client status to DISABLED + enable Set client status to ACTIVE + list List all registered clients + +WEB UI + web [--addr host:port] Start the management web UI (default 127.0.0.1:8080). + Set AWG_WEB_USER + AWG_WEB_PASS for HTTP Basic auth. + [--theme name] Select the design (or AWG_WEB_THEME env): + classic original Modern Dark UI (default) + glass glassmorphism · Slate + Amber + [--ui-mode mode] Colour mode (or AWG_WEB_MODE env): + dark force dark palette (default) + light force light palette (classic only) + auto follow the viewer's OS preference + +Supported OS: Ubuntu, Debian, Linux Mint, Alpine Linux +`) +} + +func main() { + initPaths() + + args := os.Args[1:] + cmd := "" + if len(args) > 0 { + cmd = args[0] + } + rest := args + if len(args) > 0 { + rest = args[1:] + } + + switch cmd { + + case "init-server": + initServer() + + case "install-deps": + installDeps() + + case "server-status": + requireBinary("awg") + c := loadConfig() + osInfo := detectOS() + serverStatus(c, osInfo) + + case "server-start": + c := loadConfig() + osInfo := detectOS() + serviceStart(osInfo, c.Interface) + info("AmneziaWG started: %s", c.Interface) + + case "server-stop": + c := loadConfig() + osInfo := detectOS() + serviceStop(osInfo, c.Interface) + info("AmneziaWG stopped: %s", c.Interface) + + case "server-restart": + c := loadConfig() + osInfo := detectOS() + serviceRestart(osInfo, c.Interface) + info("AmneziaWG restarted: %s", c.Interface) + + case "show-config": + showConfig() + + case "sync-config": + // jq is no longer needed: the Go port reads/writes the registry natively. + c := loadConfig() + osInfo := detectOS() + syncConfig(c, osInfo) + + case "create": + requireBinary("awg") + requireBinary("qrencode") + c := loadConfig() + initStorage() + createClient(c, detectOS(), rest) + + case "delete": + c := loadConfig() + initStorage() + deleteClient(c, detectOS(), rest) + + case "disable": + c := loadConfig() + initStorage() + disableClient(c, detectOS(), rest) + + case "enable": + c := loadConfig() + initStorage() + enableClient(c, detectOS(), rest) + + case "list": + // load_config enforces that the server has been initialised first. + loadConfig() + initStorage() + listClients() + + case "web": + runWeb(rest) + + case "--help", "-h", "help", "": + usage() + + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd) + usage() + os.Exit(1) + } +} diff --git a/osdetect.go b/osdetect.go new file mode 100644 index 0000000..5835597 --- /dev/null +++ b/osdetect.go @@ -0,0 +1,76 @@ +package main + +import ( + "bufio" + "os" + "strings" +) + +// OSInfo holds the detected distribution identity and init system. +type OSInfo struct { + ID string + Version string + Init string // "systemd" or "openrc" +} + +// detectOS parses /etc/os-release and maps the distro to its init system. +func detectOS() *OSInfo { + f, err := os.Open("/etc/os-release") + if err != nil { + die("Cannot detect OS: /etc/os-release not found") + } + defer f.Close() + + vals := map[string]string{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if i := strings.Index(line, "="); i >= 0 { + key := strings.TrimSpace(line[:i]) + vals[key] = unquoteShell(line[i+1:]) + } + } + + osInfo := &OSInfo{ + ID: valueOr(vals["ID"], "unknown"), + Version: valueOr(vals["VERSION_ID"], "unknown"), + } + + switch osInfo.ID { + case "ubuntu", "debian", "linuxmint": + osInfo.Init = "systemd" + case "alpine": + osInfo.Init = "openrc" + default: + die("Unsupported OS: %s (supported: ubuntu, debian, linuxmint, alpine)", osInfo.ID) + } + + // A systemd-based distro may still be running here without systemd as + // PID 1 — the container case (e.g. the Linux Mint image used for the + // alternative container build). systemd creates /run/systemd/system only + // when it is actually active as the init system, so its absence is the + // standard way to detect this. Fall back to driving awg-quick directly, + // same as the Alpine/OpenRC path. + if osInfo.Init == "systemd" && !systemdRunning() { + osInfo.Init = "openrc" + } + + info("Detected OS: %s %s (init: %s)", osInfo.ID, osInfo.Version, osInfo.Init) + return osInfo +} + +// systemdRunning reports whether systemd is active as PID 1. +func systemdRunning() bool { + _, err := os.Stat("/run/systemd/system") + return err == nil +} + +func valueOr(v, def string) string { + if v == "" { + return def + } + return v +} diff --git a/registry.go b/registry.go new file mode 100644 index 0000000..6224043 --- /dev/null +++ b/registry.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/json" + "os" + "sort" + "time" +) + +// Client is one entry in the JSON registry. The field tags reproduce the exact +// object shape the bash/jq version wrote, so registries are interchangeable. +type Client struct { + ID int64 `json:"id"` + Name string `json:"name"` + IP string `json:"ip"` + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key"` + PSKKey string `json:"psk_key"` + IsEnabled string `json:"is_enabled"` + CreatedAt int64 `json:"created_at"` + // Comment is a free-form note attached to the profile via the web UI. It is + // registry-only (never written into the .conf) and omitted when empty so + // registries stay byte-compatible with the bash/jq version until a note is set. + Comment string `json:"comment,omitempty"` +} + +// initStorage creates the data/client directories and an empty registry, +// validating any existing registry JSON (bash: jq empty). Both directories +// hold secret material (the registry's private_key field, client .conf/.png +// files) so they are created owner-only. +func initStorage() { + if err := os.MkdirAll(dataDir, 0700); err != nil { + die("Failed to create data dir: %v", err) + } + if err := os.MkdirAll(clientDir, 0700); err != nil { + die("Failed to create client dir: %v", err) + } + if _, err := os.Stat(registryFile); os.IsNotExist(err) { + if err := os.WriteFile(registryFile, []byte("[]"), 0644); err != nil { + die("Failed to initialise registry: %v", err) + } + } + // Validate JSON (equivalent to `jq empty`). + loadRegistry() +} + +// loadRegistry reads and parses the registry, aborting on invalid JSON. +func loadRegistry() []Client { + data, err := os.ReadFile(registryFile) + if err != nil { + die("Failed to read registry: %v", err) + } + var clients []Client + if err := json.Unmarshal(data, &clients); err != nil { + die("Registry JSON is invalid") + } + return clients +} + +// saveRegistry writes the registry atomically (temp file + rename), matching +// the bash mktemp/mv pattern, then re-validates it. +func saveRegistry(clients []Client) { + data, err := json.MarshalIndent(clients, "", " ") + if err != nil { + die("Failed to update registry: %v", err) + } + tmp, err := os.CreateTemp(dataDir, "registry-*.tmp") + if err != nil { + die("Failed to update registry: %v", err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + die("Failed to update registry: %v", err) + } + tmp.Close() + if err := os.Rename(tmpName, registryFile); err != nil { + os.Remove(tmpName) + die("Failed to update registry: %v", err) + } + // Re-validate (bash: jq empty after write). + loadRegistry() +} + +// getNextID returns max(id)+1, or 1 for an empty registry. +func getNextID(clients []Client) int64 { + if len(clients) == 0 { + return 1 + } + var max int64 + for _, c := range clients { + if c.ID > max { + max = c.ID + } + } + return max + 1 +} + +// getLastIP returns the IP of the highest-id client (empty registry → ""). +func getLastIP(clients []Client) string { + if len(clients) == 0 { + return "" + } + sorted := append([]Client(nil), clients...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) + return sorted[len(sorted)-1].IP +} + +// findClient returns a pointer to the client with the given id, or nil. +func findClient(clients []Client, id int64) *Client { + for i := range clients { + if clients[i].ID == id { + return &clients[i] + } + } + return nil +} + +// nowUnix returns the current Unix timestamp (bash: now|floor). +func nowUnix() int64 { + return time.Now().Unix() +} diff --git a/review.md b/review.md new file mode 100644 index 0000000..8435000 --- /dev/null +++ b/review.md @@ -0,0 +1,211 @@ +# Ревью проекта awg_profiler + +*Дата: 2026-07-18. Метод: сверка каждого утверждения README.md с кодом +(все 13 Go-файлов, оба Dockerfile, compose-файлы, entrypoint'ы, webui).* + +Общий вердикт: **README точен, проект добротный** — атомарные записи, +продуманная схема статистики, аккуратное разделение CLI/web. Но при сверке +нашлись реальные расхождения и проблемы в самом коде. + +## Точность README: подтверждено кодом + +- Окно «онлайн» ≤ 150 с — совпадает (`onlineWindow = 150s`, stats.go:29), + обновление UI каждые 10 с (`setInterval(refreshAll, 10000)`), фоновый замер + раз в 20 с (`time.Tick(20s)`). +- Накопление статистики: дельты неотрицательны, сброс счётчика распознаётся, + atomic write (temp+rename), битый файл → `.bad` — всё как описано (stats.go). +- Секреты не отдаются в JSON API (`clientOut` без private/psk), Basic auth + через `subtle.ConstantTimeCompare`, `die()` в web-режиме превращается в + HTTP-ошибку — совпадает. +- `go vet` чистый, `go test ./...` — ok (9 тестов). + +## Найденные проблемы + +### 1. CSRF на мутирующих POST-эндпоинтах (средняя серьёзность) + +Web API защищён только Basic auth, а браузер прикладывает эти credentials +автоматически. Cross-origin `` POST с телом `text/plain` не требует +preflight, а `json.Decoder` в хендлерах не проверяет `Content-Type` — то есть +вредоносная страница может выполнить `POST /api/server/stop`, +`/api/clients` (create), `/api/server/restart` от имени залогиненного +админа. `DELETE` через форму невозможен, но enable/disable/stop — POST. + +**Фикс:** проверка `Content-Type: application/json` или заголовка +`Origin`/`Sec-Fetch-Site` в обёртке `h()` (web.go:214) — ~5 строк. + +### 2. Заявлен произвольный CIDR, реально поддерживается только /24 + +`init-server` спрашивает «VPN network CIDR» (default `10.0.0.0/24`), но: + +- `serverIP()` жёстко берёт `x.y.z.1`; +- `getFirstClientIP` — `x.y.z.2`; +- `incrementIP` крутит только последний октет (умирает на .255 → максимум + ~252 клиента); +- `awgConfHeader` пишет `Address = %s/24` **независимо от введённого + префикса** (awg.go:111). + +Введи пользователь `10.0.0.0/16` — конфиг молча станет /24. + +**Фикс:** либо валидация «только /24» на входе, либо честная поддержка +префикса. + +### 3. QR-код содержит приватный ключ, но PNG создаётся с правами 0644 + +`.conf` пишется с 0600, а `.png` — вывод `qrencode` с дефолтным +umask (0644) в каталоге 0755. QR кодирует весь конфиг, включая `PrivateKey` +и `PresharedKey` — любой локальный пользователь хоста может его прочитать и +декодировать. + +**Фикс:** `chmod 0600` после генерации и/или `0700` на `awg_clients/`. + +### 4. Противоречие Alpine-веток + +Комментарий в Dockerfile: «AmneziaWG is NOT packaged in Alpine's repos» +(потому tools собираются из исходников), но `deps.go` для Alpine-хоста +выполняет `apk add amneziawg-tools` — если пакета нет, `install-deps` на +голом Alpine просто упадёт. Одно из двух утверждений неверно. + +**Фикс:** проверить наличие пакета в Alpine и привести к единому поведению +(либо собирать из исходников и на хосте, либо убрать комментарий). + +### 5. docker-compose по умолчанию: web без auth на всех интерфейсах хоста + +`ports: "8080:8080"` публикует UI наружу, а auth закомментирован. Приложение +печатает WARN, README предупреждает — но безопасный дефолт был бы +`"127.0.0.1:8080:8080"` с комментарием «поменяйте после включения auth». + +### Мелочи + +- `randMagic()`: диапазон получается [5, 2147483646] вместо заявленного + [5, 2147483647] (`n % 2147483642 + 5`), плюс небольшой modulo bias от + uint32. Косметика, но спека в комментарии не совпадает с кодом на единицу. +- `handleCreate`: если `awgPeerAdd` упадёт после `saveRegistry`, клиент + останется в реестре, а вызывающему вернётся 400 — частичное состояние без + отката. +- IP-адреса удалённых клиентов не переиспользуются (кроме последнего) — пул + «протекает» при churn'е. +- Тесты покрывают только чистые функции (util/config/stats-accumulate); ни + одного теста на HTTP-хендлеры или registry-операции, хотя они легко + тестируются с `AWG_PROFILER_DIR` во временный каталог (паттерн уже есть в + `TestStateRoundTrip`). +- Оценка «~20-30 МБ рантайм-слой» в README оптимистична: один бинарник + профилировщика — 10.5 МБ, плюс alpine+bash+iproute2+nftables; реально + ближе к 40-50 МБ. Стоит поправить или убрать цифру. + +## Что сделано хорошо + +- Раздельные мьютексы: `opLock` для WG-операций, `statsLock` для + статистики — фоновый замер не блокируется долгим install. +- `die()` → panic → recover в web-режиме: ошибка операции становится + HTTP-ответом, а не падением сервера. +- Валидация ответа IP-сервисов через `net.ParseIP` с лимитом чтения + (защита от HTML-ответов вместо адреса). +- Hot-add/remove пиров через `awg set` без рестарта интерфейса. +- `entrypoint` сеет state-флаг только при отсутствии файла — не затирает + данные на persistent volume. +- `.dockerignore` минимизирует build-контекст. +- Дизайн накопления статистики (баз-поинт на диске + неотрицательные + дельты) — корректное решение реальной проблемы userspace-рестартов. + +## Рекомендуемый порядок исправлений + +1. **№1 (CSRF)** и **№3 (права QR)** — безопасность. +2. **№2** — валидация /24. +3. **№4** — согласовать Alpine-ветки. +4. Остальное — по мере необходимости. + +## Исправлено в ходе ревью + +- `Dockerfile` (Alpine): отсутствовал `COPY webui_glass/ ./webui_glass/` — + `go build` падал на чистом чекауте из-за `//go:embed webui/* webui_glass/*`. + Строка добавлена, сборка проверена. + +## Статус доработок (выполнены) + +### 1. CSRF — исправлено + +Добавлена функция `csrfSafe()` (web.go), вызывается из обёртки `h()`: +любой `POST`/`DELETE` без `Content-Type: application/json` отклоняется +`415 Unsupported Media Type`. HTML-форма физически не может выставить этот +заголовок (только `text/plain`, `application/x-www-form-urlencoded`, +`multipart/form-data`), поэтому blind cross-site form-POST больше не +проходит. `GET` не тронут (не мутирует состояние). + +Клиентская часть (`webui/app.js`, `webui_glass/app.js`, идентичны — +обновлены оба) теперь всегда шлёт этот заголовок на `POST`/`DELETE`, даже +если тела нет (`server/start`, `enable`/`disable`, `stats/reset`, +`install-deps` и т.д. раньше отправлялись вовсе без `Content-Type`). + +Тест: `TestCsrfSafe` (awg_profiler_test.go). + +### 2. Валидация /24 — исправлено (вариант «запретить не-/24») + +Добавлена `requireSlash24()` (util.go): парсит CIDR через `net.ParseCIDR`, +требует IPv4 и ровно `/24`, требует совпадения введённого адреса с базовым +адресом подсети (иначе подсказывает правильный). Вызывается из +`initServer()` (CLI, server.go) и `initServerWeb()` (веб-мастер, webops.go) +сразу после чтения `Network`. Любой другой префикс теперь явно отклоняется +с понятным сообщением, а не молча превращается в /24. + +Тест: `TestRequireSlash24`. + +README дополнен пояснением, что принимается только `/24`. + +### 3. Права QR-кода — исправлено + +`createClientConfig()` (client.go) теперь делает `os.Chmod(pngPath, 0600)` +сразу после `qrencode`, той же логике, что уже применялась к `.conf`. +Дополнительно (по варианту «и/или 0700 на awg_clients/» из фикса) каталоги +`data/` и `awg_clients/` в `initStorage()` (registry.go) и в +`saveStatsLocked()` (stats.go) теперь создаются с правами `0700` вместо +`0755` — они хранят приватные ключи (`registry.json`, `.conf`/`.png`). +Существующие деплойменты, где каталоги уже созданы с 0755, не меняются +автоматически (`MkdirAll` не трогает права существующих директорий). + +### 4. Противоречие Alpine-веток — исправлено + +Проверено через веб-поиск: `amneziawg-tools` действительно отсутствует в +официальных apk-репозиториях Alpine — значит был неверен `deps.go`, а не +комментарий в Dockerfile. `installDeps()` для Alpine больше не делает +`apk add amneziawg-tools` (пакета нет — команда просто падала бы на живом +хосте); вместо этого новая функция `buildAmneziawgToolsFromSource()` +(deps.go) собирает `awg`/`awg-quick` из исходников тем же способом, что и +`tools-builder`-стадия в Dockerfile (`git clone` → `make -C src` → +`make -C src install PREFIX=/usr`). Требование к kernel-модулю (нужно +предоставить отдельно) осталось прежним и явно описано в README. + +### 5. docker-compose без auth на всех интерфейсах — исправлено + +`docker-compose.yml` и `docker-compose.mint.yml`: порт `8080` теперь +публикуется как `127.0.0.1:8080:8080` вместо `8080:8080` — веб-UI по +умолчанию доступен только с самой машины. Комментарий рядом объясняет, что +расширять до всех интерфейсов стоит только вместе с +`AWG_WEB_USER`/`AWG_WEB_PASS`. README (Quick Start, вариант A) обновлён: +объяснено, как открыть UI локально или через SSH-туннель, и что менять +перед тем, как открывать порт наружу. + +### Мелочи — частично исправлены + +- **`randMagic()` off-by-one — исправлено.** Диапазон теперь честные + `[5, 2147483647]` (`span = 2147483647-5+1`), а не `[5, 2147483646]`. +- **`handleCreate` частичное состояние — исправлено.** `awgPeerAdd()` + (awg.go) больше не вызывает `die()` при неудаче `awg set` — на этом этапе + клиент уже сохранён в реестре и добавлен в `.conf` + (`confAppendPeer` вызывается раньше), так что живой hot-add — best-effort: + при неудаче пишется `warn()` и создание клиента по-прежнему считается + успешным (применится на следующем restart/sync-config). +- **Тесты на HTTP-хендлеры/registry — частично.** Добавлены целевые тесты + на обе новые функции безопасности (`TestCsrfSafe`, `TestRequireSlash24`). + Полное покрытие HTTP-хендлеров (`handleCreate`, `handleDelete` и т.д.) в + эту доработку не входило — осталось как есть. +- **IP-адреса удалённых клиентов не переиспользуются — не тронуто + осознанно.** Изменение схемы выдачи IP — это поведенческое изменение с + риском разойтись с форматом реестра bash-версии; оставлено как + зафиксированный, но не блокирующий issue. +- **Оценка размера образа «~20-30 МБ» — исправлено.** Неподтверждённая + цифра убрана из README вместо того, чтобы гадать без реальной сборки + образа. + +Все правки проверены: `gofmt -l .` чист, `go vet ./...` чист, `go build .` +успешен, `go test ./...` — 11/11 тестов проходят (добавлены `TestCsrfSafe`, +`TestRequireSlash24`). diff --git a/server.go b/server.go new file mode 100644 index 0000000..eda22c5 --- /dev/null +++ b/server.go @@ -0,0 +1,274 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// initServer runs the interactive server setup. Unlike the original script it +// no longer installs dependencies — that is a separate, prerequisite step. It +// verifies the deps-installed flag recorded by `install-deps` and aborts if it +// is missing, then generates keys/obfuscation params, writes configs, enables +// forwarding and starts the service. +func initServer() { + osInfo := detectOS() + + // New flow: dependency installation must have happened first. + st := loadState() + if !st.DepsInstalled { + die("Dependencies not installed — run 'install-deps' first, then re-run 'init-server'") + } + info("Dependency check passed (installed %s on %s %s)", + valueOr(st.InstalledAt, "unknown time"), + valueOr(st.OSID, "?"), valueOr(st.OSVersion, "?")) + // The awg binary must exist now that deps are installed. + requireBinary("awg") + + fmt.Println() + fmt.Println("=== AmneziaWG Server Initialization ===") + fmt.Println() + fmt.Println("Dependencies already installed. This will:") + fmt.Println(" 1. Generate server key pair") + fmt.Println(" 2. Generate AmneziaWG obfuscation parameters") + fmt.Printf(" 3. Write %s/.conf\n", awgConfDir) + fmt.Println(" 4. Enable IP forwarding (persistent)") + fmt.Println(" 5. Start and enable the AmneziaWG service") + fmt.Println() + if !confirm("Proceed?") { + info("Aborted") + os.Exit(0) + } + fmt.Println() + + c := &Config{} + + // Interactive configuration. + c.Interface = promptDefault("AmneziaWG interface name", "awg0") + c.Network = promptDefault("VPN network CIDR", "10.0.0.0/24") + requireSlash24(c.Network) + c.Port = promptDefault("Listen port", "51820") + + detectedIP := detectPublicIP() + c.PublicIP = promptDefault("Server public IP or hostname", detectedIP) + + c.DNS = promptDefault("DNS server for clients", "1.1.1.1") + c.MTU = promptDefault("MTU", "1420") + + mtu := atoiOrDie(c.MTU) + + // AmneziaWG obfuscation parameters — randomised defaults, editable. + // Ceilings are derived from the interface MTU per the AmneziaWG spec: + // Jmin < Jmax <= MTU ; S1 <= MTU-148 ; S2 <= MTU-92 + mtuJunkCeil := mtu + mtuS1Ceil := mtu - 148 + mtuS2Ceil := mtu - 92 + + rndJc := randRange(4, 12) + + jminHi := minInt(mtuJunkCeil-2, 32) + rndJmin := randRange(8, jminHi) + + jmaxLo := maxInt(rndJmin+32, 80) + jmaxHi := minInt(mtuJunkCeil, 200) + rndJmax := randRange(jmaxLo, jmaxHi) + + rndS1 := randRange(15, minInt(mtuS1Ceil, 150)) + rndS2 := randRange(15, minInt(mtuS2Ceil, 150)) + // S1 + 56 != S2 (spec constraint) — resample S2 until it holds. + for rndS1+56 == rndS2 { + rndS2 = randRange(15, minInt(mtuS2Ceil, 150)) + } + + fmt.Println() + fmt.Println("AmneziaWG obfuscation parameters (shared by server and all clients):") + c.Jc = promptDefault("Jc (junk packet count)", itoa(rndJc)) + c.Jmin = promptDefault("Jmin (min junk size)", itoa(rndJmin)) + c.Jmax = promptDefault("Jmax (max junk size)", itoa(rndJmax)) + c.S1 = promptDefault("S1 (init junk size)", itoa(rndS1)) + c.S2 = promptDefault("S2 (response junk size)", itoa(rndS2)) + + // Four distinct magic-header values in [5, 2^31-1]. + h1 := randMagic() + h2 := randMagic() + for h2 == h1 { + h2 = randMagic() + } + h3 := randMagic() + for h3 == h1 || h3 == h2 { + h3 = randMagic() + } + h4 := randMagic() + for h4 == h1 || h4 == h2 || h4 == h3 { + h4 = randMagic() + } + c.H1 = promptDefault("H1 (magic header 1)", itoa64(h1)) + c.H2 = promptDefault("H2 (magic header 2)", itoa64(h2)) + c.H3 = promptDefault("H3 (magic header 3)", itoa64(h3)) + c.H4 = promptDefault("H4 (magic header 4)", itoa64(h4)) + fmt.Println() + + // Generate server keys (awg is guaranteed present by the deps check). + info("Generating server keys...") + priv, err := output("awg", "genkey") + if err != nil { + die("awg genkey failed: %v", err) + } + pub, err := outputWithInput(priv, "awg", "pubkey") + if err != nil { + die("awg pubkey failed: %v", err) + } + c.ServerPriv = priv + c.ServerPub = pub + + srvIP := serverIP(c.Network) + defIface := defaultRouteIface() + + // Write the profiler config file. + writeConfig(c) + + // Write /.conf and companion nft ruleset. + confPath := awgConfPath(c.Interface) + info("Writing %s...", confPath) + if err := os.MkdirAll(awgConfDir, 0755); err != nil { + die("Failed to create %s: %v", awgConfDir, err) + } + if err := os.WriteFile(confPath, []byte(awgConfHeader(c, c.ServerPriv, srvIP, c.Port, c.MTU)), 0600); err != nil { + die("Failed to write %s: %v", confPath, err) + } + writeNftRules(c.Interface, defIface, c.MTU) + + // IP forwarding. + info("Enabling IP forwarding...") + enableIPForwarding() + + // Service. + serviceEnable(osInfo, c.Interface) + serviceStart(osInfo, c.Interface) + + // Init client storage. + initStorage() + + fmt.Println() + info("=== Server initialization complete ===") + info("Interface : %s", c.Interface) + info("Server IP : %s", srvIP) + info("Network : %s", c.Network) + info("Port : %s", c.Port) + info("Public IP : %s", c.PublicIP) + info("Public Key: %s", c.ServerPub) + info("Obfusc. : Jc=%s Jmin=%s Jmax=%s S1=%s S2=%s", c.Jc, c.Jmin, c.Jmax, c.S1, c.S2) + fmt.Println() + info("Next steps: use 'create ' to add VPN clients") +} + +// ─── server management ─────────────────────────────────────────────────────────── + +func serverStatus(c *Config, osInfo *OSInfo) { + fmt.Println() + fmt.Println("=== AmneziaWG Server Configuration ===") + fmt.Printf(" %-12s %s\n", "Interface:", c.Interface) + fmt.Printf(" %-12s %s\n", "Network:", c.Network) + fmt.Printf(" %-12s %s\n", "Port:", c.Port) + fmt.Printf(" %-12s %s\n", "Public IP:", c.PublicIP) + fmt.Printf(" %-12s %s\n", "Public Key:", c.ServerPub) + fmt.Println() + fmt.Println("=== Interface Status ===") + if awgIfaceUp(c.Interface) { + run("awg", "show", c.Interface) + } else { + fmt.Printf(" Interface %s is DOWN\n", c.Interface) + } + fmt.Println() + fmt.Println("=== Service Status ===") + serviceStatus(osInfo, c.Interface) +} + +func showConfig() { + c := loadConfig() + confPath := awgConfPath(c.Interface) + + fmt.Println() + fmt.Printf("=== Profiler Config (%s) ===\n", configFile) + printFilteredConfig(configFile) + fmt.Println() + + if data, err := os.ReadFile(confPath); err == nil { + fmt.Printf("=== AmneziaWG Config (%s) ===\n", confPath) + fmt.Print(hidePrivateKey(string(data))) + } else { + warn("%s not found", confPath) + } +} + +// printFilteredConfig reproduces the bash grep chain: drop lines containing +// PRIVATE / PASSW / KEY, comment lines, and blank lines. +func printFilteredConfig(path string) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "PRIVATE") || + strings.Contains(line, "PASSW") || + strings.Contains(line, "KEY") { + continue + } + if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" { + continue + } + fmt.Println(line) + } +} + +// hidePrivateKey masks the PrivateKey value (bash: sed s/PrivateKey...//). +func hidePrivateKey(content string) string { + lines := strings.Split(content, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "PrivateKey") { + if idx := strings.Index(line, "="); idx >= 0 { + lines[i] = line[:idx+1] + " " + } + } + } + return strings.Join(lines, "\n") +} + +// syncConfig regenerates the interface conf with all ACTIVE peers, optionally +// restarting the service. +func syncConfig(c *Config, osInfo *OSInfo) { + confPath := awgConfPath(c.Interface) + if _, err := os.Stat(confPath); err != nil { + die("%s not found — run init-server first", confPath) + } + + srvIP := serverIP(c.Network) + defIface := defaultRouteIface() + mtu := c.mtuOr("1420") + + info("Regenerating %s with all ACTIVE peers...", confPath) + + clients := loadRegistry() + var b strings.Builder + b.WriteString(awgConfHeader(c, c.ServerPriv, srvIP, c.Port, mtu)) + for _, cl := range clients { + if cl.IsEnabled == "ACTIVE" { + b.WriteString(peerBlock(cl.Name, cl.PublicKey, cl.PSKKey, cl.IP)) + } + } + if err := os.WriteFile(confPath, []byte(b.String()), 0600); err != nil { + die("Failed to write %s: %v", confPath, err) + } + writeNftRules(c.Interface, defIface, mtu) + info("Config written: %s", confPath) + + if confirm("Restart AmneziaWG to apply changes?") { + serviceRestart(osInfo, c.Interface) + info("AmneziaWG restarted") + } +} diff --git a/stats.go b/stats.go new file mode 100644 index 0000000..bbe67c9 --- /dev/null +++ b/stats.go @@ -0,0 +1,252 @@ +package main + +import ( + "encoding/json" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// PeerStat holds the live transfer/handshake counters for a single peer, parsed +// from `awg show dump`. Keys are the peer public keys. +type PeerStat struct { + PublicKey string `json:"public_key"` + Endpoint string `json:"endpoint"` + LatestHandshake int64 `json:"latest_handshake"` // unix seconds, 0 = never + TransferRx int64 `json:"transfer_rx"` // bytes received by server from peer + TransferTx int64 `json:"transfer_tx"` // bytes sent by server to peer + Keepalive string `json:"keepalive"` +} + +// onlineWindow is how recent a handshake must be for a peer to count as online. +// It must stay above AmneziaWG/WireGuard's rekey interval (~120s): a live but +// idle peer only refreshes its handshake on rekey, so a shorter window would +// falsely flip connected peers to offline. 150s is the tightest safe value — +// it makes a genuinely disconnected peer drop within ~30s of its last rekey +// instead of the previous 180s. +const onlineWindow = 150 * time.Second + +// Online reports whether the peer handshaked within onlineWindow. +func (p PeerStat) Online() bool { + if p.LatestHandshake == 0 { + return false + } + return time.Since(time.Unix(p.LatestHandshake, 0)) <= onlineWindow +} + +// peerStats runs `awg show dump` and returns a map keyed by peer public +// key. On any error (interface down, awg missing) it returns an empty map so the +// caller can still render the registry without live data. +// +// Dump line layout for a peer (tab-separated): +// +// public-key preshared-key endpoint allowed-ips latest-handshake rx tx keepalive +// +// The first line describes the interface itself and is skipped. +func peerStats(iface string) map[string]PeerStat { + stats := map[string]PeerStat{} + if iface == "" { + return stats + } + out, err := output("awg", "show", iface, "dump") + if err != nil { + return stats + } + lines := strings.Split(strings.TrimSpace(out), "\n") + for i, line := range lines { + if i == 0 || strings.TrimSpace(line) == "" { + continue // interface header / blank + } + f := strings.Fields(line) + if len(f) < 8 { + continue + } + hs, _ := strconv.ParseInt(f[4], 10, 64) + rx, _ := strconv.ParseInt(f[5], 10, 64) + tx, _ := strconv.ParseInt(f[6], 10, 64) + endpoint := f[2] + if endpoint == "(none)" { + endpoint = "" + } + stats[f[0]] = PeerStat{ + PublicKey: f[0], + Endpoint: endpoint, + LatestHandshake: hs, + TransferRx: rx, + TransferTx: tx, + Keepalive: f[7], + } + } + return stats +} + +// ─── persistent, per-peer cumulative traffic ──────────────────────────────────── +// +// The live PeerStat counters above are volatile: the WG interface resets them on +// every restart, and in the container the userspace amneziawg-go data plane +// restarts together with the app, so a plain read is zeroed after any restart. +// StatRecord keeps a durable running total per peer in awg_stats.json that only +// ever grows, by folding successive live readings in as deltas. + +// StatRecord is the persisted, cumulative traffic tally for one peer. +type StatRecord struct { + PublicKey string `json:"public_key"` + Since int64 `json:"since"` // unix: when accumulation started / was last cleared + UpdatedAt int64 `json:"updated_at"` // unix: last sample that moved the totals + TotalRx int64 `json:"total_rx"` // accumulated bytes received from the peer + TotalTx int64 `json:"total_tx"` // accumulated bytes sent to the peer + LastRx int64 `json:"last_rx"` // last raw counter seen — the delta baseline + LastTx int64 `json:"last_tx"` +} + +// StatsStore is the on-disk shape of awg_stats.json, keyed by peer public key. +type StatsStore struct { + Peers map[string]StatRecord `json:"peers"` +} + +// statsLock serialises every read-modify-write of the stats store. It is +// deliberately independent of opLock (which guards WG-mutating operations): the +// background sampler must never block behind a long install/init operation. +var statsLock sync.Mutex + +// loadStatsLocked reads awg_stats.json. A missing/unreadable file yields an empty +// store; a corrupt file is preserved as awg_stats.json.bad and treated as empty, +// so a parse error can never silently wipe good data. Callers must hold statsLock. +func loadStatsLocked() *StatsStore { + data, err := os.ReadFile(statsFile) + if err != nil { + return &StatsStore{Peers: map[string]StatRecord{}} + } + var s StatsStore + if err := json.Unmarshal(data, &s); err != nil { + warn("Stats file %s is invalid — backing it up as %s.bad and starting fresh", statsFile, statsFile) + _ = os.Rename(statsFile, statsFile+".bad") + return &StatsStore{Peers: map[string]StatRecord{}} + } + if s.Peers == nil { + s.Peers = map[string]StatRecord{} + } + return &s +} + +// saveStatsLocked writes the store atomically (temp file + rename). Stats are +// best-effort telemetry: on any failure it warns and returns rather than dying, +// so a full disk can never crash the server or a CLI operation. Holds statsLock. +func saveStatsLocked(s *StatsStore) { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + warn("Failed to encode stats: %v", err) + return + } + if err := os.MkdirAll(dataDir, 0700); err != nil { + warn("Failed to create data dir for stats: %v", err) + return + } + tmp, err := os.CreateTemp(dataDir, "stats-*.tmp") + if err != nil { + warn("Failed to write stats: %v", err) + return + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + warn("Failed to write stats: %v", err) + return + } + tmp.Close() + if err := os.Rename(tmpName, statsFile); err != nil { + os.Remove(tmpName) + warn("Failed to write stats: %v", err) + } +} + +// accumulate folds a raw counter reading into a running total. A reading that is +// not below the baseline contributes its increase; a reading that dropped means +// the counter was reset (interface/app restart), so the whole current value is +// counted as new traffic. Returns the new total and the new baseline. Because +// every delta is non-negative, a total can only ever grow — a restart (which +// only lowers the live counter) can never reduce the persisted total. +func accumulate(total, last, cur int64) (int64, int64) { + if cur >= last { + return total + (cur - last), cur + } + return total + cur, cur +} + +// snapshotLocked returns a copy of the store's records for lock-free rendering. +func snapshotLocked(s *StatsStore) map[string]StatRecord { + out := make(map[string]StatRecord, len(s.Peers)) + for k, v := range s.Peers { + out[k] = v + } + return out +} + +// sampleStats folds one live reading into the persisted totals and returns a +// snapshot for the caller to render. Passing in an already-fetched live map lets +// callers avoid a second `awg show dump`. An empty live map (interface down / +// dump failed) is a no-op that still returns the current snapshot — crucially it +// never rebaselines or zeroes anything, and peers missing from a non-empty dump +// are left untouched too. +func sampleStats(live map[string]PeerStat) map[string]StatRecord { + statsLock.Lock() + defer statsLock.Unlock() + s := loadStatsLocked() + now := nowUnix() + changed := false + for pk, ps := range live { + rec, ok := s.Peers[pk] + if !ok { + // First sighting: start counting from now, ignoring whatever the raw + // counter already holds (that traffic predates tracking). "since" is + // the mark the user sees. + s.Peers[pk] = StatRecord{ + PublicKey: pk, Since: now, UpdatedAt: now, + LastRx: ps.TransferRx, LastTx: ps.TransferTx, + } + changed = true + continue + } + nrx, brx := accumulate(rec.TotalRx, rec.LastRx, ps.TransferRx) + ntx, btx := accumulate(rec.TotalTx, rec.LastTx, ps.TransferTx) + if nrx != rec.TotalRx || ntx != rec.TotalTx || brx != rec.LastRx || btx != rec.LastTx { + rec.TotalRx, rec.LastRx = nrx, brx + rec.TotalTx, rec.LastTx = ntx, btx + rec.UpdatedAt = now + s.Peers[pk] = rec + changed = true + } + } + if changed { + saveStatsLocked(s) + } + return snapshotLocked(s) +} + +// resetStats clears a peer's accumulated totals and rebaselines to the current +// live counter, so subsequent samples count only traffic from now on. "since" is +// set to now — the fresh mark from which stats accumulate again. +func resetStats(pubkey string, curRx, curTx int64) StatRecord { + statsLock.Lock() + defer statsLock.Unlock() + s := loadStatsLocked() + now := nowUnix() + rec := StatRecord{PublicKey: pubkey, Since: now, UpdatedAt: now, LastRx: curRx, LastTx: curTx} + s.Peers[pubkey] = rec + saveStatsLocked(s) + return rec +} + +// deleteStats drops a peer's record (called when a client is deleted). +func deleteStats(pubkey string) { + statsLock.Lock() + defer statsLock.Unlock() + s := loadStatsLocked() + if _, ok := s.Peers[pubkey]; ok { + delete(s.Peers, pubkey) + saveStatsLocked(s) + } +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..48a2711 --- /dev/null +++ b/util.go @@ -0,0 +1,376 @@ +package main + +import ( + "bufio" + "context" + crand "crypto/rand" + "encoding/binary" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +func itoa(n int) string { return strconv.Itoa(n) } +func itoa64(n int64) string { return strconv.FormatInt(n, 10) } + +// atoiOrDie parses a base-10 integer, aborting on malformed input. +func atoiOrDie(s string) int { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + die("expected integer, got %q", s) + } + return n +} + +// ─── logging helpers (mirror the bash die/info/warn) ──────────────────────────── + +// webMode makes die() panic instead of exiting the process, so the long-running +// web server can recover from an operation failure and turn it into an HTTP +// error response rather than crashing. It is set only by the `web` command. +var webMode bool + +// dieError carries a die() message across a recover() in web mode. +type dieError struct{ msg string } + +func (e dieError) Error() string { return e.msg } + +// die prints an error to stderr and terminates with status 1. In web mode it +// panics with a dieError instead, to be recovered by the HTTP handler wrapper. +func die(format string, args ...any) { + if webMode { + panic(dieError{fmt.Sprintf(format, args...)}) + } + fmt.Fprintf(os.Stderr, "ERROR: "+format+"\n", args...) + os.Exit(1) +} + +func info(format string, args ...any) { + fmt.Printf("INFO: "+format+"\n", args...) +} + +func warn(format string, args ...any) { + fmt.Fprintf(os.Stderr, "WARN: "+format+"\n", args...) +} + +// requireBinary aborts unless the named executable is on PATH. +func requireBinary(name string) { + if _, err := exec.LookPath(name); err != nil { + die("%s not installed", name) + } +} + +// ─── interactive input ────────────────────────────────────────────────────────── + +var stdinReader = bufio.NewReader(os.Stdin) + +// confirm asks a yes/no question; returns true only for a bare y/Y. +func confirm(prompt string) bool { + fmt.Printf("%s [y/N] ", prompt) + answer, _ := stdinReader.ReadString('\n') + answer = strings.TrimSpace(answer) + return answer == "y" || answer == "Y" +} + +// promptDefault reads a value, falling back to def when the user hits enter. +func promptDefault(prompt, def string) string { + fmt.Printf("%s [%s]: ", prompt, def) + value, _ := stdinReader.ReadString('\n') + value = strings.TrimRight(value, "\r\n") + if value == "" { + return def + } + return value +} + +// ─── randomness (crypto/rand, replaces od < /dev/urandom) ─────────────────────── + +func randUint32() uint32 { + var b [4]byte + if _, err := crand.Read(b[:]); err != nil { + die("failed to read random bytes: %v", err) + } + return binary.BigEndian.Uint32(b[:]) +} + +// randMagic returns a random unsigned 32-bit integer in [5, 2^31-1]. +func randMagic() int64 { + const span = 2147483647 - 5 + 1 // inclusive [5, 2^31-1] + n := uint64(randUint32()) + return int64(n%span) + 5 +} + +// randRange returns a uniform integer in the inclusive range [min, max]. +// If max < min it warns and returns min so the range stays sane. +func randRange(min, max int) int { + if max < min { + warn("Randomisation range [%d,%d] invalid (MTU too small?) — using %d", min, max, min) + return min + } + span := uint64(max - min + 1) + n := uint64(randUint32()) + return int(n%span) + min +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// ─── name sanitisation ────────────────────────────────────────────────────────── + +var sanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func sanitize(v string) string { + return sanitizeRe.ReplaceAllString(v, "_") +} + +// ─── IP helpers ───────────────────────────────────────────────────────────────── + +// networkBase strips the /CIDR suffix, returning the bare address portion. +func networkBase(network string) string { + if i := strings.Index(network, "/"); i >= 0 { + return network[:i] + } + return network +} + +// requireSlash24 validates that network is an IPv4 CIDR with a /24 prefix. +// serverIP/getFirstClientIP/incrementIP only ever vary the last octet, and +// every written config hardcodes "Address = /24" regardless of what was +// typed in — so any other prefix would silently produce a broken, internally +// inconsistent config rather than the network the operator actually asked for. +func requireSlash24(network string) { + ip, ipnet, err := net.ParseCIDR(network) + if err != nil || ip.To4() == nil { + die("Invalid network %q: expected an IPv4 CIDR, e.g. 10.0.0.0/24", network) + } + ones, bits := ipnet.Mask.Size() + if bits != 32 || ones != 24 { + die("Network %q must be a /24 (only /24 subnets are supported) — e.g. 10.0.0.0/24", network) + } + if ipnet.IP.String() != networkBase(network) { + die("Network %q is not a valid /24 base address — did you mean %s/24?", network, ipnet.IP.String()) + } +} + +// getFirstClientIP derives the .2 host of the configured network. +func getFirstClientIP(network string) string { + base := networkBase(network) + parts := strings.Split(base, ".") + if len(parts) != 4 { + die("Invalid network: %s", network) + } + return fmt.Sprintf("%s.%s.%s.2", parts[0], parts[1], parts[2]) +} + +// incrementIP returns the next host IP; if current is empty it seeds from .2. +func incrementIP(current, network string) string { + if current == "" { + return getFirstClientIP(network) + } + parts := strings.Split(current, ".") + if len(parts) != 4 { + die("Invalid IP: %s", current) + } + o4 := atoiOrDie(parts[3]) + o4++ + if o4 >= 255 { + die("IP pool exhausted") + } + return fmt.Sprintf("%s.%s.%s.%d", parts[0], parts[1], parts[2], o4) +} + +// ─── command execution ────────────────────────────────────────────────────────── + +// run executes a command with inherited stdio and returns its error. +func run(name string, args ...string) error { + return runEnv(nil, name, args...) +} + +func runEnv(extraEnv []string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if extraEnv != nil { + cmd.Env = append(os.Environ(), extraEnv...) + } + return cmd.Run() +} + +// runOrDie runs a command and aborts if it fails. +func runOrDie(name string, args ...string) { + if err := run(name, args...); err != nil { + die("%s failed: %v", name, err) + } +} + +// runOrDieIn runs a command with its working directory set to dir (e.g. a +// cloned source tree) and aborts if it fails. +func runOrDieIn(dir, name string, args ...string) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + die("%s failed: %v", name, err) + } +} + +// enableIPForwarding turns on IPv4 forwarding and persists it. It is tolerant +// of container environments: if `sysctl -w` fails but forwarding is already on +// (e.g. set at namespace creation via `--sysctl`/compose, with /proc/sys mounted +// read-only) it warns and continues instead of aborting. It only dies if +// forwarding is genuinely off and cannot be enabled. +func enableIPForwarding() { + if err := run("sysctl", "-w", "net.ipv4.ip_forward=1"); err != nil { + if ipForwardingEnabled() { + warn("Could not write net.ipv4.ip_forward (%v), but it is already enabled — continuing", err) + } else { + die("Failed to enable IP forwarding: %v (set it on the host, e.g. --sysctl net.ipv4.ip_forward=1)", err) + } + } + // Best-effort persistence; /etc may be read-only in some containers. + if err := os.WriteFile("/etc/sysctl.d/99-amneziawg.conf", []byte("net.ipv4.ip_forward=1\n"), 0644); err != nil { + warn("Could not persist sysctl config: %v", err) + } + run("sysctl", "-p", "/etc/sysctl.d/99-amneziawg.conf") +} + +// ipForwardingEnabled reads the live kernel flag directly. +func ipForwardingEnabled() bool { + data, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward") + return err == nil && strings.TrimSpace(string(data)) == "1" +} + +// output runs a command and returns its trimmed stdout. +func output(name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + return strings.TrimSpace(string(out)), err +} + +// outputWithInput runs a command feeding stdin, returning trimmed stdout. +func outputWithInput(stdin, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Stdin = strings.NewReader(stdin) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + return strings.TrimSpace(string(out)), err +} + +// silent reports whether a command succeeds, discarding all its output. +func silent(name string, args ...string) bool { + cmd := exec.Command(name, args...) + return cmd.Run() == nil +} + +// ─── network detection ────────────────────────────────────────────────────────── + +// defaultRouteIface parses `ip route` for the default outbound interface, +// mirroring `ip route | awk '/default/ {print $5; exit}'`. +func defaultRouteIface() string { + out, err := output("ip", "route") + if err != nil { + return "" + } + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "default") { + fields := strings.Fields(line) + if len(fields) >= 5 { + return fields[4] + } + } + } + return "" +} + +// detectPublicIP mimics `curl -sf4 https://ifconfig.me` (IPv4 only), falling +// back to the placeholder used by the original script. +// +// It queries several plaintext "what is my IP" services in turn and validates +// each response with net.ParseIP. Validation is essential: services such as +// ifconfig.me serve a full HTML landing page (not the bare IP) to clients that +// don't send a curl-like User-Agent, so without the parse check the config +// would end up storing an HTML fragment instead of an address. +func detectPublicIP() string { + dialer := &net.Dialer{Timeout: 5 * time.Second} + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // Force IPv4 so the detected address matches the udp4 listener. + return dialer.DialContext(ctx, "tcp4", addr) + }, + } + client := &http.Client{Timeout: 8 * time.Second, Transport: transport} + + // Plaintext IPv4 endpoints, tried in order until one yields a valid IP. + for _, url := range []string{ + "https://ifconfig.me/ip", + "https://api.ipify.org", + "https://icanhazip.com", + } { + if ip := fetchIPv4(client, url); ip != "" { + return ip + } + } + return "YOUR_SERVER_IP" +} + +// fetchIPv4 requests url and returns the trimmed body only if it is a valid +// IPv4 address; otherwise it returns "". A curl-like User-Agent is sent so +// services that content-negotiate (e.g. ifconfig.me) reply with the bare IP. +func fetchIPv4(client *http.Client, url string) string { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "" + } + req.Header.Set("User-Agent", "curl/8.0.0") + req.Header.Set("Accept", "text/plain") + + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "" + } + // A valid IPv4 string is at most 15 bytes; cap the read to reject any + // unexpectedly large (e.g. HTML) response early. + body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) + if err != nil { + return "" + } + ip := strings.TrimSpace(string(body)) + if parsed := net.ParseIP(ip); parsed == nil || parsed.To4() == nil { + return "" + } + return ip +} + +// unameRelease returns the running kernel release (`uname -r`). +func unameRelease() string { + out, err := output("uname", "-r") + if err != nil { + die("uname -r failed: %v", err) + } + return out +} diff --git a/web.go b/web.go new file mode 100644 index 0000000..0f17166 --- /dev/null +++ b/web.go @@ -0,0 +1,583 @@ +package main + +import ( + "crypto/subtle" + "embed" + "encoding/json" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +//go:embed webui/* webui_glass/* +var webAssets embed.FS + +// webThemes maps a user-facing theme name to its embedded asset directory. Both +// designs share the same REST API and JS logic; only the static shell differs, +// so a theme is just a different sub-directory of the embedded FS. +var webThemes = map[string]string{ + "classic": "webui", // original Modern Dark design (default) + "glass": "webui_glass", // glassmorphism · Slate + Amber +} + +// webModes are the accepted colour modes. "dark" (default) forces the dark +// palette; "light" forces light; "auto" follows the viewer's OS preference. +// The mode is stamped onto at serve time — see +// injectUIMode. glass is a dark-only design and ignores light/auto. +var webModes = map[string]bool{"dark": true, "light": true, "auto": true} + +// injectUIMode stamps data-theme="" onto the tag of the served +// index.html so the stylesheet resolves the palette before first paint. It +// keys off the ` AWG_WEB_THEME env > "classic" default. + theme := os.Getenv("AWG_WEB_THEME") + if theme == "" { + theme = "classic" + } + // Colour-mode precedence: --ui-mode flag > AWG_WEB_MODE env > "dark" default. + // "dark" forces the dark palette regardless of the viewer's OS preference. + uiMode := os.Getenv("AWG_WEB_MODE") + if uiMode == "" { + uiMode = "dark" + } + for i := 0; i < len(args); i++ { + switch args[i] { + case "-addr", "--addr": + if i+1 >= len(args) { + die("--addr requires a value (e.g. 0.0.0.0:8080)") + } + addr = args[i+1] + i++ + case "-theme", "--theme": + if i+1 >= len(args) { + die("--theme requires a value (classic or glass)") + } + theme = args[i+1] + i++ + case "-ui-mode", "--ui-mode": + if i+1 >= len(args) { + die("--ui-mode requires a value (dark, light or auto)") + } + uiMode = args[i+1] + i++ + default: + die("Unknown web option: %s", args[i]) + } + } + + assetDir, ok := webThemes[theme] + if !ok { + die("Unknown theme %q (choose: classic, glass)", theme) + } + if !webModes[uiMode] { + die("Unknown ui-mode %q (choose: dark, light, auto)", uiMode) + } + + webMode = true + + // Accumulate traffic totals in the background so counters keep folding into + // the durable store even when no browser is polling. + go statsSampler() + + user := os.Getenv("AWG_WEB_USER") + pass := os.Getenv("AWG_WEB_PASS") + authOn := user != "" && pass != "" + + mux := http.NewServeMux() + registerAPI(mux) + + // Static SPA assets, served from the selected theme's embedded directory. + sub, err := fs.Sub(webAssets, assetDir) + if err != nil { + die("failed to open embedded assets: %v", err) + } + // index.html is served with the colour mode stamped onto so the CSS + // applies it before first paint (no flash, no client JS). All other assets + // go through the plain file server. + rawIndex, err := fs.ReadFile(sub, "index.html") + if err != nil { + die("failed to read embedded index.html: %v", err) + } + indexHTML := injectUIMode(rawIndex, uiMode) + fileSrv := http.FileServer(http.FS(sub)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(indexHTML) + return + } + fileSrv.ServeHTTP(w, r) + }) + + handler := basicAuth(authOn, user, pass, mux) + + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 15 * time.Second, + // No write timeout: install-deps / init-server can run for minutes. + } + + fmt.Printf("INFO: AmneziaWG web UI listening on http://%s (theme: %s, ui-mode: %s)\n", addr, theme, uiMode) + if authOn { + fmt.Println("INFO: HTTP Basic auth enabled (AWG_WEB_USER/AWG_WEB_PASS)") + } else { + fmt.Println("WARN: no auth set — bind to 127.0.0.1 or set AWG_WEB_USER/AWG_WEB_PASS") + } + if err := srv.ListenAndServe(); err != nil { + die("web server error: %v", err) + } +} + +// statsSampler periodically folds live counters into the persisted totals so +// accumulation continues with no browser open and counter resets are caught +// promptly. Each tick is isolated: a panic (e.g. a die() from loadConfig in web +// mode) is recovered so the background loop can never crash the server. +func statsSampler() { + for range time.Tick(20 * time.Second) { + func() { + defer func() { _ = recover() }() + if !serverInitialized() { + return + } + c := loadConfig() + sampleStats(peerStats(c.Interface)) + }() + } +} + +// basicAuth optionally guards every request with HTTP Basic credentials. +func basicAuth(on bool, user, pass string, next http.Handler) http.Handler { + if !on { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 + passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1 + if !ok || !userOK || !passOK { + w.Header().Set("WWW-Authenticate", `Basic realm="awg-profiler"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// ─── routing ───────────────────────────────────────────────────────────────────── + +func registerAPI(mux *http.ServeMux) { + mux.HandleFunc("GET /api/setup-status", h(handleSetupStatus)) + mux.HandleFunc("GET /api/status", h(handleStatus)) + mux.HandleFunc("GET /api/clients", h(handleClients)) + mux.HandleFunc("POST /api/clients", h(handleCreate)) + mux.HandleFunc("DELETE /api/clients/{id}", h(handleDelete)) + mux.HandleFunc("POST /api/clients/{id}/enable", h(handleEnable)) + mux.HandleFunc("POST /api/clients/{id}/disable", h(handleDisable)) + mux.HandleFunc("POST /api/clients/{id}/comment", h(handleComment)) + mux.HandleFunc("POST /api/clients/{id}/stats/reset", h(handleStatsReset)) + mux.HandleFunc("GET /api/clients/{id}/config", h(handleConfigDownload)) + mux.HandleFunc("GET /api/clients/{id}/qr", h(handleQR)) + mux.HandleFunc("POST /api/server/start", h(handleServerStart)) + mux.HandleFunc("POST /api/server/stop", h(handleServerStop)) + mux.HandleFunc("POST /api/server/restart", h(handleServerRestart)) + mux.HandleFunc("POST /api/server/sync", h(handleSync)) + mux.HandleFunc("POST /api/install-deps", h(handleInstallDeps)) + mux.HandleFunc("POST /api/init-server", h(handleInitServer)) +} + +// h wraps an API handler with CSRF hardening and panic recovery (turning +// die() into a JSON error response). +func h(fn func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !csrfSafe(r) { + writeErr(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json") + return + } + defer func() { + if rec := recover(); rec != nil { + msg := fmt.Sprintf("%v", rec) + if de, ok := rec.(dieError); ok { + msg = de.msg + } + writeErr(w, http.StatusBadRequest, msg) + } + }() + fn(w, r) + } +} + +// csrfSafe blocks cross-site form-triggered state changes. Basic-auth +// credentials are attached to same-origin requests automatically by the +// browser, so without this check a malicious page could submit a blind +// POST/DELETE (forms can only send text/plain, +// application/x-www-form-urlencoded, or multipart/form-data — never +// application/json) and trigger e.g. server-stop or client deletion under +// the logged-in admin's session. GET requests are read-only and exempt. +func csrfSafe(r *http.Request) bool { + if r.Method != http.MethodPost && r.Method != http.MethodDelete { + return true + } + return strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") +} + +// ─── response helpers ────────────────────────────────────────────────────────── + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +// serverInitialized reports whether init-server has produced a config, without +// dying (used to decide between the dashboard and the setup wizard). +func serverInitialized() bool { + _, err := os.Stat(configFile) + return err == nil +} + +// ─── handlers: status / setup ─────────────────────────────────────────────────── + +func handleSetupStatus(w http.ResponseWriter, r *http.Request) { + st := loadState() + resp := map[string]any{ + "deps_installed": st.DepsInstalled, + "deps_installed_at": st.InstalledAt, + "server_initialized": serverInitialized(), + } + writeJSON(w, http.StatusOK, resp) +} + +func handleStatus(w http.ResponseWriter, r *http.Request) { + if !serverInitialized() { + writeJSON(w, http.StatusOK, map[string]any{"initialized": false}) + return + } + c := loadConfig() + up := awgIfaceUp(c.Interface) + + clients := loadRegistry() + active := 0 + for _, cl := range clients { + if cl.IsEnabled == "ACTIVE" { + active++ + } + } + stats := peerStats(c.Interface) + sampleStats(stats) // keep totals accumulating even on status-only polls + online := 0 + for _, s := range stats { + if s.Online() { + online++ + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "initialized": true, + "interface": c.Interface, + "network": c.Network, + "port": c.Port, + "public_ip": c.PublicIP, + "public_key": c.ServerPub, + "dns": c.DNS, + "mtu": c.MTU, + "interface_up": up, + "total_clients": len(clients), + "active_clients": active, + "online_clients": online, + }) +} + +// ─── handlers: clients ────────────────────────────────────────────────────────── + +// clientOut is the browser-facing client shape. It deliberately omits the +// private and preshared keys — those only ever leave via the .conf download. +type clientOut struct { + ID int64 `json:"id"` + Name string `json:"name"` + IP string `json:"ip"` + PublicKey string `json:"public_key"` + IsEnabled string `json:"is_enabled"` + CreatedAt int64 `json:"created_at"` + Comment string `json:"comment"` + Online bool `json:"online"` + Endpoint string `json:"endpoint"` + LatestHandshake int64 `json:"latest_handshake"` + TransferRx int64 `json:"transfer_rx"` + TransferTx int64 `json:"transfer_tx"` + // Cumulative, restart-surviving traffic accumulated from the stats store, + // plus the mark from which it has been counting. These are what the UI shows. + TotalRx int64 `json:"total_rx"` + TotalTx int64 `json:"total_tx"` + StatsSince int64 `json:"stats_since"` +} + +func handleClients(w http.ResponseWriter, r *http.Request) { + c := loadConfig() + initStorage() + clients := loadRegistry() + stats := peerStats(c.Interface) + // Fold this live reading into the persisted totals and get the fresh snapshot. + totals := sampleStats(stats) + + out := make([]clientOut, 0, len(clients)) + for _, cl := range clients { + o := clientOut{ + ID: cl.ID, + Name: cl.Name, + IP: cl.IP, + PublicKey: cl.PublicKey, + IsEnabled: cl.IsEnabled, + CreatedAt: cl.CreatedAt, + Comment: cl.Comment, + } + if s, ok := stats[cl.PublicKey]; ok { + o.Online = s.Online() + o.Endpoint = s.Endpoint + o.LatestHandshake = s.LatestHandshake + o.TransferRx = s.TransferRx + o.TransferTx = s.TransferTx + } + if rec, ok := totals[cl.PublicKey]; ok { + o.TotalRx = rec.TotalRx + o.TotalTx = rec.TotalTx + o.StatsSince = rec.Since + } + out = append(out, o) + } + writeJSON(w, http.StatusOK, out) +} + +func handleCreate(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + writeErr(w, http.StatusBadRequest, "client name required") + return + } + + opLock.Lock() + defer opLock.Unlock() + + requireBinary("awg") + requireBinary("qrencode") + c := loadConfig() + initStorage() + createClient(c, detectOS(), []string{name}) + + // Return the freshly created client (highest id with this sanitized name). + clients := loadRegistry() + sanitized := sanitize(name) + var created *Client + for i := range clients { + if clients[i].Name == sanitized { + created = &clients[i] + } + } + if created == nil { + writeErr(w, http.StatusInternalServerError, "client created but not found in registry") + return + } + writeJSON(w, http.StatusCreated, clientOut{ + ID: created.ID, Name: created.Name, IP: created.IP, + PublicKey: created.PublicKey, IsEnabled: created.IsEnabled, + CreatedAt: created.CreatedAt, Comment: created.Comment, + }) +} + +func handleComment(w http.ResponseWriter, r *http.Request) { + var body struct { + Comment string `json:"comment"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + opLock.Lock() + defer opLock.Unlock() + loadConfig() + initStorage() + id := parseID([]string{r.PathValue("id")}) + cl := setClientComment(id, strings.TrimSpace(body.Comment)) + writeJSON(w, http.StatusOK, map[string]any{"status": "updated", "comment": cl.Comment}) +} + +// handleStatsReset clears a client's accumulated traffic and rebaselines it to +// the current live counter, so the tally restarts from zero as of now. +func handleStatsReset(w http.ResponseWriter, r *http.Request) { + c := loadConfig() + cl := clientByID(r.PathValue("id")) // dies (→ 400/404) if the id is unknown + // Baseline to the current raw counter so we don't re-add pre-reset bytes. If + // the peer is absent from the dump the interface is down (counters restart + // near zero on the way back up), so a zero baseline is correct. + live := peerStats(c.Interface)[cl.PublicKey] + rec := resetStats(cl.PublicKey, live.TransferRx, live.TransferTx) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "reset", + "total_rx": rec.TotalRx, + "total_tx": rec.TotalTx, + "stats_since": rec.Since, + }) +} + +func handleDelete(w http.ResponseWriter, r *http.Request) { + opLock.Lock() + defer opLock.Unlock() + c := loadConfig() + initStorage() + deleteClient(c, detectOS(), []string{r.PathValue("id")}) + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +func handleEnable(w http.ResponseWriter, r *http.Request) { + opLock.Lock() + defer opLock.Unlock() + c := loadConfig() + initStorage() + enableClient(c, detectOS(), []string{r.PathValue("id")}) + writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"}) +} + +func handleDisable(w http.ResponseWriter, r *http.Request) { + opLock.Lock() + defer opLock.Unlock() + c := loadConfig() + initStorage() + disableClient(c, detectOS(), []string{r.PathValue("id")}) + writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"}) +} + +// clientByID looks up a client for download endpoints. +func clientByID(idStr string) *Client { + id := parseID([]string{idStr}) + clients := loadRegistry() + cl := findClient(clients, id) + if cl == nil { + die("Client with ID %d not found", id) + } + return cl +} + +func handleConfigDownload(w http.ResponseWriter, r *http.Request) { + cl := clientByID(r.PathValue("id")) + path := filepath.Join(clientDir, cl.Name+".conf") + data, err := os.ReadFile(path) + if err != nil { + die("config file for %s not found", cl.Name) + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.conf"`, cl.Name)) + _, _ = w.Write(data) +} + +func handleQR(w http.ResponseWriter, r *http.Request) { + cl := clientByID(r.PathValue("id")) + path := filepath.Join(clientDir, cl.Name+".png") + data, err := os.ReadFile(path) + if err != nil { + die("QR image for %s not found", cl.Name) + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(data) +} + +// ─── handlers: server control ─────────────────────────────────────────────────── + +func handleServerStart(w http.ResponseWriter, r *http.Request) { + opLock.Lock() + defer opLock.Unlock() + c := loadConfig() + serviceStart(detectOS(), c.Interface) + writeJSON(w, http.StatusOK, map[string]string{"status": "started"}) +} + +func handleServerStop(w http.ResponseWriter, r *http.Request) { + opLock.Lock() + defer opLock.Unlock() + c := loadConfig() + serviceStop(detectOS(), c.Interface) + writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"}) +} + +func handleServerRestart(w http.ResponseWriter, r *http.Request) { + opLock.Lock() + defer opLock.Unlock() + c := loadConfig() + serviceRestart(detectOS(), c.Interface) + writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"}) +} + +func handleSync(w http.ResponseWriter, r *http.Request) { + var body struct { + Restart bool `json:"restart"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + + opLock.Lock() + defer opLock.Unlock() + c := loadConfig() + syncConfigWeb(c, detectOS(), body.Restart) + writeJSON(w, http.StatusOK, map[string]any{"status": "synced", "restarted": body.Restart}) +} + +// ─── handlers: setup wizard ───────────────────────────────────────────────────── + +func handleInstallDeps(w http.ResponseWriter, r *http.Request) { + opLock.Lock() + defer opLock.Unlock() + installDeps() // logs stream to the server console; panics (recovered) on failure + writeJSON(w, http.StatusOK, map[string]string{"status": "installed"}) +} + +func handleInitServer(w http.ResponseWriter, r *http.Request) { + var p initParams + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return + } + opLock.Lock() + defer opLock.Unlock() + if serverInitialized() { + writeErr(w, http.StatusConflict, "server already initialized") + return + } + c := initServerWeb(p) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "initialized", + "interface": c.Interface, + "public_key": c.ServerPub, + }) +} diff --git a/webops.go b/webops.go new file mode 100644 index 0000000..6c65795 --- /dev/null +++ b/webops.go @@ -0,0 +1,152 @@ +package main + +import ( + "os" + "strings" +) + +// initParams carries the init-server form fields from the web wizard. Empty +// fields fall back to the same defaults the interactive CLI prompt offers. +type initParams struct { + Interface string `json:"interface"` + Network string `json:"network"` + Port string `json:"port"` + PublicIP string `json:"public_ip"` + DNS string `json:"dns"` + MTU string `json:"mtu"` +} + +// initServerWeb is the non-interactive twin of initServer used by the web +// wizard. It performs the same steps — verify deps flag, generate keys and +// obfuscation parameters, write configs, enable forwarding, start the service — +// but takes its inputs from a form instead of stdin prompts. +func initServerWeb(p initParams) *Config { + osInfo := detectOS() + + st := loadState() + if !st.DepsInstalled { + die("Dependencies not installed — run install-deps first") + } + requireBinary("awg") + + c := &Config{} + c.Interface = valueOr(strings.TrimSpace(p.Interface), "awg0") + c.Network = valueOr(strings.TrimSpace(p.Network), "10.0.0.0/24") + requireSlash24(c.Network) + c.Port = valueOr(strings.TrimSpace(p.Port), "51820") + c.PublicIP = strings.TrimSpace(p.PublicIP) + if c.PublicIP == "" { + c.PublicIP = detectPublicIP() + } + c.DNS = valueOr(strings.TrimSpace(p.DNS), "1.1.1.1") + c.MTU = valueOr(strings.TrimSpace(p.MTU), "1420") + + mtu := atoiOrDie(c.MTU) + + // Obfuscation parameters — same ceilings/constraints as initServer: + // Jmin < Jmax <= MTU ; S1 <= MTU-148 ; S2 <= MTU-92 ; S1+56 != S2. + mtuJunkCeil := mtu + mtuS1Ceil := mtu - 148 + mtuS2Ceil := mtu - 92 + + rndJc := randRange(4, 12) + rndJmin := randRange(8, minInt(mtuJunkCeil-2, 32)) + rndJmax := randRange(maxInt(rndJmin+32, 80), minInt(mtuJunkCeil, 200)) + rndS1 := randRange(15, minInt(mtuS1Ceil, 150)) + rndS2 := randRange(15, minInt(mtuS2Ceil, 150)) + for rndS1+56 == rndS2 { + rndS2 = randRange(15, minInt(mtuS2Ceil, 150)) + } + + c.Jc = itoa(rndJc) + c.Jmin = itoa(rndJmin) + c.Jmax = itoa(rndJmax) + c.S1 = itoa(rndS1) + c.S2 = itoa(rndS2) + + // Four distinct magic-header values in [5, 2^31-1]. + h1 := randMagic() + h2 := randMagic() + for h2 == h1 { + h2 = randMagic() + } + h3 := randMagic() + for h3 == h1 || h3 == h2 { + h3 = randMagic() + } + h4 := randMagic() + for h4 == h1 || h4 == h2 || h4 == h3 { + h4 = randMagic() + } + c.H1 = itoa64(h1) + c.H2 = itoa64(h2) + c.H3 = itoa64(h3) + c.H4 = itoa64(h4) + + // Server keys. + priv, err := output("awg", "genkey") + if err != nil { + die("awg genkey failed: %v", err) + } + pub, err := outputWithInput(priv, "awg", "pubkey") + if err != nil { + die("awg pubkey failed: %v", err) + } + c.ServerPriv = priv + c.ServerPub = pub + + srvIP := serverIP(c.Network) + defIface := defaultRouteIface() + + writeConfig(c) + + confPath := awgConfPath(c.Interface) + if err := os.MkdirAll(awgConfDir, 0755); err != nil { + die("Failed to create %s: %v", awgConfDir, err) + } + if err := os.WriteFile(confPath, []byte(awgConfHeader(c, c.ServerPriv, srvIP, c.Port, c.MTU)), 0600); err != nil { + die("Failed to write %s: %v", confPath, err) + } + writeNftRules(c.Interface, defIface, c.MTU) + + // IP forwarding (persistent). + enableIPForwarding() + + serviceEnable(osInfo, c.Interface) + serviceStart(osInfo, c.Interface) + + initStorage() + info("Server initialised via web: interface=%s pubkey=%s", c.Interface, c.ServerPub) + return c +} + +// syncConfigWeb is the non-interactive twin of syncConfig: it rebuilds the +// interface conf from the ACTIVE peers in the registry and optionally restarts +// the service, without the CLI's stdin confirmation prompt. +func syncConfigWeb(c *Config, osInfo *OSInfo, restart bool) { + confPath := awgConfPath(c.Interface) + if _, err := os.Stat(confPath); err != nil { + die("%s not found — run init-server first", confPath) + } + + srvIP := serverIP(c.Network) + defIface := defaultRouteIface() + mtu := c.mtuOr("1420") + + clients := loadRegistry() + var b strings.Builder + b.WriteString(awgConfHeader(c, c.ServerPriv, srvIP, c.Port, mtu)) + for _, cl := range clients { + if cl.IsEnabled == "ACTIVE" { + b.WriteString(peerBlock(cl.Name, cl.PublicKey, cl.PSKKey, cl.IP)) + } + } + if err := os.WriteFile(confPath, []byte(b.String()), 0600); err != nil { + die("Failed to write %s: %v", confPath, err) + } + writeNftRules(c.Interface, defIface, mtu) + + if restart { + serviceRestart(osInfo, c.Interface) + } +} diff --git a/webui/app.js b/webui/app.js new file mode 100644 index 0000000..c2d3885 --- /dev/null +++ b/webui/app.js @@ -0,0 +1,591 @@ +"use strict"; + +// ─── tiny helpers ────────────────────────────────────────────────────────────── +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +async function api(method, path, body) { + const opts = { method, headers: {} }; + // Server requires application/json on every POST/DELETE (CSRF hardening: + // HTML forms can never set this content type), so set it even when there + // is no body to send. + if (method === "POST" || method === "DELETE") { + opts.headers["Content-Type"] = "application/json"; + } + if (body !== undefined) { + opts.body = JSON.stringify(body); + } + const res = await fetch(path, opts); + const ct = res.headers.get("content-type") || ""; + const data = ct.includes("application/json") ? await res.json() : await res.text(); + if (!res.ok) { + const msg = data && data.error ? data.error : (typeof data === "string" ? data : "Request failed"); + throw new Error(msg); + } + return data; +} + +let toastTimer; +function toast(msg, kind) { + const t = $("#toast"); + t.textContent = msg; + t.className = "toast" + (kind ? " " + kind : ""); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.add("hidden"), 3200); +} + +function fmtBytes(n) { + n = Number(n) || 0; + if (n < 1024) return n + " B"; + const u = ["KB", "MB", "GB", "TB"]; + let i = -1; + do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1); + return n.toFixed(n < 10 ? 1 : 0) + " " + u[i]; +} + +function fmtAgo(ts) { + if (!ts) return "never"; + const s = Math.floor(Date.now() / 1000) - ts; + if (s < 0) return "just now"; + if (s < 60) return s + "s ago"; + if (s < 3600) return Math.floor(s / 60) + "m ago"; + if (s < 86400) return Math.floor(s / 3600) + "h ago"; + return Math.floor(s / 86400) + "d ago"; +} + +function fmtDate(ts) { + if (!ts) return "—"; + const d = new Date(ts * 1000); + return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +// ─── state ─────────────────────────────────────────────────────────────────────── +let clientsCache = []; +let clientFilter = ""; +let refreshTimer; +let clientRows = new Map(); // id -> row (keyed DOM reconciliation, see paintClients) +let hideDisabled = false; +let hideOffline = false; + +// ─── initial load / routing ──────────────────────────────────────────────────── +async function boot() { + try { + const setup = await api("GET", "/api/setup-status"); + $("#loading").classList.add("hidden"); + if (!setup.server_initialized) { + showSetup(setup); + } else { + showDashboard(); + } + } catch (e) { + $("#loading").textContent = "Error: " + e.message; + } +} + +function showView(id) { + $$(".view").forEach((v) => v.classList.add("hidden")); + $(id).classList.remove("hidden"); +} + +// ─── setup wizard ────────────────────────────────────────────────────────────── +function showSetup(setup) { + showView("#setup"); + const badge = $("#depsBadge"); + if (setup.deps_installed) { + badge.textContent = "Installed"; + badge.className = "badge ok"; + $("#installDepsBtn").disabled = true; + } else { + badge.textContent = "Not installed"; + badge.className = "badge no"; + } +} + +$("#installDepsBtn").addEventListener("click", async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + btn.innerHTML = ' Installing… (may take minutes)'; + try { + await api("POST", "/api/install-deps"); + toast("Dependencies installed", "ok"); + boot(); + } catch (err) { + toast(err.message, "err"); + btn.disabled = false; + btn.textContent = "Install dependencies"; + } +}); + +$("#initForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const btn = e.target.querySelector('button[type=submit]'); + const fd = new FormData(e.target); + const body = {}; + for (const [k, v] of fd.entries()) body[k] = v.trim(); + btn.disabled = true; + btn.innerHTML = ' Initialising…'; + try { + await api("POST", "/api/init-server", body); + toast("Server initialised", "ok"); + boot(); + } catch (err) { + toast(err.message, "err"); + btn.disabled = false; + btn.textContent = "Initialise server"; + } +}); + +// ─── dashboard ───────────────────────────────────────────────────────────────── +async function showDashboard() { + showView("#dashboard"); + paintSkeleton(); + await refreshAll(); + clearInterval(refreshTimer); + refreshTimer = setInterval(refreshAll, 10000); +} + +// paintSkeleton shows shimmer placeholders until the first data arrives, so the +// dashboard never flashes empty "–" cells (Feedback: loading states). +function paintSkeleton() { + const list = $("#clientList"); + list.innerHTML = ""; + for (let n = 0; n < 3; n++) { + const row = document.createElement("div"); + row.className = "client skeleton"; + row.innerHTML = + '' + + '
' + + ''; + list.appendChild(row); + } +} + +let refreshing = false; +async function refreshAll() { + if (refreshing) return; + refreshing = true; + const icon = $("#refreshBtn .ic"); + if (icon) icon.classList.add("spin"); // auto-refresh activity indicator + try { + const [status, clients] = await Promise.all([ + api("GET", "/api/status"), + api("GET", "/api/clients"), + ]); + renderStatus(status); + renderClients(clients); + } catch (e) { + toast(e.message, "err"); + } finally { + refreshing = false; + if (icon) icon.classList.remove("spin"); + } +} + +function renderStatus(s) { + $("#stTotal").textContent = s.total_clients ?? 0; + $("#stActive").textContent = s.active_clients ?? 0; + $("#stOnline").textContent = s.online_clients ?? 0; + $("#svIface").textContent = s.interface || "–"; + $("#svEndpoint").textContent = (s.public_ip || "?") + ":" + (s.port || "?"); + $("#svNetwork").textContent = s.network || "–"; + $("#svKey").textContent = s.public_key || "–"; + const b = $("#ifaceBadge"); + if (s.interface_up) { b.innerHTML = 'UP'; b.className = "badge up"; } + else { b.innerHTML = 'DOWN'; b.className = "badge down"; } +} + +// renderClients stores the fresh list, then paints it through the active filter. +function renderClients(clients) { + clientsCache = clients; + paintClients(); +} + +// createClientRow builds a client row's DOM once and wires its event handlers +// once. The row object keeps references to the bits that can change plus the +// last-painted value of each, so later updates touch only what actually +// changed instead of tearing the node down (see updateClientRow). +function createClientRow(c) { + const el = document.createElement("div"); + el.className = "client"; + el.setAttribute("role", "button"); + el.tabIndex = 0; + el.innerHTML = ` + +
+
+
+ +
+
+
+
+
+ `; + + const row = { + id: c.id, + el, + dot: el.querySelector(".dot"), + name: el.querySelector(".name"), + sub: el.querySelector(".sub"), + note: el.querySelector(".note"), + noteText: el.querySelector(".note span"), + rx: el.querySelector(".down span"), + tx: el.querySelector(".up span"), + switchLabel: el.querySelector(".switch"), + input: el.querySelector(".switch input"), + // last-painted values, used by updateClientRow to skip no-op writes + _dotClass: null, _name: null, _sub: null, _note: null, + _rx: null, _tx: null, _enabled: null, + }; + + el.addEventListener("click", (e) => { + // Ignore clicks that originate on the toggle switch. + if (e.target.closest(".switch")) return; + openDetail(row.id); + }); + // Keyboard activation (role="button"): Enter/Space open the detail view, + // but not when focus is on the inner switch (it has its own handling). + el.addEventListener("keydown", (e) => { + if (e.target.closest(".switch")) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openDetail(row.id); + } + }); + row.input.addEventListener("change", () => toggleClient(row.id, row.input)); + + updateClientRow(row, c); + return row; +} + +// updateClientRow paints only the fields that changed since the last call, +// so a 10s refresh with unchanged data touches no DOM at all — the row's +// entrance animation and focus/scroll state are left completely alone. +function updateClientRow(row, c) { + const enabled = c.is_enabled === "ACTIVE"; + + const dotClass = "dot" + (!enabled ? " disabled" : (c.online ? " online" : "")); + if (dotClass !== row._dotClass) { + row.dot.className = dotClass; + row._dotClass = dotClass; + } + + if (c.name !== row._name) { + row.name.textContent = c.name; + row.el.setAttribute("aria-label", "Open " + c.name); + row.input.setAttribute("aria-label", "Enable " + c.name); + row._name = c.name; + } + + const handshake = enabled + ? (c.online ? "online" : (c.latest_handshake ? fmtAgo(c.latest_handshake) : "—")) + : "disabled"; + const sub = c.ip + " · " + handshake; + if (sub !== row._sub) { + row.sub.textContent = sub; + row._sub = sub; + } + + const noteVal = c.comment || ""; + if (noteVal !== row._note) { + row.note.classList.toggle("hidden", !noteVal); + if (noteVal) { + row.note.title = noteVal; + row.noteText.textContent = noteVal; + } + row._note = noteVal; + } + + const rx = fmtBytes(c.total_rx); + if (rx !== row._rx) { row.rx.textContent = rx; row._rx = rx; } + const tx = fmtBytes(c.total_tx); + if (tx !== row._tx) { row.tx.textContent = tx; row._tx = tx; } + + if (enabled !== row._enabled) { + row.switchLabel.title = enabled ? "Enabled" : "Disabled"; + // Don't stomp on the checkbox mid-toggle: toggleClient disables it for + // the duration of its request and repaints once that settles. + if (!row.input.disabled) row.input.checked = enabled; + row._enabled = enabled; + } +} + +// clientRank orders the list so online clients surface at the top; everyone +// else (enabled-idle and disabled) keeps registry order. +function clientRank(c) { + return c.online ? 0 : 1; +} + +// paintClients reconciles clientsCache (filtered by search/hideDisabled and +// sorted by clientRank) against the DOM by client id instead of rebuilding +// the list from scratch. +// Existing rows are matched by id and patched in place; only genuinely new +// rows are created (and get the entrance animation) and only genuinely +// removed/filtered-out rows are dropped. On a steady-state refresh this is a +// no-op for structure — nothing is torn down, so the list never flashes. +function paintClients() { + const list = $("#clientList"); + if (list.querySelector(".skeleton")) { + // First real paint after the loading skeleton: those rows aren't keyed. + list.innerHTML = ""; + clientRows.clear(); + } + + const q = clientFilter.trim().toLowerCase(); + const clients = clientsCache.filter((c) => { + if (hideDisabled && c.is_enabled !== "ACTIVE") return false; + if (hideOffline && !c.online) return false; + if (!q) return true; + return (c.name || "").toLowerCase().includes(q) || + (c.ip || "").toLowerCase().includes(q) || + (c.comment || "").toLowerCase().includes(q); + }); + // Online clients first; stable sort keeps registry order within each group. + clients.sort((a, b) => clientRank(a) - clientRank(b)); + + $("#clientEmpty").classList.toggle("hidden", clientsCache.length > 0); + $("#clientNoMatch").classList.toggle("hidden", !(clientsCache.length > 0 && clients.length === 0)); + + const seen = new Set(); + let prevEl = null; + clients.forEach((c, i) => { + seen.add(c.id); + let row = clientRows.get(c.id); + if (!row) { + row = createClientRow(c); + row.el.style.animationDelay = (Math.min(i, 8) * 35) + "ms"; + clientRows.set(c.id, row); + } else { + updateClientRow(row, c); + } + const ref = prevEl ? prevEl.nextSibling : list.firstChild; + if (ref !== row.el) list.insertBefore(row.el, ref); + prevEl = row.el; + }); + + for (const [id, row] of clientRows) { + if (!seen.has(id)) { + row.el.remove(); + clientRows.delete(id); + } + } +} + +// toggleClient enables/disables a client from its row switch. The input is +// disabled during the request and reverted on failure. +async function toggleClient(id, input) { + const act = input.checked ? "enable" : "disable"; + input.disabled = true; + try { + await api("POST", "/api/clients/" + id + "/" + act); + toast("Client " + act + "d", "ok"); + await refreshAll(); + } catch (err) { + input.checked = !input.checked; // revert optimistic flip + toast(err.message, "err"); + } finally { + input.disabled = false; + } +} + +// ─── server controls ─────────────────────────────────────────────────────────── +$$("[data-server]").forEach((btn) => { + btn.addEventListener("click", async () => { + const action = btn.dataset.server; + btn.disabled = true; + try { + await api("POST", "/api/server/" + action); + toast("Server " + action + "ed", "ok"); + await refreshAll(); + } catch (e) { + toast(e.message, "err"); + } finally { + btn.disabled = false; + } + }); +}); + +$("#syncBtn").addEventListener("click", async (e) => { + const btn = e.currentTarget; // capture before await (currentTarget nulls out) + const restart = confirm("Rebuild interface config from the registry.\n\nRestart the service to apply now?"); + btn.disabled = true; + try { + await api("POST", "/api/server/sync", { restart }); + toast("Config synced" + (restart ? " & restarted" : ""), "ok"); + await refreshAll(); + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +$("#refreshBtn").addEventListener("click", () => { if (!$("#dashboard").classList.contains("hidden")) refreshAll(); else boot(); }); + +// ─── search / filters ────────────────────────────────────────────────────────── +$("#clientSearch").addEventListener("input", (e) => { + clientFilter = e.target.value; + paintClients(); +}); + +$("#hideDisabled").addEventListener("change", (e) => { + hideDisabled = e.target.checked; + paintClients(); +}); + +$("#hideOffline").addEventListener("change", (e) => { + hideOffline = e.target.checked; + paintClients(); +}); + +// ─── create client ───────────────────────────────────────────────────────────── +$("#createBtn").addEventListener("click", () => openModal("#createModal")); +$("#createForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const btn = e.target.querySelector('button[type=submit]'); + const name = e.target.name.value.trim(); + if (!name) return; + btn.disabled = true; + try { + const c = await api("POST", "/api/clients", { name }); + closeModals(); + e.target.reset(); + toast("Client “" + c.name + "” created", "ok"); + await refreshAll(); + openDetail(c.id); + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +// ─── client detail ───────────────────────────────────────────────────────────── +let detailId = null; +function openDetail(id) { + const c = clientsCache.find((x) => x.id === id); + if (!c) return; + detailId = id; + $("#dName").textContent = c.name; + const st = $("#dStatus"); + if (c.is_enabled === "ACTIVE") { + st.innerHTML = c.online + ? 'Online' + : 'Active · idle'; + } else { + st.innerHTML = 'Disabled'; + } + $("#dIP").textContent = c.ip; + $("#dEndpoint").textContent = c.endpoint || "—"; + $("#dHandshake").textContent = c.is_enabled === "ACTIVE" ? fmtAgo(c.latest_handshake) : "—"; + $("#dRx").textContent = fmtBytes(c.total_rx); + $("#dTx").textContent = fmtBytes(c.total_tx); + $("#dSince").textContent = fmtDate(c.stats_since); + $("#dQR").src = "/api/clients/" + id + "/qr?t=" + Date.now(); + $("#dDownload").href = "/api/clients/" + id + "/config"; + $("#dDownload").setAttribute("download", c.name + ".conf"); + + renderComment(c.comment || ""); + + openModal("#detailModal"); +} + +// renderComment shows the note (or a placeholder) and collapses the editor back +// to its read-only view. +function renderComment(comment) { + const view = $("#dCommentView"); + if (comment) { + view.textContent = comment; + view.classList.remove("muted"); + } else { + view.textContent = "— no comment —"; + view.classList.add("muted"); + } + $("#dCommentView").classList.remove("hidden"); + $("#dCommentEditor").classList.add("hidden"); + $("#dCommentEdit").classList.remove("hidden"); +} + +$("#dCommentEdit").addEventListener("click", () => { + const c = clientsCache.find((x) => x.id === detailId); + $("#dCommentText").value = c ? (c.comment || "") : ""; + $("#dCommentView").classList.add("hidden"); + $("#dCommentEdit").classList.add("hidden"); + $("#dCommentEditor").classList.remove("hidden"); + $("#dCommentText").focus(); +}); + +$("#dCommentCancel").addEventListener("click", () => { + const c = clientsCache.find((x) => x.id === detailId); + renderComment(c ? (c.comment || "") : ""); +}); + +$("#dCommentSave").addEventListener("click", async (e) => { + const btn = e.currentTarget; + const id = detailId; + const comment = $("#dCommentText").value.trim(); + btn.disabled = true; + try { + const res = await api("POST", "/api/clients/" + id + "/comment", { comment }); + const saved = res.comment || ""; + const c = clientsCache.find((x) => x.id === id); + if (c) c.comment = saved; // keep cache in sync without a full refetch + renderComment(saved); + toast("Comment saved", "ok"); + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +$("#dDelete").addEventListener("click", async (e) => { + // Capture the button now: e.currentTarget is null after the first await, so + // re-enabling it in finally must not go through the event object (that threw + // and left the button permanently disabled — the "delete works only once" bug). + const btn = e.currentTarget; + const id = detailId; + const c = clientsCache.find((x) => x.id === id); + if (!confirm("Delete client “" + (c ? c.name : id) + "”? This cannot be undone.")) return; + btn.disabled = true; + try { + await api("DELETE", "/api/clients/" + id); + closeModals(); + toast("Client deleted", "ok"); + await refreshAll(); + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +// ─── modal plumbing ──────────────────────────────────────────────────────────── +function openModal(sel) { $(sel).classList.remove("hidden"); } +$("#dResetStats").addEventListener("click", async (e) => { + const btn = e.currentTarget; // capture before await (see #dDelete note) + const id = detailId; + if (!confirm("Reset accumulated traffic stats for this client?")) return; + btn.disabled = true; + try { + await api("POST", "/api/clients/" + id + "/stats/reset"); + toast("Stats reset", "ok"); + await refreshAll(); + if (clientsCache.find((x) => x.id === id)) openDetail(id); // re-render with zeroed totals + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +function closeModals() { $$(".modal").forEach((m) => m.classList.add("hidden")); } +$$("[data-close]").forEach((b) => b.addEventListener("click", closeModals)); +$$(".modal").forEach((m) => m.addEventListener("click", (e) => { if (e.target === m) closeModals(); })); +document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModals(); }); + +boot(); diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..78c7cab --- /dev/null +++ b/webui/index.html @@ -0,0 +1,208 @@ + + + + + + + AmneziaWG Profiler + + + + + + + +
+ +
+ +
Loading…
+
+ + + + + + +
+ + + + + + + + + + + + diff --git a/webui/style.css b/webui/style.css new file mode 100644 index 0000000..940b62d --- /dev/null +++ b/webui/style.css @@ -0,0 +1,548 @@ +:root { + /* Modern Dark (operations dashboard) — deep slate + connected-green. + Semantic tokens; components never use raw hex. */ + --bg: #0F172A; /* slate-900, avoids pure black (OLED smear) */ + --bg-elev: #172033; /* elevated surface */ + --bg-elev2: #1E293B; /* slate-800, pressed / inset */ + --line: rgba(255, 255, 255, .09); + --text: #F8FAFC; + --muted: #94A3B8; /* slate-400, ≥3:1 on --bg */ + --accent: #22C55E; /* connected green — brand + primary CTA */ + --accent-ink: #052E16; /* near-black-green ink on accent, high contrast */ + --green: #22C55E; /* online */ + --sky: #38BDF8; /* rx / download traffic */ + --red: #F87171; /* danger text / ghost */ + --danger: #DC2626; /* destructive fill */ + --amber: #FBBF24; /* disabled / warning */ + --radius: 14px; + --radius-sm: 10px; + --shadow: 0 10px 30px rgba(0, 0, 0, .45); + --maxw: 640px; + --icon-sm: 16px; + --icon-md: 20px; + --icon-lg: 24px; + /* dense/dashboard spacing rhythm (density 8/10) */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; +} + +/* Light palette. The server stamps data-theme on per the admin's + --ui-mode (default "dark" forces the dark base above regardless of OS): + data-theme="light" — admin forced light + data-theme="auto" — follow the OS, so only light under a light OS pref + Forced dark emits neither trigger, so light never applies. */ +:root[data-theme="light"], +:root[data-theme="auto"] { color-scheme: light; } +:root[data-theme="dark"] { color-scheme: dark; } + +:root[data-theme="light"] { + --bg: #F1F5F9; /* slate-100, not pure white */ + --bg-elev: #FFFFFF; + --bg-elev2: #E2E8F0; + --line: #E2E8F0; + --text: #0F172A; + --muted: #475569; /* slate-600, ≥4.5:1 on light surfaces */ + --accent: #16A34A; /* green-600 for crisper contrast on light */ + --accent-ink: #FFFFFF; + --green: #16A34A; + --sky: #0284C7; + --red: #DC2626; + --danger: #DC2626; + --amber: #B45309; + --shadow: 0 8px 24px rgba(15, 23, 32, .12); +} + +@media (prefers-color-scheme: light) { + :root[data-theme="auto"] { + --bg: #F1F5F9; /* slate-100, not pure white */ + --bg-elev: #FFFFFF; + --bg-elev2: #E2E8F0; + --line: #E2E8F0; + --text: #0F172A; + --muted: #475569; /* slate-600, ≥4.5:1 on light surfaces */ + --accent: #16A34A; /* green-600 for crisper contrast on light */ + --accent-ink: #FFFFFF; + --green: #16A34A; + --sky: #0284C7; + --red: #DC2626; + --danger: #DC2626; + --amber: #B45309; + --shadow: 0 8px 24px rgba(15, 23, 32, .12); + } +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; } +.muted { color: var(--muted); } +.hidden { display: none !important; } +.center { text-align: center; padding: 40px 16px; } +.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── icons ── */ +.ic { + width: var(--icon-md); + height: var(--icon-md); + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + flex: 0 0 auto; + vertical-align: -.15em; +} +.ic-sm { width: var(--icon-sm); height: var(--icon-sm); } +.ic-lg { width: var(--icon-lg); height: var(--icon-lg); } +/* filled glyphs (play / stop / pause) */ +#i-play, #i-stop, #i-pause { fill: currentColor; stroke: none; } +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--text); + cursor: pointer; + width: 44px; + height: 44px; + border-radius: 50%; + transition: background .15s; +} +.icon-btn:active { background: var(--bg-elev2); } +.icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* ── layout ── */ +main { + max-width: var(--maxw); + margin: 0 auto; + padding: max(16px, env(safe-area-inset-top)) 16px calc(32px + env(safe-area-inset-bottom)); +} + +h1 { font-size: 22px; margin: 8px 0 16px; font-weight: 700; } +h2 { font-size: 16px; margin: 0; font-weight: 620; } + +#loading { display: flex; flex-direction: column; align-items: center; gap: 12px; } + +/* ── stat grid ── */ +.stat-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 16px; +} +.stat { + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 16px 12px; + text-align: center; +} +.stat-val { + font-size: 28px; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; + min-height: 28px; +} +.stat-online { color: var(--green); } +.stat-label { font-size: 12px; color: var(--muted); margin-top: 6px; text-transform: uppercase; letter-spacing: .5px; } + +/* ── cards ── */ +.card { + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 16px; +} +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.card p.muted { margin: 0 0 12px; font-size: 14px; } + +.kv { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 16px; + margin: 0 0 8px; + font-size: 14px; +} +.stats-meta { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; margin: 12px 0 4px; font-size: 13px; +} +.kv dt { color: var(--muted); } +.kv dd { margin: 0; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── badges ── */ +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; + background: var(--bg-elev2); + color: var(--muted); + white-space: nowrap; +} +.badge-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: 0 0 auto; } +.badge.up { background: color-mix(in srgb, var(--green) 20%, transparent); color: var(--green); } +.badge.down { background: color-mix(in srgb, var(--red) 20%, transparent); color: var(--red); } +.badge.ok { background: color-mix(in srgb, var(--green) 20%, transparent); color: var(--green); } +.badge.no { background: color-mix(in srgb, var(--amber) 20%, transparent); color: var(--amber); } + +/* ── buttons ── */ +.btn { + appearance: none; + border: 1px solid var(--line); + background: var(--bg-elev2); + color: var(--text); + font-size: 14px; + font-weight: 550; + padding: 10px 16px; + min-height: 44px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: transform .12s cubic-bezier(.16, 1, .3, 1), filter .15s, background .15s; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} +.btn:hover { filter: brightness(1.08); } +.btn:active { transform: scale(.97); } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.btn:disabled { opacity: .5; cursor: default; filter: none; } +.btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; } +.btn-danger { background: var(--danger); color: #fff; border-color: transparent; } +.btn-danger-ghost { color: var(--red); } +.btn-sm { padding: 7px 12px; min-height: 38px; font-size: 13px; } +.btn-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.btn-row.end { justify-content: flex-end; } +/* flex-basis 0 makes buttons that share a row equal width regardless of their + label length (e.g. "Download .conf" vs "Delete" in the client modal). */ +.btn-row .btn { flex: 1 1 0; min-width: 0; } +.btn-row.end .btn { flex: 0 0 auto; } + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 24px 0 12px; +} +.section-actions { display: flex; align-items: center; gap: 4px; } +.section-actions .icon-btn { width: 38px; height: 38px; color: var(--muted); } +.section-actions .icon-btn:hover { color: var(--text); } + +/* ── forms ── */ +.form { display: flex; flex-direction: column; gap: 12px; } +.form label { display: flex; flex-direction: column; gap: 5px; font-size: 13px; color: var(--muted); font-weight: 500; } +.form input { + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 16px; + padding: 12px; + min-height: 44px; + width: 100%; +} +.form input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; } + +/* ── search ── */ +.search-wrap { position: relative; margin-bottom: 12px; } +.search-ic { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--muted); + pointer-events: none; +} +.search-wrap input { + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 16px; + padding: 12px 14px 12px 40px; + min-height: 44px; + width: 100%; +} +.search-wrap input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; } +.search-wrap input::placeholder { color: var(--muted); } + +.list-filter { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 4px 16px; + margin: -4px 0 12px; + font-size: 13px; + color: var(--muted); +} + +.filter-item { + display: flex; + align-items: center; + gap: 8px; +} + +/* ── comment block (client modal) ── */ +.comment-block { + border-top: 1px solid var(--line); + margin-top: 12px; + padding-top: 12px; +} +.comment-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} +.comment-label { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: .5px; +} +.comment-view { font-size: 14px; white-space: pre-wrap; word-break: break-word; } +.comment-editor textarea { + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + color: var(--text); + font-family: inherit; + font-size: 15px; + line-height: 1.4; + padding: 10px 12px; + width: 100%; + resize: vertical; +} +.comment-editor textarea:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; } + +/* ── client list ── */ +.client-list { display: flex; flex-direction: column; gap: 10px; } +.client { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + text-align: left; + font: inherit; + color: inherit; + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 14px 16px; + cursor: pointer; + transition: background .15s, border-color .15s, transform .12s cubic-bezier(.16, 1, .3, 1); +} +.client:hover { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); } +.client:active { background: var(--bg-elev2); transform: scale(.99); } +.client:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.client .dot { + position: relative; + width: 12px; height: 12px; border-radius: 50%; + background: transparent; + border: 2px solid var(--muted); + flex: 0 0 auto; +} +/* status conveyed by shape + colour, not colour alone */ +.client .dot.online { + background: var(--green); border-color: var(--green); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 22%, transparent); + animation: livepulse 2s ease-in-out infinite; +} +.client .dot.disabled { + border-color: var(--amber); background: transparent; +} +.client .dot.disabled::after { + content: ""; position: absolute; inset: 2px 1px; border-top: 2px solid var(--amber); + transform: translateY(2px); +} +.client .info { flex: 1 1 auto; min-width: 0; } +.client .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.client .sub { font-size: 12px; color: var(--muted); font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums; } +.client .note { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); margin-top: 2px; overflow: hidden; } +.client .note span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.client .traffic { text-align: right; font-size: 12px; color: var(--muted); flex: 0 0 auto; font-variant-numeric: tabular-nums; } +.client .traffic > div { display: flex; align-items: center; justify-content: flex-end; gap: 3px; } +.client .traffic .down { color: var(--sky); } +.client .traffic .up { color: var(--green); } + +@keyframes livepulse { + 0%, 100% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 22%, transparent); } + 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--green) 6%, transparent); } +} + +/* ── skeletons ── */ +.sk { + display: inline-block; + border-radius: 6px; + background: linear-gradient(90deg, + color-mix(in srgb, var(--muted) 16%, transparent) 25%, + color-mix(in srgb, var(--muted) 28%, transparent) 37%, + color-mix(in srgb, var(--muted) 16%, transparent) 63%); + background-size: 400% 100%; + animation: shimmer 1.4s ease infinite; +} +.sk-num { width: 40%; height: 26px; } +.client.skeleton { pointer-events: none; animation: none; } +.client.skeleton .sk-dot { width: 12px; height: 12px; border-radius: 50%; } +.client.skeleton .info .sk { display: block; } +.client.skeleton .sk-line1 { width: 45%; height: 12px; margin-bottom: 7px; } +.client.skeleton .sk-line2 { width: 65%; height: 10px; } +.client.skeleton .sk-tr { width: 44px; height: 24px; } +@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } + +/* ── toggle switch ── */ +.switch { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 46px; + height: 44px; /* ≥44px touch target; visual track is the slider inside */ + cursor: pointer; +} +.switch input { + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + margin: 0; + cursor: pointer; +} +.switch .slider { + position: relative; + width: 46px; + height: 28px; + background: var(--bg-elev2); + border: 1px solid var(--line); + border-radius: 999px; + transition: background .18s, border-color .18s; +} +.switch .slider::before { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--muted); + transition: transform .18s cubic-bezier(.16, 1, .3, 1), background .18s; +} +.switch input:checked + .slider { + background: color-mix(in srgb, var(--green) 30%, transparent); + border-color: transparent; +} +.switch input:checked + .slider::before { + transform: translateX(18px); + background: var(--green); +} +.switch input:focus-visible + .slider { outline: 2px solid var(--accent); outline-offset: 2px; } +.switch input:disabled { cursor: default; } +.switch input:disabled + .slider { opacity: .5; } + +/* ── modal ── */ +.modal { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: flex-end; + justify-content: center; + background: rgba(0, 0, 0, .55); + padding: 0; + animation: fade .15s ease; +} +@media (min-width: 560px) { .modal { align-items: center; padding: 16px; } } +.modal-card { + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--radius) var(--radius) 0 0; + box-shadow: var(--shadow); + width: 100%; + max-width: var(--maxw); + padding: 20px 16px calc(20px + env(safe-area-inset-bottom)); + animation: slideup .24s cubic-bezier(.16, 1, .3, 1); +} +@media (min-width: 560px) { .modal-card { border-radius: var(--radius); animation: popin .2s cubic-bezier(.16, 1, .3, 1); } } +.modal-card h2 { margin-bottom: 14px; } + +.qr-wrap { text-align: center; margin: 16px 0; } +.qr-wrap img { + width: 220px; max-width: 70%; + background: #fff; padding: 10px; border-radius: var(--radius-sm); + image-rendering: pixelated; +} + +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideup { from { transform: translateY(24px); opacity: .6; } to { transform: translateY(0); opacity: 1; } } +@keyframes popin { from { transform: scale(.96); opacity: .6; } to { transform: scale(1); opacity: 1; } } +@keyframes rowin { from { transform: translateY(6px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } + +/* staggered list entrance (subtle) */ +.client-list .client { animation: rowin .28s cubic-bezier(.16, 1, .3, 1) both; } + +/* ── toast ── */ +.toast { + position: fixed; + left: 50%; + bottom: calc(24px + env(safe-area-inset-bottom)); + transform: translateX(-50%); + z-index: 100; + background: var(--bg-elev2); + color: var(--text); + border: 1px solid var(--line); + padding: 11px 18px; + border-radius: 999px; + box-shadow: var(--shadow); + font-size: 14px; + max-width: 90%; + animation: fade .15s ease; +} +.toast.err { border-color: var(--red); color: var(--red); } +.toast.ok { border-color: var(--green); } + +.spin { display: inline-block; animation: rot 1s linear infinite; transform-origin: center; } +@keyframes rot { to { transform: rotate(360deg); } } + +/* ── reduced motion ── */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .001ms !important; + animation-iteration-count: 1 !important; + transition-duration: .001ms !important; + } + .sk { animation: none; opacity: .6; } +} diff --git a/webui_glass/app.js b/webui_glass/app.js new file mode 100644 index 0000000..c2d3885 --- /dev/null +++ b/webui_glass/app.js @@ -0,0 +1,591 @@ +"use strict"; + +// ─── tiny helpers ────────────────────────────────────────────────────────────── +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +async function api(method, path, body) { + const opts = { method, headers: {} }; + // Server requires application/json on every POST/DELETE (CSRF hardening: + // HTML forms can never set this content type), so set it even when there + // is no body to send. + if (method === "POST" || method === "DELETE") { + opts.headers["Content-Type"] = "application/json"; + } + if (body !== undefined) { + opts.body = JSON.stringify(body); + } + const res = await fetch(path, opts); + const ct = res.headers.get("content-type") || ""; + const data = ct.includes("application/json") ? await res.json() : await res.text(); + if (!res.ok) { + const msg = data && data.error ? data.error : (typeof data === "string" ? data : "Request failed"); + throw new Error(msg); + } + return data; +} + +let toastTimer; +function toast(msg, kind) { + const t = $("#toast"); + t.textContent = msg; + t.className = "toast" + (kind ? " " + kind : ""); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.classList.add("hidden"), 3200); +} + +function fmtBytes(n) { + n = Number(n) || 0; + if (n < 1024) return n + " B"; + const u = ["KB", "MB", "GB", "TB"]; + let i = -1; + do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1); + return n.toFixed(n < 10 ? 1 : 0) + " " + u[i]; +} + +function fmtAgo(ts) { + if (!ts) return "never"; + const s = Math.floor(Date.now() / 1000) - ts; + if (s < 0) return "just now"; + if (s < 60) return s + "s ago"; + if (s < 3600) return Math.floor(s / 60) + "m ago"; + if (s < 86400) return Math.floor(s / 3600) + "h ago"; + return Math.floor(s / 86400) + "d ago"; +} + +function fmtDate(ts) { + if (!ts) return "—"; + const d = new Date(ts * 1000); + return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +// ─── state ─────────────────────────────────────────────────────────────────────── +let clientsCache = []; +let clientFilter = ""; +let refreshTimer; +let clientRows = new Map(); // id -> row (keyed DOM reconciliation, see paintClients) +let hideDisabled = false; +let hideOffline = false; + +// ─── initial load / routing ──────────────────────────────────────────────────── +async function boot() { + try { + const setup = await api("GET", "/api/setup-status"); + $("#loading").classList.add("hidden"); + if (!setup.server_initialized) { + showSetup(setup); + } else { + showDashboard(); + } + } catch (e) { + $("#loading").textContent = "Error: " + e.message; + } +} + +function showView(id) { + $$(".view").forEach((v) => v.classList.add("hidden")); + $(id).classList.remove("hidden"); +} + +// ─── setup wizard ────────────────────────────────────────────────────────────── +function showSetup(setup) { + showView("#setup"); + const badge = $("#depsBadge"); + if (setup.deps_installed) { + badge.textContent = "Installed"; + badge.className = "badge ok"; + $("#installDepsBtn").disabled = true; + } else { + badge.textContent = "Not installed"; + badge.className = "badge no"; + } +} + +$("#installDepsBtn").addEventListener("click", async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + btn.innerHTML = ' Installing… (may take minutes)'; + try { + await api("POST", "/api/install-deps"); + toast("Dependencies installed", "ok"); + boot(); + } catch (err) { + toast(err.message, "err"); + btn.disabled = false; + btn.textContent = "Install dependencies"; + } +}); + +$("#initForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const btn = e.target.querySelector('button[type=submit]'); + const fd = new FormData(e.target); + const body = {}; + for (const [k, v] of fd.entries()) body[k] = v.trim(); + btn.disabled = true; + btn.innerHTML = ' Initialising…'; + try { + await api("POST", "/api/init-server", body); + toast("Server initialised", "ok"); + boot(); + } catch (err) { + toast(err.message, "err"); + btn.disabled = false; + btn.textContent = "Initialise server"; + } +}); + +// ─── dashboard ───────────────────────────────────────────────────────────────── +async function showDashboard() { + showView("#dashboard"); + paintSkeleton(); + await refreshAll(); + clearInterval(refreshTimer); + refreshTimer = setInterval(refreshAll, 10000); +} + +// paintSkeleton shows shimmer placeholders until the first data arrives, so the +// dashboard never flashes empty "–" cells (Feedback: loading states). +function paintSkeleton() { + const list = $("#clientList"); + list.innerHTML = ""; + for (let n = 0; n < 3; n++) { + const row = document.createElement("div"); + row.className = "client skeleton"; + row.innerHTML = + '' + + '
' + + ''; + list.appendChild(row); + } +} + +let refreshing = false; +async function refreshAll() { + if (refreshing) return; + refreshing = true; + const icon = $("#refreshBtn .ic"); + if (icon) icon.classList.add("spin"); // auto-refresh activity indicator + try { + const [status, clients] = await Promise.all([ + api("GET", "/api/status"), + api("GET", "/api/clients"), + ]); + renderStatus(status); + renderClients(clients); + } catch (e) { + toast(e.message, "err"); + } finally { + refreshing = false; + if (icon) icon.classList.remove("spin"); + } +} + +function renderStatus(s) { + $("#stTotal").textContent = s.total_clients ?? 0; + $("#stActive").textContent = s.active_clients ?? 0; + $("#stOnline").textContent = s.online_clients ?? 0; + $("#svIface").textContent = s.interface || "–"; + $("#svEndpoint").textContent = (s.public_ip || "?") + ":" + (s.port || "?"); + $("#svNetwork").textContent = s.network || "–"; + $("#svKey").textContent = s.public_key || "–"; + const b = $("#ifaceBadge"); + if (s.interface_up) { b.innerHTML = 'UP'; b.className = "badge up"; } + else { b.innerHTML = 'DOWN'; b.className = "badge down"; } +} + +// renderClients stores the fresh list, then paints it through the active filter. +function renderClients(clients) { + clientsCache = clients; + paintClients(); +} + +// createClientRow builds a client row's DOM once and wires its event handlers +// once. The row object keeps references to the bits that can change plus the +// last-painted value of each, so later updates touch only what actually +// changed instead of tearing the node down (see updateClientRow). +function createClientRow(c) { + const el = document.createElement("div"); + el.className = "client"; + el.setAttribute("role", "button"); + el.tabIndex = 0; + el.innerHTML = ` + +
+
+
+ +
+
+
+
+
+ `; + + const row = { + id: c.id, + el, + dot: el.querySelector(".dot"), + name: el.querySelector(".name"), + sub: el.querySelector(".sub"), + note: el.querySelector(".note"), + noteText: el.querySelector(".note span"), + rx: el.querySelector(".down span"), + tx: el.querySelector(".up span"), + switchLabel: el.querySelector(".switch"), + input: el.querySelector(".switch input"), + // last-painted values, used by updateClientRow to skip no-op writes + _dotClass: null, _name: null, _sub: null, _note: null, + _rx: null, _tx: null, _enabled: null, + }; + + el.addEventListener("click", (e) => { + // Ignore clicks that originate on the toggle switch. + if (e.target.closest(".switch")) return; + openDetail(row.id); + }); + // Keyboard activation (role="button"): Enter/Space open the detail view, + // but not when focus is on the inner switch (it has its own handling). + el.addEventListener("keydown", (e) => { + if (e.target.closest(".switch")) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openDetail(row.id); + } + }); + row.input.addEventListener("change", () => toggleClient(row.id, row.input)); + + updateClientRow(row, c); + return row; +} + +// updateClientRow paints only the fields that changed since the last call, +// so a 10s refresh with unchanged data touches no DOM at all — the row's +// entrance animation and focus/scroll state are left completely alone. +function updateClientRow(row, c) { + const enabled = c.is_enabled === "ACTIVE"; + + const dotClass = "dot" + (!enabled ? " disabled" : (c.online ? " online" : "")); + if (dotClass !== row._dotClass) { + row.dot.className = dotClass; + row._dotClass = dotClass; + } + + if (c.name !== row._name) { + row.name.textContent = c.name; + row.el.setAttribute("aria-label", "Open " + c.name); + row.input.setAttribute("aria-label", "Enable " + c.name); + row._name = c.name; + } + + const handshake = enabled + ? (c.online ? "online" : (c.latest_handshake ? fmtAgo(c.latest_handshake) : "—")) + : "disabled"; + const sub = c.ip + " · " + handshake; + if (sub !== row._sub) { + row.sub.textContent = sub; + row._sub = sub; + } + + const noteVal = c.comment || ""; + if (noteVal !== row._note) { + row.note.classList.toggle("hidden", !noteVal); + if (noteVal) { + row.note.title = noteVal; + row.noteText.textContent = noteVal; + } + row._note = noteVal; + } + + const rx = fmtBytes(c.total_rx); + if (rx !== row._rx) { row.rx.textContent = rx; row._rx = rx; } + const tx = fmtBytes(c.total_tx); + if (tx !== row._tx) { row.tx.textContent = tx; row._tx = tx; } + + if (enabled !== row._enabled) { + row.switchLabel.title = enabled ? "Enabled" : "Disabled"; + // Don't stomp on the checkbox mid-toggle: toggleClient disables it for + // the duration of its request and repaints once that settles. + if (!row.input.disabled) row.input.checked = enabled; + row._enabled = enabled; + } +} + +// clientRank orders the list so online clients surface at the top; everyone +// else (enabled-idle and disabled) keeps registry order. +function clientRank(c) { + return c.online ? 0 : 1; +} + +// paintClients reconciles clientsCache (filtered by search/hideDisabled and +// sorted by clientRank) against the DOM by client id instead of rebuilding +// the list from scratch. +// Existing rows are matched by id and patched in place; only genuinely new +// rows are created (and get the entrance animation) and only genuinely +// removed/filtered-out rows are dropped. On a steady-state refresh this is a +// no-op for structure — nothing is torn down, so the list never flashes. +function paintClients() { + const list = $("#clientList"); + if (list.querySelector(".skeleton")) { + // First real paint after the loading skeleton: those rows aren't keyed. + list.innerHTML = ""; + clientRows.clear(); + } + + const q = clientFilter.trim().toLowerCase(); + const clients = clientsCache.filter((c) => { + if (hideDisabled && c.is_enabled !== "ACTIVE") return false; + if (hideOffline && !c.online) return false; + if (!q) return true; + return (c.name || "").toLowerCase().includes(q) || + (c.ip || "").toLowerCase().includes(q) || + (c.comment || "").toLowerCase().includes(q); + }); + // Online clients first; stable sort keeps registry order within each group. + clients.sort((a, b) => clientRank(a) - clientRank(b)); + + $("#clientEmpty").classList.toggle("hidden", clientsCache.length > 0); + $("#clientNoMatch").classList.toggle("hidden", !(clientsCache.length > 0 && clients.length === 0)); + + const seen = new Set(); + let prevEl = null; + clients.forEach((c, i) => { + seen.add(c.id); + let row = clientRows.get(c.id); + if (!row) { + row = createClientRow(c); + row.el.style.animationDelay = (Math.min(i, 8) * 35) + "ms"; + clientRows.set(c.id, row); + } else { + updateClientRow(row, c); + } + const ref = prevEl ? prevEl.nextSibling : list.firstChild; + if (ref !== row.el) list.insertBefore(row.el, ref); + prevEl = row.el; + }); + + for (const [id, row] of clientRows) { + if (!seen.has(id)) { + row.el.remove(); + clientRows.delete(id); + } + } +} + +// toggleClient enables/disables a client from its row switch. The input is +// disabled during the request and reverted on failure. +async function toggleClient(id, input) { + const act = input.checked ? "enable" : "disable"; + input.disabled = true; + try { + await api("POST", "/api/clients/" + id + "/" + act); + toast("Client " + act + "d", "ok"); + await refreshAll(); + } catch (err) { + input.checked = !input.checked; // revert optimistic flip + toast(err.message, "err"); + } finally { + input.disabled = false; + } +} + +// ─── server controls ─────────────────────────────────────────────────────────── +$$("[data-server]").forEach((btn) => { + btn.addEventListener("click", async () => { + const action = btn.dataset.server; + btn.disabled = true; + try { + await api("POST", "/api/server/" + action); + toast("Server " + action + "ed", "ok"); + await refreshAll(); + } catch (e) { + toast(e.message, "err"); + } finally { + btn.disabled = false; + } + }); +}); + +$("#syncBtn").addEventListener("click", async (e) => { + const btn = e.currentTarget; // capture before await (currentTarget nulls out) + const restart = confirm("Rebuild interface config from the registry.\n\nRestart the service to apply now?"); + btn.disabled = true; + try { + await api("POST", "/api/server/sync", { restart }); + toast("Config synced" + (restart ? " & restarted" : ""), "ok"); + await refreshAll(); + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +$("#refreshBtn").addEventListener("click", () => { if (!$("#dashboard").classList.contains("hidden")) refreshAll(); else boot(); }); + +// ─── search / filters ────────────────────────────────────────────────────────── +$("#clientSearch").addEventListener("input", (e) => { + clientFilter = e.target.value; + paintClients(); +}); + +$("#hideDisabled").addEventListener("change", (e) => { + hideDisabled = e.target.checked; + paintClients(); +}); + +$("#hideOffline").addEventListener("change", (e) => { + hideOffline = e.target.checked; + paintClients(); +}); + +// ─── create client ───────────────────────────────────────────────────────────── +$("#createBtn").addEventListener("click", () => openModal("#createModal")); +$("#createForm").addEventListener("submit", async (e) => { + e.preventDefault(); + const btn = e.target.querySelector('button[type=submit]'); + const name = e.target.name.value.trim(); + if (!name) return; + btn.disabled = true; + try { + const c = await api("POST", "/api/clients", { name }); + closeModals(); + e.target.reset(); + toast("Client “" + c.name + "” created", "ok"); + await refreshAll(); + openDetail(c.id); + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +// ─── client detail ───────────────────────────────────────────────────────────── +let detailId = null; +function openDetail(id) { + const c = clientsCache.find((x) => x.id === id); + if (!c) return; + detailId = id; + $("#dName").textContent = c.name; + const st = $("#dStatus"); + if (c.is_enabled === "ACTIVE") { + st.innerHTML = c.online + ? 'Online' + : 'Active · idle'; + } else { + st.innerHTML = 'Disabled'; + } + $("#dIP").textContent = c.ip; + $("#dEndpoint").textContent = c.endpoint || "—"; + $("#dHandshake").textContent = c.is_enabled === "ACTIVE" ? fmtAgo(c.latest_handshake) : "—"; + $("#dRx").textContent = fmtBytes(c.total_rx); + $("#dTx").textContent = fmtBytes(c.total_tx); + $("#dSince").textContent = fmtDate(c.stats_since); + $("#dQR").src = "/api/clients/" + id + "/qr?t=" + Date.now(); + $("#dDownload").href = "/api/clients/" + id + "/config"; + $("#dDownload").setAttribute("download", c.name + ".conf"); + + renderComment(c.comment || ""); + + openModal("#detailModal"); +} + +// renderComment shows the note (or a placeholder) and collapses the editor back +// to its read-only view. +function renderComment(comment) { + const view = $("#dCommentView"); + if (comment) { + view.textContent = comment; + view.classList.remove("muted"); + } else { + view.textContent = "— no comment —"; + view.classList.add("muted"); + } + $("#dCommentView").classList.remove("hidden"); + $("#dCommentEditor").classList.add("hidden"); + $("#dCommentEdit").classList.remove("hidden"); +} + +$("#dCommentEdit").addEventListener("click", () => { + const c = clientsCache.find((x) => x.id === detailId); + $("#dCommentText").value = c ? (c.comment || "") : ""; + $("#dCommentView").classList.add("hidden"); + $("#dCommentEdit").classList.add("hidden"); + $("#dCommentEditor").classList.remove("hidden"); + $("#dCommentText").focus(); +}); + +$("#dCommentCancel").addEventListener("click", () => { + const c = clientsCache.find((x) => x.id === detailId); + renderComment(c ? (c.comment || "") : ""); +}); + +$("#dCommentSave").addEventListener("click", async (e) => { + const btn = e.currentTarget; + const id = detailId; + const comment = $("#dCommentText").value.trim(); + btn.disabled = true; + try { + const res = await api("POST", "/api/clients/" + id + "/comment", { comment }); + const saved = res.comment || ""; + const c = clientsCache.find((x) => x.id === id); + if (c) c.comment = saved; // keep cache in sync without a full refetch + renderComment(saved); + toast("Comment saved", "ok"); + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +$("#dDelete").addEventListener("click", async (e) => { + // Capture the button now: e.currentTarget is null after the first await, so + // re-enabling it in finally must not go through the event object (that threw + // and left the button permanently disabled — the "delete works only once" bug). + const btn = e.currentTarget; + const id = detailId; + const c = clientsCache.find((x) => x.id === id); + if (!confirm("Delete client “" + (c ? c.name : id) + "”? This cannot be undone.")) return; + btn.disabled = true; + try { + await api("DELETE", "/api/clients/" + id); + closeModals(); + toast("Client deleted", "ok"); + await refreshAll(); + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +// ─── modal plumbing ──────────────────────────────────────────────────────────── +function openModal(sel) { $(sel).classList.remove("hidden"); } +$("#dResetStats").addEventListener("click", async (e) => { + const btn = e.currentTarget; // capture before await (see #dDelete note) + const id = detailId; + if (!confirm("Reset accumulated traffic stats for this client?")) return; + btn.disabled = true; + try { + await api("POST", "/api/clients/" + id + "/stats/reset"); + toast("Stats reset", "ok"); + await refreshAll(); + if (clientsCache.find((x) => x.id === id)) openDetail(id); // re-render with zeroed totals + } catch (err) { + toast(err.message, "err"); + } finally { + btn.disabled = false; + } +}); + +function closeModals() { $$(".modal").forEach((m) => m.classList.add("hidden")); } +$$("[data-close]").forEach((b) => b.addEventListener("click", closeModals)); +$$(".modal").forEach((m) => m.addEventListener("click", (e) => { if (e.target === m) closeModals(); })); +document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModals(); }); + +boot(); diff --git a/webui_glass/index.html b/webui_glass/index.html new file mode 100644 index 0000000..920bd24 --- /dev/null +++ b/webui_glass/index.html @@ -0,0 +1,215 @@ + + + + + + + AmneziaWG Profiler · Glass + + + + + + + + + + +
+ +
+ +
Loading…
+
+ + + + + + +
+ + + + + + + + + + + + diff --git a/webui_glass/style.css b/webui_glass/style.css new file mode 100644 index 0000000..ada0221 --- /dev/null +++ b/webui_glass/style.css @@ -0,0 +1,478 @@ +:root { + /* ── Glassmorphism · "Slate + Amber" ───────────────────────────────────────── + Deep steel-black gradient behind frosted glass panels. Sky is the primary/ + brand colour, amber the warm status accent (online / rx). Green-free. + Components consume semantic tokens only — never raw hex. */ + + /* backdrop (fixed gradient + drifting blobs live on .bg-field) */ + --bg-0: #0C0F16; /* top of gradient */ + --bg-1: #10141F; /* mid */ + --bg-2: #171B26; /* bottom, steel-black */ + + /* glass surfaces (translucent, sit over the blurred backdrop) */ + --glass: rgba(255, 255, 255, .05); + --glass-2: rgba(255, 255, 255, .08); /* elevated / pressed */ + --glass-strong: rgba(18, 23, 34, .55); /* modals — denser so text stays legible */ + --glass-border: rgba(255, 255, 255, .12); + --glass-border-soft: rgba(255, 255, 255, .07); + --glass-hi: rgba(255, 255, 255, .16); /* top-edge highlight */ + --blur: 18px; + + --text: #F8FAFC; + --muted: #94A3B8; /* slate-400, ≥3:1 on backdrop */ + + --accent: #38BDF8; /* sky — brand + primary CTA */ + --accent-2: #0EA5E9; /* deeper sky for gradient fills */ + --accent-ink: #06121F; /* dark ink on the bright sky button */ + --amber: #FBBF24; /* online status + warning */ + --rx: #FBBF24; /* download / received traffic */ + --tx: #60A5FA; /* upload / sent traffic */ + --red: #F87171; /* danger text / ghost */ + --danger: #E11D48; /* destructive fill (rose-600) */ + + --radius: 18px; + --radius-sm: 12px; + --shadow: 0 20px 50px rgba(2, 6, 16, .55), 0 2px 8px rgba(2, 6, 16, .4); + --glow: 0 0 0 1px rgba(56, 189, 248, .35), 0 8px 30px rgba(56, 189, 248, .28); + --maxw: 640px; + --icon-sm: 16px; + --icon-md: 20px; + --icon-lg: 24px; + --ease: cubic-bezier(.16, 1, .3, 1); + /* dense/dashboard spacing rhythm (density 8/10) */ + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; +} + +/* Glass is a dark-only design — the palette above is the only theme; OS light + preference is deliberately ignored so the frosted look stays consistent. */ +:root { color-scheme: dark; } + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + min-height: 100dvh; + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + background: + radial-gradient(1200px 800px at 15% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 60%), + radial-gradient(900px 700px at 110% 10%, color-mix(in srgb, var(--amber) 10%, transparent), transparent 55%), + linear-gradient(180deg, var(--bg-0) 0%, var(--bg-1) 45%, var(--bg-2) 100%); + background-attachment: fixed; +} + +/* ── ambient drifting blobs (behind everything) ── */ +.bg-field { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + pointer-events: none; +} +.blob { + position: absolute; + border-radius: 50%; + filter: blur(60px); + opacity: .5; + will-change: transform; +} +.blob-a { width: 42vmax; height: 42vmax; left: -12vmax; top: -10vmax; + background: radial-gradient(circle, var(--accent), transparent 70%); + animation: drift-a 26s ease-in-out infinite; } +.blob-b { width: 34vmax; height: 34vmax; right: -10vmax; top: 20vmax; + background: radial-gradient(circle, var(--amber), transparent 70%); + opacity: .38; animation: drift-b 32s ease-in-out infinite; } +.blob-c { width: 30vmax; height: 30vmax; left: 30vmax; bottom: -14vmax; + background: radial-gradient(circle, var(--tx), transparent 70%); + opacity: .32; animation: drift-c 30s ease-in-out infinite; } +@keyframes drift-a { 50% { transform: translate(6vmax, 8vmax) scale(1.1); } } +@keyframes drift-b { 50% { transform: translate(-7vmax, 5vmax) scale(1.08); } } +@keyframes drift-c { 50% { transform: translate(4vmax, -6vmax) scale(1.12); } } + +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; } +.muted { color: var(--muted); } +.hidden { display: none !important; } +.center { text-align: center; padding: 40px 16px; } +.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── the glass primitive ── */ +.glass { + background: var(--glass); + border: 1px solid var(--glass-border-soft); + -webkit-backdrop-filter: blur(var(--blur)) saturate(150%); + backdrop-filter: blur(var(--blur)) saturate(150%); + box-shadow: var(--shadow); + position: relative; +} +/* top-edge light reflection */ +.glass::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: linear-gradient(180deg, var(--glass-hi), transparent 40%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +/* ── icons ── */ +.ic { + width: var(--icon-md); height: var(--icon-md); + stroke: currentColor; fill: none; stroke-width: 2; + stroke-linecap: round; stroke-linejoin: round; + flex: 0 0 auto; vertical-align: -.15em; +} +.ic-sm { width: var(--icon-sm); height: var(--icon-sm); } +.ic-lg { width: var(--icon-lg); height: var(--icon-lg); } +#i-play, #i-stop, #i-pause { fill: currentColor; stroke: none; } + +.icon-btn { + display: inline-flex; align-items: center; justify-content: center; + background: transparent; border: none; color: var(--text); + cursor: pointer; width: 44px; height: 44px; border-radius: 50%; + transition: background .15s; +} +.icon-btn:hover { background: var(--glass-2); } +.icon-btn:active { background: var(--glass-2); } +.icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* ── layout ── */ +main { + max-width: var(--maxw); + margin: 0 auto; + padding: max(16px, env(safe-area-inset-top)) 16px calc(32px + env(safe-area-inset-bottom)); +} +h1 { font-size: 24px; margin: 6px 0 18px; font-weight: 750; letter-spacing: -.2px; } +h2 { font-size: 16px; margin: 0; font-weight: 650; } +#loading { display: flex; flex-direction: column; align-items: center; gap: 12px; } + +/* ── stat grid ── */ +.stat-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 16px; +} +.stat { + border-radius: var(--radius); + padding: 18px 12px; + text-align: center; + overflow: hidden; +} +.stat-val { + font-size: 30px; font-weight: 750; line-height: 1; + font-variant-numeric: tabular-nums; min-height: 30px; +} +.stat-online { color: var(--amber); } +.stat-online-tile { + background: + radial-gradient(120% 120% at 50% -20%, color-mix(in srgb, var(--amber) 22%, transparent), transparent 60%), + var(--glass); +} +.stat-label { font-size: 11px; color: var(--muted); margin-top: 7px; text-transform: uppercase; letter-spacing: .6px; } + +/* ── cards ── */ +.card { + border-radius: var(--radius); + padding: 18px; + margin-bottom: 16px; +} +.card-head { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; margin-bottom: 12px; +} +.card p.muted { margin: 0 0 14px; font-size: 14px; } + +.kv { + display: grid; grid-template-columns: auto 1fr; + gap: 8px 16px; margin: 0 0 8px; font-size: 14px; +} +.stats-meta { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; margin: 12px 0 4px; font-size: 13px; +} +.kv dt { color: var(--muted); } +.kv dd { margin: 0; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── badges ── */ +.badge { + display: inline-flex; align-items: center; gap: 6px; + font-size: 12px; font-weight: 650; padding: 4px 11px; border-radius: 999px; + background: var(--glass-2); + border: 1px solid var(--glass-border-soft); + color: var(--muted); white-space: nowrap; +} +.badge-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: 0 0 auto; } +.badge.up { background: color-mix(in srgb, var(--amber) 20%, transparent); color: var(--amber); border-color: color-mix(in srgb, var(--amber) 35%, transparent); } +.badge.down { background: color-mix(in srgb, var(--red) 20%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 35%, transparent); } +.badge.ok { background: color-mix(in srgb, var(--accent) 20%, transparent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, transparent); } +.badge.no { background: color-mix(in srgb, var(--amber) 18%, transparent); color: var(--amber); border-color: color-mix(in srgb, var(--amber) 32%, transparent); } + +/* ── buttons ── */ +.btn { + appearance: none; + border: 1px solid var(--glass-border); + background: var(--glass-2); + -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); + color: var(--text); + font-size: 14px; font-weight: 600; + padding: 10px 16px; min-height: 44px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: transform .12s var(--ease), box-shadow .18s, background .15s, filter .15s; + text-decoration: none; + display: inline-flex; align-items: center; justify-content: center; gap: 6px; +} +.btn:hover { background: var(--glass-hi); border-color: var(--glass-border); } +.btn:active { transform: scale(.97); } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.btn:disabled { opacity: .5; cursor: default; filter: none; } +.btn-primary { + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: var(--accent-ink); + border-color: transparent; + box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 35%, transparent); +} +.btn-primary:hover { filter: brightness(1.06); box-shadow: var(--glow); } +.btn-danger { background: var(--danger); color: #fff; border-color: transparent; box-shadow: 0 6px 18px color-mix(in srgb, var(--danger) 35%, transparent); } +.btn-danger-ghost { color: var(--red); } +.btn-sm { padding: 7px 12px; min-height: 38px; font-size: 13px; } +.btn-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } +.btn-row.end { justify-content: flex-end; } +.btn-row .btn { flex: 1 1 0; min-width: 0; } +.btn-row.end .btn { flex: 0 0 auto; } + +.section-head { + display: flex; align-items: center; justify-content: space-between; + margin: 26px 0 12px; +} +.section-actions { display: flex; align-items: center; gap: 4px; } +.section-actions .icon-btn { width: 38px; height: 38px; color: var(--muted); } +.section-actions .icon-btn:hover { color: var(--text); } + +/* ── forms ── */ +.form { display: flex; flex-direction: column; gap: 12px; } +.form label { display: flex; flex-direction: column; gap: 5px; font-size: 13px; color: var(--muted); font-weight: 500; } +.form input { + background: var(--glass-2); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + color: var(--text); font-size: 16px; padding: 12px; min-height: 44px; width: 100%; +} +.form input::placeholder { color: var(--muted); } +.form input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; } + +/* ── search ── */ +.search-wrap { position: relative; margin-bottom: 12px; } +.search-ic { + position: absolute; left: 13px; top: 50%; transform: translateY(-50%); + color: var(--muted); pointer-events: none; +} +.search-wrap input { + background: var(--glass); + -webkit-backdrop-filter: blur(var(--blur)); backdrop-filter: blur(var(--blur)); + border: 1px solid var(--glass-border-soft); + border-radius: var(--radius-sm); + color: var(--text); font-size: 16px; padding: 12px 14px 12px 42px; min-height: 44px; width: 100%; +} +.search-wrap input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; } +.search-wrap input::placeholder { color: var(--muted); } + +.list-filter { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 4px 16px; + margin: -4px 0 12px; + font-size: 13px; + color: var(--muted); +} + +.filter-item { + display: flex; + align-items: center; + gap: 8px; +} + +/* ── comment block (client modal) ── */ +.comment-block { border-top: 1px solid var(--glass-border-soft); margin-top: 14px; padding-top: 14px; } +.comment-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; } +.comment-label { + display: inline-flex; align-items: center; gap: 6px; color: var(--muted); + font-size: 13px; text-transform: uppercase; letter-spacing: .5px; +} +.comment-view { font-size: 14px; white-space: pre-wrap; word-break: break-word; } +.comment-editor textarea { + background: var(--glass-2); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + color: var(--text); font-family: inherit; font-size: 15px; line-height: 1.4; + padding: 10px 12px; width: 100%; resize: vertical; +} +.comment-editor textarea:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; } + +/* ── client list ── */ +.client-list { display: flex; flex-direction: column; gap: 12px; } +.client { + display: flex; align-items: center; gap: 12px; width: 100%; + text-align: left; font: inherit; color: inherit; + background: var(--glass); + border: 1px solid var(--glass-border-soft); + -webkit-backdrop-filter: blur(var(--blur)) saturate(140%); + backdrop-filter: blur(var(--blur)) saturate(140%); + border-radius: var(--radius); + padding: 14px 16px; cursor: pointer; + box-shadow: 0 8px 22px rgba(2, 6, 16, .28); + transition: transform .16s var(--ease), border-color .15s, background .15s, box-shadow .18s; +} +.client:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--accent) 45%, var(--glass-border)); box-shadow: var(--glow); } +.client:active { transform: translateY(0) scale(.99); background: var(--glass-2); } +.client:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.client .dot { + position: relative; width: 12px; height: 12px; border-radius: 50%; + background: transparent; border: 2px solid var(--muted); flex: 0 0 auto; +} +/* status conveyed by shape + colour, not colour alone */ +.client .dot.online { + background: var(--amber); border-color: var(--amber); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--amber) 22%, transparent); + animation: livepulse 2s ease-in-out infinite; +} +.client .dot.disabled { border-color: var(--muted); background: transparent; } +.client .dot.disabled::after { + content: ""; position: absolute; inset: 2px 1px; border-top: 2px solid var(--muted); + transform: translateY(2px); +} +.client .info { flex: 1 1 auto; min-width: 0; } +.client .name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.client .sub { font-size: 12px; color: var(--muted); font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums; } +.client .note { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); margin-top: 2px; overflow: hidden; } +.client .note span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.client .traffic { text-align: right; font-size: 12px; color: var(--muted); flex: 0 0 auto; font-variant-numeric: tabular-nums; } +.client .traffic > div { display: flex; align-items: center; justify-content: flex-end; gap: 3px; } +.client .traffic .down { color: var(--rx); } +.client .traffic .up { color: var(--tx); } + +@keyframes livepulse { + 0%, 100% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--amber) 22%, transparent); } + 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--amber) 6%, transparent); } +} + +/* ── skeletons ── */ +.sk { + display: inline-block; border-radius: 6px; + background: linear-gradient(90deg, + color-mix(in srgb, var(--muted) 16%, transparent) 25%, + color-mix(in srgb, var(--muted) 30%, transparent) 37%, + color-mix(in srgb, var(--muted) 16%, transparent) 63%); + background-size: 400% 100%; + animation: shimmer 1.4s ease infinite; +} +.sk-num { width: 40%; height: 28px; } +.client.skeleton { pointer-events: none; animation: none; } +.client.skeleton .sk-dot { width: 12px; height: 12px; border-radius: 50%; } +.client.skeleton .info .sk { display: block; } +.client.skeleton .sk-line1 { width: 45%; height: 12px; margin-bottom: 7px; } +.client.skeleton .sk-line2 { width: 65%; height: 10px; } +.client.skeleton .sk-tr { width: 44px; height: 24px; } +@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } + +/* ── toggle switch ── */ +.switch { + position: relative; display: inline-flex; align-items: center; justify-content: center; + flex: 0 0 auto; width: 46px; height: 44px; cursor: pointer; +} +.switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; } +.switch .slider { + position: relative; width: 46px; height: 28px; + background: var(--glass-2); + border: 1px solid var(--glass-border); + border-radius: 999px; transition: background .18s, border-color .18s; +} +.switch .slider::before { + content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; + border-radius: 50%; background: var(--muted); + transition: transform .18s var(--ease), background .18s; +} +.switch input:checked + .slider { + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + border-color: transparent; + box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 45%, transparent); +} +.switch input:checked + .slider::before { transform: translateX(18px); background: #fff; } +.switch input:focus-visible + .slider { outline: 2px solid var(--accent); outline-offset: 2px; } +.switch input:disabled { cursor: default; } +.switch input:disabled + .slider { opacity: .5; } + +/* ── modal ── */ +.modal { + position: fixed; inset: 0; z-index: 50; + display: flex; align-items: flex-end; justify-content: center; + background: rgba(4, 8, 16, .55); + -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); + padding: 0; animation: fade .15s ease; +} +@media (min-width: 560px) { .modal { align-items: center; padding: 16px; } } +.modal-card { + background: var(--glass-strong); + -webkit-backdrop-filter: blur(28px) saturate(150%); backdrop-filter: blur(28px) saturate(150%); + border: 1px solid var(--glass-border); + border-radius: var(--radius) var(--radius) 0 0; + box-shadow: var(--shadow); + width: 100%; max-width: var(--maxw); + padding: 22px 18px calc(22px + env(safe-area-inset-bottom)); + animation: slideup .26s var(--ease); +} +@media (min-width: 560px) { .modal-card { border-radius: var(--radius); animation: popin .2s var(--ease); } } +.modal-card h2 { margin-bottom: 14px; } + +.qr-wrap { text-align: center; margin: 18px 0; } +.qr-wrap img { + width: 220px; max-width: 70%; + background: #fff; padding: 12px; border-radius: var(--radius-sm); + box-shadow: 0 8px 24px rgba(2, 6, 16, .4); + image-rendering: pixelated; +} + +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes slideup { from { transform: translateY(24px); opacity: .6; } to { transform: translateY(0); opacity: 1; } } +@keyframes popin { from { transform: scale(.96); opacity: .6; } to { transform: scale(1); opacity: 1; } } +@keyframes rowin { from { transform: translateY(8px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } + +/* staggered list entrance (subtle) */ +.client-list .client { animation: rowin .3s var(--ease) both; } + +/* ── toast ── */ +.toast { + position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom)); + transform: translateX(-50%); z-index: 100; + background: var(--glass-strong); + -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); + color: var(--text); border: 1px solid var(--glass-border); + padding: 12px 18px; border-radius: 999px; box-shadow: var(--shadow); + font-size: 14px; max-width: 90%; animation: fade .15s ease; +} +.toast.err { border-color: var(--red); color: var(--red); } +.toast.ok { border-color: var(--accent); } + +.spin { display: inline-block; animation: rot 1s linear infinite; transform-origin: center; } +@keyframes rot { to { transform: rotate(360deg); } } + +/* ── reduced motion ── */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .001ms !important; + animation-iteration-count: 1 !important; + transition-duration: .001ms !important; + } + .sk { animation: none; opacity: .6; } + .blob { animation: none; } +}