init commit

This commit is contained in:
2026-07-18 10:02:43 +03:00
commit 3b4a1f5388
31 changed files with 6580 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebSearch"
]
}
}
+13
View File
@@ -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
+87
View File
@@ -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"]
+112
View File
@@ -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"]
+315
View File
@@ -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 <repo-url> 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 <repo-url> 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
## Расположение файлов
Пути привязаны к каталогу исполняемого файла (`<dir>`, переопределяется
переменной `AWG_PROFILER_DIR` — так собран Docker-образ, где `<dir>=/data`):
| Назначение | Путь |
|---|---|
| Конфиг профилировщика | `<dir>/awg_config` (формат shell `KEY="value"`) |
| Реестр клиентов | `<dir>/data/awg_clients.json` |
| Накопленная статистика трафика | `<dir>/data/awg_stats.json` |
| Профили клиентов | `<dir>/awg_clients/<name>.conf` + `.png` (QR) |
| Флаг «зависимости установлены» | `<dir>/awg_state.json` |
| Interface conf / nft-правила | `/etc/amnezia/amneziawg/<iface>.conf`, `<iface>-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-dir>/<iface>.conf из реестра
клиентов и (по подтверждению) перезапустить службу
CLIENT MANAGEMENT
create <name> Создать клиента: ключи, .conf, QR-код
delete <id> Удалить клиента (файлы + запись в реестре)
disable <id> Перевести клиента в статус DISABLED
enable <id> Перевести клиента в статус 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 <iface> dump` и
сопоставляются с реестром по публичному ключу.
- **Мастер настройки** — если сервер ещё не инициализирован, UI показывает
форму `install-deps``init-server` (неинтерактивные аналоги CLI-команд).
UI построен как одностраничное приложение на «ванильном» JS/CSS (без внешних
зависимостей — работает офлайн), mobile-first, с обновлением статистики каждые
10 c. По умолчанию всегда тёмная тема (см. `--ui-mode` выше).
### Накопление статистики (переживает перезапуск)
`awg show <iface> 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 ./...
```
+241
View File
@@ -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 <iface>.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-dir>/<iface>.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-dir>/<iface>.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 <conf-dir>/<iface>-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)
}
Executable
BIN
View File
Binary file not shown.
+265
View File
@@ -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, "<hidden>") {
t.Errorf("expected <hidden> 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 <form>
// 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
}
+242
View File
@@ -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 <name>.conf plus a scannable <name>.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)
}
+187
View File
@@ -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)
}
}
+94
View File
@@ -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)")
}
+39
View File
@@ -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:
+37
View File
@@ -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:
+38
View File
@@ -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" <<EOF
{
"deps_installed": true,
"installed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"os_id": "linuxmint",
"os_version": "${OS_VERSION:-container}"
}
EOF
chmod 0600 "$STATE_FILE"
fi
# Enable forwarding so WG→internet routing works. Requires NET_ADMIN; ignore
# failure (e.g. read-only sysctl) — the operator can also pass --sysctl.
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
exec /usr/local/bin/awg_profiler "$@"
+35
View File
@@ -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" <<EOF
{
"deps_installed": true,
"installed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"os_id": "alpine",
"os_version": "container"
}
EOF
chmod 0600 "$STATE_FILE"
fi
# Enable forwarding so WG→internet routing works. Requires NET_ADMIN; ignore
# failure (e.g. read-only sysctl) — the operator can also pass --sysctl.
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
exec /usr/local/bin/awg_profiler "$@"
+3
View File
@@ -0,0 +1,3 @@
module awgprofiler
go 1.26
+187
View File
@@ -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 <command> [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-dir>/<iface>.conf from client
registry and optionally restart the service
CLIENT MANAGEMENT
create <name> Create new VPN client; generates keys, .conf and QR
code
delete <id> Remove client by ID (deletes files + registry entry)
disable <id> Set client status to DISABLED
enable <id> 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)
}
}
+76
View File
@@ -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
}
+123
View File
@@ -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()
}
+211
View File
@@ -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 `<form>` 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
`<name>.conf` пишется с 0600, а `<name>.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` — на этом этапе
клиент уже сохранён в реестре и добавлен в `<iface>.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`).
+274
View File
@@ -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/<interface>.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-dir>/<iface>.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 <name>' 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.../<hidden>/).
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] + " <hidden>"
}
}
}
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")
}
}
+252
View File
@@ -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 <iface> 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 <iface> 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)
}
}
+376
View File
@@ -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 = <ip>/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
}
+583
View File
@@ -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 <html data-theme="…"> 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="<mode>" onto the <html> tag of the served
// index.html so the stylesheet resolves the palette before first paint. It
// keys off the `<html lang="en"` prefix both theme shells share; if that
// anchor is ever renamed the page still works (it just falls back to CSS
// defaults), so the replace is best-effort.
func injectUIMode(html []byte, mode string) []byte {
const anchor = `<html lang="en"`
attr := fmt.Sprintf(`<html lang="en" data-theme=%q`, mode)
return []byte(strings.Replace(string(html), anchor, attr, 1))
}
// opLock serialises all mutating operations (create/delete/enable/disable/sync
// /server control). The underlying registry + interface state are shared, so we
// process one change at a time to avoid races between concurrent requests.
var opLock sync.Mutex
// runWeb starts the management web server. It is deliberately the only place
// that flips webMode on, so every die() reached from a handler becomes a
// recoverable panic instead of killing the process.
func runWeb(args []string) {
addr := "127.0.0.1:8080"
// Theme precedence: --theme flag > 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 <html> 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
// <form> 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,
})
}
+152
View File
@@ -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)
}
}
+591
View File
@@ -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 = '<svg class="ic ic-sm spin" aria-hidden="true"><use href="#i-refresh"/></svg> 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 = '<svg class="ic ic-sm spin" aria-hidden="true"><use href="#i-refresh"/></svg> 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 =
'<span class="sk sk-dot"></span>' +
'<div class="info"><div class="sk sk-line1"></div><div class="sk sk-line2"></div></div>' +
'<span class="sk sk-tr"></span>';
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 = '<span class="badge-dot"></span>UP'; b.className = "badge up"; }
else { b.innerHTML = '<span class="badge-dot"></span>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 = `
<span class="dot"></span>
<div class="info">
<div class="name"></div>
<div class="sub"></div>
<div class="note hidden"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-message"/></svg><span></span></div>
</div>
<div class="traffic">
<div class="down"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-arrow-down"/></svg><span></span></div>
<div class="up"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-arrow-up"/></svg><span></span></div>
</div>
<label class="switch">
<input type="checkbox" />
<span class="slider"></span>
</label>`;
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
? '<span class="badge up">Online</span>'
: '<span class="badge">Active · idle</span>';
} else {
st.innerHTML = '<span class="badge no">Disabled</span>';
}
$("#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();
+208
View File
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0F172A" />
<title>AmneziaWG Profiler</title>
<link rel="stylesheet" href="style.css" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2322C55E' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/%3E%3C/svg%3E" />
</head>
<body>
<!-- ── inline SVG icon sprite (Lucide geometry; no external CDN) ────────────── -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="M6 6l12 12"/></symbol>
<symbol id="i-message" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol>
<symbol id="i-arrow-down" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></symbol>
<symbol id="i-arrow-up" viewBox="0 0 24 24"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.3-4.3"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M6 3l14 9-14 9V3z"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></symbol>
<symbol id="i-rotate" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M10 4H6v16h4z"/><path d="M18 4h-4v16h4z"/></symbol>
</svg>
<main id="app">
<!-- Loading placeholder -->
<div id="loading" class="center muted">
<svg class="ic ic-lg spin" aria-hidden="true"><use href="#i-refresh" /></svg>
<div>Loading…</div>
</div>
<!-- Setup wizard (shown when server not initialised) -->
<section id="setup" class="view hidden">
<h1>Server setup</h1>
<div class="card">
<div class="card-head">
<h2>1 · Install dependencies</h2>
<span id="depsBadge" class="badge"></span>
</div>
<p class="muted">Installs AmneziaWG, qrencode, jq and nftables for this OS.</p>
<button id="installDepsBtn" class="btn btn-primary">Install dependencies</button>
</div>
<div class="card" id="initCard">
<div class="card-head"><h2>2 · Initialise server</h2></div>
<p class="muted">Generates keys &amp; obfuscation parameters, writes configs and starts the service.</p>
<form id="initForm" class="form">
<label>Interface <input name="interface" placeholder="awg0" /></label>
<label>Network CIDR <input name="network" placeholder="10.0.0.0/24" /></label>
<label>Listen port <input name="port" inputmode="numeric" placeholder="51820" /></label>
<label>Public IP / host <input name="public_ip" placeholder="auto-detect" /></label>
<label>Client DNS <input name="dns" placeholder="1.1.1.1" /></label>
<label>MTU <input name="mtu" inputmode="numeric" placeholder="1420" /></label>
<button type="submit" class="btn btn-primary">Initialise server</button>
</form>
</div>
</section>
<!-- Dashboard -->
<section id="dashboard" class="view hidden">
<div class="stat-grid">
<div class="stat">
<div class="stat-val" id="stTotal"><span class="sk sk-num"></span></div>
<div class="stat-label">Clients</div>
</div>
<div class="stat">
<div class="stat-val" id="stActive"><span class="sk sk-num"></span></div>
<div class="stat-label">Active</div>
</div>
<div class="stat">
<div class="stat-val stat-online" id="stOnline"><span class="sk sk-num"></span></div>
<div class="stat-label">Online</div>
</div>
</div>
<div class="card">
<div class="card-head">
<h2>Server</h2>
<span id="ifaceBadge" class="badge"><span class="badge-dot"></span></span>
</div>
<dl class="kv">
<dt>Interface</dt><dd id="svIface"></dd>
<dt>Endpoint</dt><dd id="svEndpoint" class="mono"></dd>
<dt>Network</dt><dd id="svNetwork" class="mono"></dd>
<dt>Public key</dt><dd id="svKey" class="mono ellipsis"></dd>
</dl>
<div class="btn-row">
<button class="btn" data-server="start"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-play" /></svg>Start</button>
<button class="btn" data-server="restart"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-rotate" /></svg>Restart</button>
<button class="btn btn-danger-ghost" data-server="stop"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-stop" /></svg>Stop</button>
<button class="btn" id="syncBtn"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-refresh" /></svg>Sync</button>
</div>
</div>
<div class="section-head">
<h2>Clients</h2>
<div class="section-actions">
<button id="refreshBtn" class="icon-btn" title="Refresh" aria-label="Refresh">
<svg class="ic" aria-hidden="true"><use href="#i-refresh" /></svg>
</button>
<button id="createBtn" class="btn btn-primary btn-sm"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-plus" /></svg>New</button>
</div>
</div>
<div class="search-wrap">
<svg class="ic search-ic" aria-hidden="true"><use href="#i-search" /></svg>
<input id="clientSearch" type="search" autocomplete="off" spellcheck="false"
placeholder="Search by name, IP or comment…" aria-label="Search clients" />
</div>
<div class="list-filter">
<span class="filter-item">
<span>Hide offline</span>
<label class="switch" title="Hide offline clients">
<input type="checkbox" id="hideOffline" aria-label="Hide offline clients" />
<span class="slider"></span>
</label>
</span>
<span class="filter-item">
<span>Hide disabled</span>
<label class="switch" title="Hide disabled clients">
<input type="checkbox" id="hideDisabled" aria-label="Hide disabled clients" />
<span class="slider"></span>
</label>
</span>
</div>
<div id="clientList" class="client-list"></div>
<div id="clientEmpty" class="center muted hidden">No clients yet — create one.</div>
<div id="clientNoMatch" class="center muted hidden">No clients match your filters.</div>
</section>
</main>
<!-- Create client modal -->
<div id="createModal" class="modal hidden">
<div class="modal-card">
<h2>New client</h2>
<form id="createForm" class="form">
<label>Name
<input name="name" autocomplete="off" placeholder="phone" required />
</label>
<div class="btn-row end">
<button type="button" class="btn" data-close>Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
<!-- Client detail / QR modal -->
<div id="detailModal" class="modal hidden">
<div class="modal-card">
<div class="card-head">
<h2 id="dName">Client</h2>
<button class="icon-btn" data-close aria-label="Close">
<svg class="ic" aria-hidden="true"><use href="#i-x" /></svg>
</button>
</div>
<dl class="kv">
<dt>Status</dt><dd id="dStatus"></dd>
<dt>IP</dt><dd id="dIP" class="mono"></dd>
<dt>Endpoint</dt><dd id="dEndpoint" class="mono"></dd>
<dt>Handshake</dt><dd id="dHandshake"></dd>
<dt>Received</dt><dd id="dRx" class="mono"></dd>
<dt>Sent</dt><dd id="dTx" class="mono"></dd>
</dl>
<div class="stats-meta">
<span class="muted">Stats since <span id="dSince"></span></span>
<button id="dResetStats" type="button" class="btn btn-sm">
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-rotate" /></svg>Reset stats
</button>
</div>
<div class="comment-block">
<div class="comment-head">
<span class="comment-label">
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-message" /></svg>Comment
</span>
<button id="dCommentEdit" type="button" class="btn btn-sm">Edit</button>
</div>
<div id="dCommentView" class="comment-view muted"></div>
<div id="dCommentEditor" class="comment-editor hidden">
<textarea id="dCommentText" rows="2" maxlength="500"
placeholder="Add a note for this profile…"></textarea>
<div class="btn-row end">
<button id="dCommentCancel" type="button" class="btn btn-sm">Cancel</button>
<button id="dCommentSave" type="button" class="btn btn-primary btn-sm">Save</button>
</div>
</div>
</div>
<div class="qr-wrap"><img id="dQR" alt="Client configuration QR code" /></div>
<div class="btn-row">
<a id="dDownload" class="btn btn-primary" download><svg class="ic ic-sm" aria-hidden="true"><use href="#i-download" /></svg>Download .conf</a>
<button id="dDelete" class="btn btn-danger"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-trash" /></svg>Delete</button>
</div>
</div>
</div>
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>
+548
View File
@@ -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 <html> 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; }
}
+591
View File
@@ -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 = '<svg class="ic ic-sm spin" aria-hidden="true"><use href="#i-refresh"/></svg> 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 = '<svg class="ic ic-sm spin" aria-hidden="true"><use href="#i-refresh"/></svg> 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 =
'<span class="sk sk-dot"></span>' +
'<div class="info"><div class="sk sk-line1"></div><div class="sk sk-line2"></div></div>' +
'<span class="sk sk-tr"></span>';
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 = '<span class="badge-dot"></span>UP'; b.className = "badge up"; }
else { b.innerHTML = '<span class="badge-dot"></span>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 = `
<span class="dot"></span>
<div class="info">
<div class="name"></div>
<div class="sub"></div>
<div class="note hidden"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-message"/></svg><span></span></div>
</div>
<div class="traffic">
<div class="down"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-arrow-down"/></svg><span></span></div>
<div class="up"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-arrow-up"/></svg><span></span></div>
</div>
<label class="switch">
<input type="checkbox" />
<span class="slider"></span>
</label>`;
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
? '<span class="badge up">Online</span>'
: '<span class="badge">Active · idle</span>';
} else {
st.innerHTML = '<span class="badge no">Disabled</span>';
}
$("#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();
+215
View File
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en" data-theme-variant="glass">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0C0F16" />
<title>AmneziaWG Profiler · Glass</title>
<link rel="stylesheet" href="style.css" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2338BDF8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z'/%3E%3C/svg%3E" />
</head>
<body>
<!-- ── ambient background: gradient base + drifting colour blobs ─────────────── -->
<div class="bg-field" aria-hidden="true">
<span class="blob blob-a"></span>
<span class="blob blob-b"></span>
<span class="blob blob-c"></span>
</div>
<!-- ── inline SVG icon sprite (Lucide geometry; no external CDN) ────────────── -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="M6 6l12 12"/></symbol>
<symbol id="i-message" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol>
<symbol id="i-arrow-down" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></symbol>
<symbol id="i-arrow-up" viewBox="0 0 24 24"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.3-4.3"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M6 3l14 9-14 9V3z"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></symbol>
<symbol id="i-rotate" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M10 4H6v16h4z"/><path d="M18 4h-4v16h4z"/></symbol>
</svg>
<main id="app">
<!-- Loading placeholder -->
<div id="loading" class="center muted">
<svg class="ic ic-lg spin" aria-hidden="true"><use href="#i-refresh" /></svg>
<div>Loading…</div>
</div>
<!-- Setup wizard (shown when server not initialised) -->
<section id="setup" class="view hidden">
<h1>Server setup</h1>
<div class="card glass">
<div class="card-head">
<h2>1 · Install dependencies</h2>
<span id="depsBadge" class="badge"></span>
</div>
<p class="muted">Installs AmneziaWG, qrencode, jq and nftables for this OS.</p>
<button id="installDepsBtn" class="btn btn-primary">Install dependencies</button>
</div>
<div class="card glass" id="initCard">
<div class="card-head"><h2>2 · Initialise server</h2></div>
<p class="muted">Generates keys &amp; obfuscation parameters, writes configs and starts the service.</p>
<form id="initForm" class="form">
<label>Interface <input name="interface" placeholder="awg0" /></label>
<label>Network CIDR <input name="network" placeholder="10.0.0.0/24" /></label>
<label>Listen port <input name="port" inputmode="numeric" placeholder="51820" /></label>
<label>Public IP / host <input name="public_ip" placeholder="auto-detect" /></label>
<label>Client DNS <input name="dns" placeholder="1.1.1.1" /></label>
<label>MTU <input name="mtu" inputmode="numeric" placeholder="1420" /></label>
<button type="submit" class="btn btn-primary">Initialise server</button>
</form>
</div>
</section>
<!-- Dashboard -->
<section id="dashboard" class="view hidden">
<div class="stat-grid">
<div class="stat glass">
<div class="stat-val" id="stTotal"><span class="sk sk-num"></span></div>
<div class="stat-label">Clients</div>
</div>
<div class="stat glass">
<div class="stat-val" id="stActive"><span class="sk sk-num"></span></div>
<div class="stat-label">Active</div>
</div>
<div class="stat glass stat-online-tile">
<div class="stat-val stat-online" id="stOnline"><span class="sk sk-num"></span></div>
<div class="stat-label">Online</div>
</div>
</div>
<div class="card glass">
<div class="card-head">
<h2>Server</h2>
<span id="ifaceBadge" class="badge"><span class="badge-dot"></span></span>
</div>
<dl class="kv">
<dt>Interface</dt><dd id="svIface"></dd>
<dt>Endpoint</dt><dd id="svEndpoint" class="mono"></dd>
<dt>Network</dt><dd id="svNetwork" class="mono"></dd>
<dt>Public key</dt><dd id="svKey" class="mono ellipsis"></dd>
</dl>
<div class="btn-row">
<button class="btn" data-server="start"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-play" /></svg>Start</button>
<button class="btn" data-server="restart"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-rotate" /></svg>Restart</button>
<button class="btn btn-danger-ghost" data-server="stop"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-stop" /></svg>Stop</button>
<button class="btn" id="syncBtn"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-refresh" /></svg>Sync</button>
</div>
</div>
<div class="section-head">
<h2>Clients</h2>
<div class="section-actions">
<button id="refreshBtn" class="icon-btn" title="Refresh" aria-label="Refresh">
<svg class="ic" aria-hidden="true"><use href="#i-refresh" /></svg>
</button>
<button id="createBtn" class="btn btn-primary btn-sm"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-plus" /></svg>New</button>
</div>
</div>
<div class="search-wrap">
<svg class="ic search-ic" aria-hidden="true"><use href="#i-search" /></svg>
<input id="clientSearch" type="search" autocomplete="off" spellcheck="false"
placeholder="Search by name, IP or comment…" aria-label="Search clients" />
</div>
<div class="list-filter">
<span class="filter-item">
<span>Hide offline</span>
<label class="switch" title="Hide offline clients">
<input type="checkbox" id="hideOffline" aria-label="Hide offline clients" />
<span class="slider"></span>
</label>
</span>
<span class="filter-item">
<span>Hide disabled</span>
<label class="switch" title="Hide disabled clients">
<input type="checkbox" id="hideDisabled" aria-label="Hide disabled clients" />
<span class="slider"></span>
</label>
</span>
</div>
<div id="clientList" class="client-list"></div>
<div id="clientEmpty" class="center muted hidden">No clients yet — create one.</div>
<div id="clientNoMatch" class="center muted hidden">No clients match your filters.</div>
</section>
</main>
<!-- Create client modal -->
<div id="createModal" class="modal hidden">
<div class="modal-card glass">
<h2>New client</h2>
<form id="createForm" class="form">
<label>Name
<input name="name" autocomplete="off" placeholder="phone" required />
</label>
<div class="btn-row end">
<button type="button" class="btn" data-close>Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
<!-- Client detail / QR modal -->
<div id="detailModal" class="modal hidden">
<div class="modal-card glass">
<div class="card-head">
<h2 id="dName">Client</h2>
<button class="icon-btn" data-close aria-label="Close">
<svg class="ic" aria-hidden="true"><use href="#i-x" /></svg>
</button>
</div>
<dl class="kv">
<dt>Status</dt><dd id="dStatus"></dd>
<dt>IP</dt><dd id="dIP" class="mono"></dd>
<dt>Endpoint</dt><dd id="dEndpoint" class="mono"></dd>
<dt>Handshake</dt><dd id="dHandshake"></dd>
<dt>Received</dt><dd id="dRx" class="mono"></dd>
<dt>Sent</dt><dd id="dTx" class="mono"></dd>
</dl>
<div class="stats-meta">
<span class="muted">Stats since <span id="dSince"></span></span>
<button id="dResetStats" type="button" class="btn btn-sm">
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-rotate" /></svg>Reset stats
</button>
</div>
<div class="comment-block">
<div class="comment-head">
<span class="comment-label">
<svg class="ic ic-sm" aria-hidden="true"><use href="#i-message" /></svg>Comment
</span>
<button id="dCommentEdit" type="button" class="btn btn-sm">Edit</button>
</div>
<div id="dCommentView" class="comment-view muted"></div>
<div id="dCommentEditor" class="comment-editor hidden">
<textarea id="dCommentText" rows="2" maxlength="500"
placeholder="Add a note for this profile…"></textarea>
<div class="btn-row end">
<button id="dCommentCancel" type="button" class="btn btn-sm">Cancel</button>
<button id="dCommentSave" type="button" class="btn btn-primary btn-sm">Save</button>
</div>
</div>
</div>
<div class="qr-wrap"><img id="dQR" alt="Client configuration QR code" /></div>
<div class="btn-row">
<a id="dDownload" class="btn btn-primary" download><svg class="ic ic-sm" aria-hidden="true"><use href="#i-download" /></svg>Download .conf</a>
<button id="dDelete" class="btn btn-danger"><svg class="ic ic-sm" aria-hidden="true"><use href="#i-trash" /></svg>Delete</button>
</div>
</div>
</div>
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>
+478
View File
@@ -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; }
}