init commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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 ./...
|
||||
```
|
||||
@@ -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
Binary file not shown.
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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`).
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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 & 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
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 & 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>
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user