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