- New in-memory login throttle (handler/login_throttle.go): 5 failed
attempts per IP or per username within 5 minutes locks that key out for
5 minutes, applied to both /login and the TOTP verification step, which
previously had no rate limiting at all
- router.New now adds middleware.Secure with X-Frame-Options,
X-Content-Type-Options, Referrer-Policy, and HSTS (only when cookies
are Secure, implying an HTTPS deployment). No CSP: the existing
templates rely on inline <script> blocks, so a CSP strict enough to
matter would need 'unsafe-inline' anyway
- All session/auth cookies now set Secure based on the new
--cookie-secure flag / WGUI_COOKIE_SECURE env var (default true) so the
session cookie is never sent over plain HTTP unless explicitly opted
into an HTTP-only LAN deployment
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATVUwTa4Pqwq26orW5BcDW
A client CIDR with nonzero host bits (e.g. 10.66.120.1/30 instead of the
network 10.66.120.0/30) is tolerated by wg's own AllowedIPs/setconf, which
just warns, but plain iproute2 rejects it with "Invalid prefix for given
prefix length" and aborts the whole PostUp chain, taking the interface
down on start. Added a routeNet template func that clears host bits via
net.ParseCIDR before generating each PostUp route line.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATVUwTa4Pqwq26orW5BcDW
A server brought up via the UI stayed active but not enabled, so a reboot
silently dropped it (and its PostUp cross-tunnel routes) with no error to
point at. Start now runs enable --now, Stop runs disable --now, so
"running now" and "survives a reboot" are the same action.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATVUwTa4Pqwq26orW5BcDW
- ServerSetting gains WanInterface/EgressSNATIP for optional per-server
masquerade/SNAT of client traffic, isolated in each server's own
nftables table
- wireguard.Start/Restart now ensure net.ipv4.ip_forward and
net.ipv6.conf.all.forwarding are enabled before bringing an interface up
- OPNsense config.xml import now parses staticroutes/filter/nat rules and
surfaces them as a manual-review checklist in the preview UI (never
auto-applied)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATVUwTa4Pqwq26orW5BcDW
wg-quick's own AllowedIPs route handling does a plain `ip route add`
per entry into the main table and hard-fails the whole interface
bring-up with "RTNETLINK: File exists" the moment two servers share
an overlapping AllowedIPs entry (real case: a client's transit IP
duplicated across 4 imported servers). Set Table = off and generate
`ip route replace` PostUp commands instead - idempotent, never fails
on a pre-existing route, and multiple servers can coexist even when
their peers' AllowedIPs overlap.
Also make ServerServiceRestart write the config to disk first, same
as ServerServiceStart already does - it had the same "config file
missing" failure mode.
wg-quick refused to start when /etc/wireguard/<iface>.conf didn't
exist yet - the case for any newly created or imported server before
someone manually hits "Apply Config". ServerServiceStart now
regenerates the config from current DB state first, reusing the same
logic ApplyServerConfig already had (extracted into
writeServerConfigToDisk).
- TOTP (RFC 6238, stdlib-only) enrollment in profile, login step-up,
admin emergency reset.
- Admins can grant a user visibility into individual clients
(User.ClientIDs) in addition to whole-server access (User.ServerIDs).
- New "My Access" page: non-admin users see only their assigned clients
(view/QR/download only, no management), reachable from the main nav.
- GetUser/GetUsers now redact TOTPSecret before returning JSON.
No Go toolchain was available while writing this - not yet build-verified.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PvrfUytqd74H6WcQkRzFM4
Per-server Start/Stop/Restart via systemd wg-quick@<iface>.service units
plus live status badge, and a table/list-view toggle with checkbox
bulk-delete for both the server list and per-server client list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PvrfUytqd74H6WcQkRzFM4
Real config.xml root is the lowercase <opnsense> element (the whole
firewall config); plugin/core model data like WireGuard lives nested
inside a separate, capitalized <OPNsense> child element. The parser
was matching the capitalized name as the document root, so every real
export failed with "expected element type but have ...".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VjwLYRA87o8m9a9zztgs3
Redact the private key from the /servers/:id/keypair response body -
the UI never rendered it, but the raw key was still returned over the
wire (json:"private_key,omitempty" plus explicit clearing before the
JSON response).
Add a new import flow: an admin can upload an OPNsense config.xml,
preview the WireGuard servers/clients it defines (editable before
committing), and confirm to create the corresponding
Server/ServerSetting/Client records. Nothing is auto-applied - no
wg-quick/systemctl call happens, matching the existing manual "Apply"
step for regular server management.
Schema verified against OPNsense core (WireGuard has been in core
since 22.1, not a plugin) - see opnsense/parse.go for the confirmed
tag reference. Public keys are always re-derived from private keys
rather than trusted from the export; client public-key collisions
against existing store data are skipped and reported per-batch rather
than aborting the whole import.
Since OPNsense stores DNS/MTU per-server and keepalive per-client, but
this fork only had those app-wide (GlobalSetting), extended
ServerSetting with DNSServers/MTU and Client with PersistentKeepalive
as optional overrides that fall back to the global default when unset
- existing single-server behavior is unchanged when the override is
empty/zero. Manual UI editing of the per-client keepalive override
outside the import flow is left for a later pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VjwLYRA87o8m9a9zztgs3
GetClients, GetClientByID, and SendRequestedConfigsToTelegram all
looked up the legacy "wg0" server unconditionally when building a
client's QR code / config, ignoring which server the client actually
belongs to. On any multi-server setup without a migrated wg0, the
server lookup returned a zero-value model.Server (error discarded),
and BuildClientConfig then dereferenced its nil KeyPair pointer,
panicking whenever a client's QR code was rendered.
Now looks up the client's own ServerID (falling back to wg0 only for
legacy clients with no ServerID set) and surfaces the lookup error
instead of silently continuing with an empty server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SuggestIPAllocation always looked up the legacy "wg0" server regardless
of which server the request was for, so any setup without a migrated
wg0 (i.e. every fresh multi-server install) failed with
"open db/servers/wg0.json: no such file or directory" when adding a
new client.
Now accepts an optional server_id query param (falling back to wg0 for
the legacy bare routes), and the per-server clients page passes its
own server ID.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Escape HTML in client list and wake-on-LAN names to prevent XSS
- Log successful/failed login attempts with remote address
- Fix leading-comma bug in AllowedIPs template when only extra allowed IPs are set
- Add PreUp script support for server interfaces (alongside existing PostUp/PreDown/PostDown)
- Fix endpoint parsing to support IPv6 addresses (upstream PR #223)
Cherry-picked from upstream PRs #656, #653, #680, #673, #223.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New firewall.DisableGlobal() removes the wireguard_ui_global nftables
table without touching stored IP list entries, and firewall.IsGlobalEnabled()
reports whether it's currently loaded. New GET /firewall-lists/status and
POST /firewall-lists/disable endpoints (admin-only), plus a status badge
and "Toggle enable/disable" button on the Global Firewall Lists page -
one click to turn the whole thing off without losing the list contents,
and back on again (re-applies the current ruleset).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
auto-merge is a standalone set statement, not an nft "flags" value -
"flags interval, auto-merge;" is a syntax error; fixed to "flags
interval;" followed by "auto-merge" on its own line. Needed because large
public blocklists (FireHOL, Spamhaus) contain overlapping CIDRs that
nftables otherwise refuses as "conflicting intervals".
Also add a hard guard: reject any "block" entry that overlaps a private/
reserved/bogon range (RFC1918, CGNAT, loopback, link-local, etc.) in both
the single-entry and bulk-import paths. Public feeds like FireHOL level1
routinely include ranges like 10.0.0.0/8 and 172.16.0.0/12, meant for
WAN-only edge firewalls - applied host-wide here (where WireGuard/LAN
subnets legitimately live in that same private space), those entries
would silently block a server's own internal/VPN traffic instead of
actual bad actors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moved two real OPNsense firewall/NAT rule exports (real internal IPs and
ports) into private/ so they can stay on disk for reference without ever
being committed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
scribble.ReadAll only returns its own ErrMissingCollection when the
collection name is empty - when the collection's directory simply
doesn't exist yet (e.g. before the first firewall rule or IP list entry
is ever created), it returns a raw os.IsNotExist error instead, which
GetFirewallRules/GetIPListEntries/GetServers didn't handle, surfacing as
"open db/ip_list_entries: no such file or directory" on a fresh install.
New isMissingCollectionErr() helper treats both cases as "empty
collection", not a real failure.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Paste-in import: one CIDR/IP per line, optional comment after '#',
blank/comment-only lines ignored - matches the format used by common
public blocklist feeds (Spamhaus DROP, blocklist.de, etc.) so those can
mostly be pasted in directly. Whole batch gets one list_type (allow or
block). Reuses the existing single-entry validation, skips duplicates
(by list_type+CIDR, including within the same paste), caps at 5000 lines,
and reports imported/skipped/invalid counts plus per-line errors.
New route: POST /firewall-lists/entries/import (admin-only). UI: a
collapsible "Bulk import" section on the Global Firewall Lists page.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New model.IPListEntry + jsondb CRUD, independent of any single WireGuard
server. firewall.GenerateGlobalRuleset builds an nftables table
(wireguard_ui_global) with allow/block sets evaluated at priority -10 -
before every per-server table - so it applies to all traffic on the host,
not just WireGuard. Allow entries always win over block entries.
firewall.ApplyGlobal loads it live via `nft -f`, scoped to that one table.
New "Global Firewall Lists" page (nav entry under Settings): add/delete
entries, ruleset preview, "Apply now (live)" with an explicit confirm()
warning since this affects the whole host's firewall, not just one server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New system package checks `apt list --upgradable` against the current
package index (no apt update triggered) and the reboot-required marker
file. Read-only - never installs or upgrades anything. Shown as a card
on the About page with a manual "Check now" refresh.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New model.FirewallRule + jsondb CRUD (GetFirewallRules/CreateFirewallRule/
UpdateFirewallRule/DeleteFirewallRule), scoped per server. firewall package
now generates a full ruleset (baseline + enabled custom rules) and can
apply it live via `nft -f` (firewall.Apply), scoped to a per-server
nftables table (wireguard_ui_<serverID>) so applying one server never
touches another server's rules or any pre-existing firewall state.
New endpoints: GET/POST /servers/:id/firewall/rules, POST .../rules/:ruleId,
POST .../rules/:ruleId/delete, POST .../apply (live, admin-only). UI in the
All Servers page: rule table with add/delete, ruleset preview, and an
"Apply now (live)" button with an explicit confirm() warning before it
touches the running firewall.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New firewall package generates an nftables snippet (INPUT accept for the
listen port, FORWARD rules for the WireGuard interface, optional LAN
forwarding via a new ServerSetting.LanInterface field). Text only -
nothing is applied to the live firewall. Exposed as GET
/servers/:id/firewall-preview and a "Firewall Preview" button in the
All Servers page.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DeleteServer already existed in the store layer (refuses if clients
still reference the server) but was never wired up anywhere, so there
was no way to actually remove a server from the UI or API.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Removed the release/contributor/author lookups against
ngoduykhanh/wireguard-ui - this fork no longer tracks that repo's
releases, so those calls were meaningless here and leaked an external
API dependency. Replaced with a short own-product description.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deletes /wg-server (route, handlers, template) entirely and adds generic
/servers/:id/interface and /servers/:id/keypair endpoints + UI in the
All Servers page, so every server (including the default one) is managed
through the same per-server registry. Drops the write-through dual-write
hacks that kept the old single-server collection in sync - the registry
is now the single source of truth. One-time legacy-install migration path
is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No route/logic changes - pure label cleanup so the legacy default-server
editor and the multi-server management page are no longer confusable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New POST /backup/download (admin-only) tars the whole jsondb directory
(all servers/clients/users/settings) and streams it back as a file
download. If a passphrase is given, the archive is encrypted first
(AES-256-GCM, scrypt-derived key, backup/encrypt.go) - a small
self-contained format, not gpg/OpenPGP-compatible, to avoid shelling
out to an external binary or adding a PGP dependency.
Deliberately does NOT upload anywhere automatically (e.g. to
Nextcloud) - the archive only ever leaves the server as this one HTTP
response to the requesting admin, who is responsible for storing it
themselves. New "Download Backup" button + passphrase modal on the
Global Settings page.
New servers previously got a bare ConfigFilePath default with no way
to set/edit EndpointAddress, FirewallMark, or Table afterward. Adds
GET/POST /servers/:id/settings (view/save ServerSetting, admin-only
for writes) and a "Settings" button + modal on each server card in
servers.html. CreateServer now seeds FirewallMark/Table with the same
defaults the legacy single-server bootstrap uses, instead of leaving
them blank.
Real per-server data isolation, the core ask behind the access-control
work: clients, config generation, and the client-management UI are now
scoped by server ID instead of implicitly operating on one global
"the server".
- util.DefaultServerID ("wg0") is the server every legacy bare route
now resolves to, so old and new routes share one consistent identity
instead of drifting apart.
- New /servers/:id/... routes (new-client, update-client, remove-client,
set-status, download, api/clients, api/client/:cid, api/apply-wg-config)
reuse the same handlers as the legacy routes via resolveServerID(c),
gated by RequireServerAccess middleware. Cross-server edits/deletes on
scoped routes are rejected (403) if a client belongs to a different
server.
- Fixes a real data leak: ApplyServerConfig previously wrote ALL clients
from ALL servers into whichever single wg.conf it targeted. It now
filters clients by server ID before generating a config, and resolves
each server's own ConfigFilePath/EndpointAddress via the new
ServerSetting record instead of the app-wide GlobalSetting.
- WireGuardServerInterfaces/WireGuardServerKeyPair/GlobalSettingSubmit
(the legacy /wg-server and /global-settings edit routes) now write
through to the new per-server registry record for "wg0" in addition
to the legacy collection, so the two stay in sync until the legacy
routes are eventually retired.
- New templates/server_clients.html: per-server clone of clients.html
wired to the scoped endpoints, with a server name/id heading.
- base.html's shared "New Client" and "Apply Config" actions (used by
every page's nav buttons) now target the scoped route when a
serverID is present on the page, instead of always hitting the
legacy default-server endpoint regardless of which server's client
page is open.
Legacy bare routes (/, /new-client, /wg-server, ...) are untouched and
still fully functional against the default "wg0" server - nothing was
removed yet, per the incremental-delivery approach for this project.
Removes the entire upstream-repo documentation section (Docker
compose, Docker build/env vars, Docker Hub links, upstream repo
links) - this fork is native-only, so those sections were dead
weight and confusing. Fixes BIND_ADDRESS default in the env var
table (was documented as 0.0.0.0:80, actual default in this fork's
systemd unit is 0.0.0.0:5000). License now points at the local
LICENSE file instead of the upstream repo.
obj.name is free text (unlike id/interface, which are already
regex-validated server-side) and was being template-literal'd
straight into innerHTML - stored-XSS risk if a server name contains
markup. Escape via jQuery .text()/.html() round-trip.
New admin-only page at /servers-settings (templates/servers.html) lists
servers and creates new ones via POST /servers (ID/name/interface/
addresses/port, key pair generated server-side). Nav gets a "Servers"
link.
templates/users_settings.html gains a multi-select "Server Access"
field wired to the server_ids support added to create-user/update-user
in the previous commit, so admins can now actually assign non-admin
users to specific servers through the UI.
Non-admin users are now restricted to servers explicitly listed in
their new ServerIDs field; empty means no access (secure by default).
Admins always have full access. Migration backfills existing users'
ServerIDs with the migrated legacy server so nobody is locked out on
upgrade. New RequireServerAccess middleware enforces this on
/servers/:id/... routes (applied to GET /servers/:id/clients so far);
GET /servers also filters its list for non-admins.
GET /servers lists registered servers; GET /servers/:id/clients returns
that server's client list (filtered in-handler, store.GetClients isn't
server-scoped yet - that's a later step). Old routes untouched.
Also factors the repeated serverID validation guard in jsondb.go's new
server-scoped methods into one validateServerID() helper, per a code
simplification review.
New IStore methods for a per-server registry (GetServers, GetServerByID,
CreateServer, DeleteServer, GetServerSettings/SaveServerSettings,
GetServerHashes/SaveServerHashes), all additive - existing single-server
methods untouched. Adds util.ValidateRecordID/ValidateInterfaceName and
applies them to every new method taking a server ID, closing a path-
traversal gap before serverID is ever driven by user input (flagged by
a security review pass).
Adds Server.ID/Name, ServerInterface.Name, Client.ServerID, and a new
ServerSetting type as additive fields so existing single-server code
paths keep working unchanged. jsondb.Init() now detects a pre-existing
single-server db/server/ layout and mirrors it into new servers/,
server_settings/, server_hashes/ collections plus backfills ServerID
on existing clients, without touching/removing the legacy files yet.
Upstream builds a single static binary (main.go at repo root, no
cmd/... subpackage) that go:embeds templates/ and assets/ at compile
time, and uses a relative ./db directory (jsondb) instead of SQLite -
so WorkingDirectory in the systemd unit now matters for the DB path,
not for template/static serving like before.
Frontend assets (admin-lte, jquery plugins) need yarn + prepare_assets.sh
before go build, so all installers now also install nodejs/npm/yarn and
run prepare_assets.sh when assets/dist is missing or package.json changed.
Binary is now literally named "wireguard-ui" (matches upstream), listens
on 0.0.0.0:5000 by default. systemd unit rewritten accordingly with
WorkingDirectory=/var/lib/wireguard-ui-multi.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The from-scratch Go rewrite had unresolved bugs (missing go.sum, UI
404s, path issues) from being built without a working local Go
toolchain to verify against. Switching strategy: use the actual
upstream wireguard-ui codebase (proven, battle-tested single-server
manager) as the base, and extend it for multi-server support instead
of re-deriving everything from zero.
Kept our own installers (bootstrap.sh, update.sh, scripts/install.sh,
scripts/proxmox-install.sh) - these still apply, just need updating
to build/install the upstream module layout instead of the old
cmd/wireguard-ui-multi structure.
Module path intentionally left as upstream's own
(github.com/ngoduykhanh/wireguard-ui) for now to avoid touching every
internal import; revisit if this needs to be fully rebranded.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cp truncates the destination in place, which fails with ETXTBSY when
the binary is currently executing (e.g. running update.sh while the
systemd service is active). Copy to a temp file and mv it into place
instead - mv is a rename within the same filesystem, which the
kernel allows even for a running binary.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The web UI 404'd in production because templates/static were loaded
via relative paths ("internal/ui/templates", "internal/ui/static"),
which only resolved when running from the repo checkout. systemd sets
WorkingDirectory=/var/lib/wireguard-ui-multi, so those paths never
existed there.
Add a -ui-root flag (default /usr/local/share/wireguard-ui-multi/ui),
have install.sh copy internal/ui there, and resolve templates/static
paths through it instead of hardcoded relative strings.
Also add release-binary fast path to bootstrap.sh (falls back to
source build with CGO_ENABLED=0/-trimpath if no release exists yet),
and document real hardware/build-RAM requirements in the README.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
update.sh pulls the latest main, rebuilds, reinstalls, and restarts
the service in one step. install.sh/bootstrap.sh now also drop
/etc/profile.d/go-path.sh so Go stays on PATH in future shells, not
just within the running install script.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
go.sum was never committed (no local Go toolchain to generate it),
causing "missing go.sum entry" build failures on target hosts.
go mod tidy regenerates it automatically before go build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/usr/local/bin wasn't guaranteed to be on PATH in non-interactive
shells, so the symlinked go binary was invisible to later steps.
Export PATH explicitly and fail fast if go is still missing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the from-scratch multi-server WireGuard management fork per
CLAUDE.md spec: sqlite schema (servers/peers/audit_log/users), Curve25519
key generation, per-interface config rendering + wg-quick/systemd control,
nftables hook scaffolding, session+CSRF-protected REST API with QR code
and config download endpoints, a minimal vanilla-JS web UI, legacy
wg0.conf migration, and both a native installer and a Proxmox LXC
provisioning script (with auto-detected latest Debian template).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>