Status: Draft v0.1 Owner: Christian Cohnen Last updated: 2026-05-30
Managing WireGuard in a hub-spoke setup — public hub VM + UniFi Cloud Gateway sites + road warrior clients — today requires manual CLI work to:
wg genkey, edit wg0.conf, restart, email the file)No open-source tool covers all three concerns together — peer management, group-based ACLs, and firewall enforcement — while remaining fully self-hostable and vendor-neutral. wg-easy covers peers but not ACLs. Tailscale and NetBird cover ACLs but lock the control plane into a SaaS or its own coordination server.
The cost of the manual workflow is: errors in firewall rules (drift between spreadsheet and ufw status), admin time per onboarding (~10 min of typing, file emailing, follow-up), and a security hole every time a config file is emailed in plaintext.
Islandr exists to do four things:
wg/wg-quick CLI for peer creation with a web UI that generates the keypair, assigns an IP from the pool, and shows the resulting .conf + QR code exactly once..conf without an admin in the loop, with German-language, non-technical wording — and a meaningful access list ("Worauf du zugreifen darfst") in resource-level terms.Mid-30s, runs IT for a 30–80 person company or a technical home setup. Comfortable with the Linux CLI but tired of editing wg0.conf by hand. Lives in the terminal and the Admin Console in roughly equal measure. Wants dense, data-rich screens and exact information (IPs, public keys, handshake times in absolute timestamps on hover). Often works in dark mode.
Primary surface: Admin Console. Success looks like: Onboarding a new employee's laptop takes under two minutes — pick user, click "Peer erstellen", hand them the QR. Revoking access takes one click and the firewall is correct within 1 second.
Knowledge worker, German-speaking, not technical. Knows what a Wi-Fi password is. Has heard of "VPN" but couldn't explain it. Was told by IT to "set this up on your laptop and your phone". Will give up if she sees the word CIDR or Public Key.
Primary surface: Self-Service Portal. Success looks like: She logs in with her Microsoft account (or a magic link). She sees "Hallo, Lena" and a list of her devices. She adds a new device by picking her phone's OS, scanning a QR code, and seeing "Verbindung erkannt".
Technical employee, comfortable with the CLI but not an admin. Wants to download a .conf for his Linux workstation rather than scan a QR. Doesn't need much hand-holding but appreciates not having to email his admin.
Primary surface: Self-Service Portal (the same one Lena uses — Tom just uses the .conf download path).
| ID | Criterion | Measurement |
|---|---|---|
| S-1 | Onboarding a new peer takes under 2 minutes from "user exists" to "QR shown" | Stopwatch on Felix's workflow |
| S-2 | Disabling a peer drops its packets at the hub within 1 second | tcpdump on hub during disable click |
| S-3 | ACL changes produce a valid nftables ruleset and zero-downtime atomic reload | nft -c -f validation passes; existing connections survive reload |
| S-4 | A non-technical end user adds a device without help | Lena onboards a phone without messaging Felix |
| S-5 | Audit log captures every mutating action with actor, action, target, timestamp | Every state-changing API call has a corresponding audit entry |
| S-6 | Backend runs as a single binary, no JVM install required on the hub VM | ./islandr starts cleanly on a fresh Ubuntu 24.04 VM |
Format: EARS (Easy Approach to Requirements Syntax) where it fits cleanly; plain "shall" otherwise. IDs match the original RFC where possible.
ISLANDR_ADMIN_PASSWORD set) and no admin@local user exists, the system shall create an admin@local administrator user and bind the ENV-admin login to it, so the bootstrap admin has a usable identity for peer ownership and role assignment. The seed shall be idempotent (no duplicate on subsequent boots).publicKey and assignedIP, and add it to the running wg interface..conf body, the private key (inline), and a QR PNG as a base64 data URL. Whether the private key is also retained server-side depends on the configured retention mode (see ADR-0007):
never (default) — the private key exists only in the create-peer response body and is never persisted. If the user loses the artefact before scanning, the only recovery is to regenerate the peer. Matches N-06 strictly.plaintext — the private key is stored in the peers table unencrypted, allowing a GET /peers/{id}/conf endpoint to re-render the .conf and QR on demand. PiVPN-equivalent behaviour. Operator opts in deliberately. Retention mode is an instance-wide setting, not per-peer. encrypted mode is deferred to v2.wg interface and recompute the nftables ruleset, while retaining the peer's public key and assigned IP in the database.wg, from the database, and from the nftables ruleset, and shall write an audit entry naming the actor.Vertrieb, IT-Admin) and assign users to roles (many-to-many).AllowedIPs rendering.10.20.0.5). A resource always belongs to exactly one site.ResourcePort carries port, transport (tcp/udp), a protocol label (RDP, SSH, SFTP, X11, HTTP, HTTPS, SMB, free-text custom), and an optional human label.connect() to a fixed probe-port set plus a connected-DatagramSocket ICMP port-unreachable probe) — it shall not use raw sockets, CAP_NET_RAW, or any new privilege (ADR-0014, BR-032). The scan shall reach a site either through its WireGuard gateway peer (a real scan requires that peer's handshake to be recent) or directly when the site is hub-local (no gateway peer); mock mode needs no route so the feature is testable without WireGuard (BR-034). The scan shall be admin-only, bound to the site's declared CIDR (no free-text range), rejected for a CIDR larger than /22 (BR-033), and audited on start and on import (BR-037). Each discovered host shall carry its open ports and a pre-filled, editable resource-type guess (BR-035); import shall be idempotent on (site, ip) (BR-036). Discovery is best-effort — a fully-filtered host may not be found, and manual add remains available.AllowedIPs = 0.0.0.0/0) and split tunnel (AllowedIPs = the VPN subnet plus every site's CIDR that has an enabled gateway peer) for the generated client config. The split-tunnel network list shall be independent of the peer's current role grants: it shall not change as grants change, so adding, narrowing, or removing an ACL grant never requires the peer to re-import its .conf or rescan its QR code. Access control to hosts inside those networks is enforced exclusively by the hub firewall (F-08), never by restricting which networks are routed to the client. See ADR-0017.nft -c -f, and atomically reload it via nft -f. If validation fails, the system shall keep the existing rules and surface the error in the UI.(peer source IP, resource IP, transport, port). The protocol label on a ResourcePort is descriptive metadata for the UI and is not enforced at L7 — a tunneled different protocol on the same port is not detected. See ADR-0006 §"What this does not protect against".wg show), enabled/disabled, online (handshake ≤3 min) / stale / offline.wg show <iface> dump on a configurable interval (default 30s), diff the result against the last sample, and persist per-peer activity samples (handshake timestamp, endpoint, rx/tx byte counters).vor 12 Sekunden, vor 2 Std, vor 3 Tagen, nie) with absolute time on hover, and shall color "active" peers (handshake ≤3 min) distinctly.totalRxBytes, totalTxBytes, lastSeenAt) shall be preserved indefinitely..conf (one-time-secret pattern) → wait for first handshake → success.<form> element with name and autocomplete attributes (username / current-password) and type="password" on the relevant fields. The RDP dialog shall additionally offer a copyable per-resource URI, built on the Islandr origin with the resource name in the route (…/#/my-access/<resource>), so a user can keep one distinct vault entry per RDP resource. Autofill matching is at the Islandr-origin level; the URI aids identification and is not a security control.ADMIN or END_USER (see domain model §7). ADMIN reaches the Admin Console; END_USER reaches the Self-Service Portal only. System role is enforced server-side on every API call, not only in the UI.| ID | Requirement |
|---|---|
| N-01 | Fully self-hostable. No external service dependencies at runtime. (CDN fonts are out — self-host IBM Plex per the design brief.) |
| N-02 | Backend runs on Linux (Ubuntu/Debian 22.04+, Java 21 or as a native binary). |
| N-03 | Backend ships as a single binary (Quarkus native build via GraalVM) for production. |
| N-04 | Web UI accessible over HTTPS only. TLS termination at Caddy or a reverse proxy. |
| N-05 | API is RESTful JSON, suitable for scripting and the future pull-mode agent. |
| N-06 | Peer private keys are shown exactly once and not stored in plaintext. If retained at all (for re-display before first handshake), they are encrypted at rest with an admin-provided key. |
| N-07 | Audit log is append-only and accessible in the UI. |
| N-08 | UI is bilingual DE/EN with a runtime language toggle. The initial language follows the user's saved choice, then the browser language, falling back to English when neither resolves to DE or EN. German copy uses the informal Du. No emoji. Status is icon + label, never color alone. |
| N-08a | Every user-visible string — labels, buttons, hints, placeholders, error and status messages, title/aria-label text — is served from the i18n catalog in both DE and EN. No display text is hardcoded in a single language in the view code; the DE and EN key sets stay at parity. |
| N-09 | Light and dark theme are equal-weight and ship together in v1. |
| N-10 | Both themes meet WCAG AA: text contrast ≥4.5:1, large text ≥3:1, visible :focus-visible ring. |
| N-11 | All admin API endpoints require an authenticated session with isAdmin=true. All user-facing endpoints require at minimum an authenticated session. Avatar endpoints are session-gated. Unauthenticated access returns HTTP 401; authenticated non-admin access to admin endpoints returns HTTP 403. |
| N-12 | All free-text fields interpolated into nftables rulesets are sanitized (double-quotes, backslashes, newlines stripped) before insertion into the comment field. Structural rule parameters (IPs, ports, transport) are typed and DB-constrained — no string interpolation path exists for them. |
| N-13 | Page and route URIs use English words regardless of the active UI language (e.g. /networks, /resources, /audit), so links and bookmarks are language-stable. The displayed label for a route is localised; its path segment is not. |
The model implements NIST RBAC0 (Core RBAC) with three primitives:
User, assigned to one or more Roles.Resources (concrete machines/services with an IP), exposing one or more ResourcePorts. Resources are organised under Sites for browsing and routing.RoleResourceGrant. A grant binds a Role to a Resource (and optionally to a subset of its ResourcePorts).Permissions never attach to a User directly. Adding a new employee to "Vertrieb" gives them every Vertrieb grant in one step; revoking the role revokes everything. This is the load-bearing benefit of RBAC over per-user permission lists and is the reason the role layer exists.
Role hierarchy (RBAC1: "IT-Admin inherits from Entwickler") and constraints (RBAC2/3: separation-of-duties, mutual exclusion) are out of scope for v1. They can be added later without restructuring the core model.
Protocol semantics:
ResourcePortcarries aprotocollabel (RDP,SSH,SFTP,X11,HTTP, …) used by the UI to render meaningful access lists ("RDP zu Terminal-01"). Enforcement is purely at the(saddr, daddr, dport, proto-tcp|udp)level on nftables. The protocol label is descriptive metadata, not a security control. See ADR-0006 for the rationale and what this does not protect against.
classDiagram
class User {
UUID id
String name
String email
Boolean enabled
}
class Role {
UUID id
String name
String description
}
class Site {
UUID id
String name
String cidr
String description
}
class Resource {
UUID id
String name
String ip
String description
}
class ResourcePort {
UUID id
Integer port
String transport
String protocol
String label
}
class WireGuardPeer {
UUID id
String publicKey
String assignedIP
Boolean enabled
Instant lastSeenAt
String lastSeenEndpoint
Long totalRxBytes
Long totalTxBytes
Instant createdAt
}
class PeerActivitySample {
UUID id
Instant sampledAt
Instant handshakeAt
String endpoint
Long rxBytes
Long txBytes
}
class AuditLog {
UUID id
String actor
String action
String target
Instant timestamp
}
class RoleResourceGrant {
UUID id
Boolean allPorts
}
User "1" -- "*" WireGuardPeer : owns
User "*" -- "*" Role : member of
Site "1" -- "*" Resource : hosts
Resource "1" -- "*" ResourcePort : exposes
Role "1" -- "*" RoleResourceGrant : grants
RoleResourceGrant "*" -- "1" Resource : on
RoleResourceGrant "*" -- "*" ResourcePort : limited to (if not allPorts)
WireGuardPeer "1" -- "*" PeerActivitySample : sampled as
| Entity | Field | Notes |
|---|---|---|
Role |
name |
E.g. Vertrieb, IT-Admin, Entwicklung. Job-function naming per NIST RBAC convention. |
Site |
cidr |
The remote subnet reachable through one or more static UCG-side tunnels. Routing is configured on the UCG; Islandr only references it. |
Resource |
ip |
A single host inside a Site's cidr. v1 supports IPv4 only. |
ResourcePort |
transport |
tcp or udp. Required — nftables needs it. |
ResourcePort |
protocol |
Free-text label (RDP, SSH, SFTP, X11, HTTP, HTTPS, SMB, custom). UI-only. |
ResourcePort |
label |
Optional human override of the auto-rendered "RDP zu Terminal-01" line. |
RoleResourceGrant |
allPorts |
If true, the role can reach every ResourcePort of the resource (current and future). If false, the grant is limited to the explicit ResourcePort set. |
The Role entity above models access roles (job functions). Application-level authorisation — who may use the Admin Console vs. who is restricted to the Self-Service Portal — uses a separate, fixed enum:
| System role | Meaning |
|---|---|
ADMIN |
Full Admin Console access. Manages users, roles, sites, resources, ACLs, audit. |
END_USER |
Self-Service Portal only. Sees own peers, own access list. |
System role is stored on User and not modelled as an entity — see ADR-0006 for why this stays a closed enum in v1.
peer.user → user.roles → role.grants → grant.resource (+ optional grant.ports) → nftables rules
On every relevant change (peer create/disable/delete/re-enable, user role-membership change, role grant change, resource/port change, site-cidr change), the backend resolves this graph and performs a full ruleset recompute + atomic reload via nft -f. There is no partial-rule update path — the ruleset is always a function of current DB state.
The generated rules target the resource tuple, not the site CIDR:
iifname "wg0" ip saddr <peer.assignedIP> ip daddr <resource.ip> <transport> dport <port> accept
A Role grant with allPorts=true produces one rule per declared ResourcePort. A site's cidr is only used at the UI level (grouping resources by site) and for the AllowedIPs block in the peer's .conf (so the OS routing table sends matching destinations into the tunnel). It is never used as an nftables target by itself.
WireGuard doesn't persist session history. wg show reports only the most recent handshake per peer, and wg-quick down wipes the in-kernel state. To answer "when did Lena's laptop last connect?" or "show me usage over 7 days", the poller writes:
WireGuardPeer (lastSeenAt, totalRxBytes/TxBytes) — survives wg restarts, fast to read.PeerActivitySample — bounded retention (30d default), for charts and timelines.Live "is this peer connected right now" is still answered from wg show directly (handshake ≤3 min = active).
sequenceDiagram
actor Felix as Admin (Felix)
participant UI as Admin Console
participant API as Islandr API
participant DB
participant WG as wg / wg-quick
participant NFT as nftables
Felix->>UI: "Peer erstellen" for user
UI->>API: POST /users/{id}/peers
API->>API: generate keypair, allocate next free IP
API->>DB: persist peer (publicKey, IP, enabled=true)
API->>WG: wg set wg0 peer <pub> allowed-ips <IP>/32
API->>NFT: recompute + nft -c -f, then nft -f
NFT-->>API: ok
API-->>UI: { conf, qr } (private key in response, not persisted)
UI->>Felix: show QR + .conf + "nur jetzt sichtbar" warning
Felix->>UI: close modal
Note over UI,API: private key gone — Felix can revoke + regenerate if lost
The Admin Console exposes a "Rollen × Ressourcen" matrix instead of the earlier "Gruppen × Netzwerke" one. Cells are tri-state per resource: ∅ (no access) / ⓐ (all ports) / N (specific port count); clicking a cell opens a port-picker if the resource has more than one declared port.
sequenceDiagram
actor Felix
participant UI as ACL Matrix (Roles × Resources)
participant API as Islandr API
participant NFT as nftables
Felix->>UI: toggle grants (dirty = amber ring)
Felix->>UI: click "Änderungen anwenden"
UI->>API: PUT /roles/{id}/grants (updated grant set)
API->>API: resolve peer.user → roles → grants → (resource, ports) for every enabled peer
API->>API: build ruleset string (one accept rule per (peer.ip, resource.ip, transport, port))
API->>NFT: write temp file, nft -c -f <tempfile>
alt validation ok
API->>NFT: nft -f <tempfile> (atomic)
NFT-->>API: applied
API-->>UI: 200 ok
UI->>Felix: "Ruleset gültig · zuletzt aktualisiert vor 0 Sekunden"
else validation fails
API-->>UI: 422 with nft stderr
UI->>Felix: red banner with error detail (rules unchanged)
end
sequenceDiagram
actor Lena as End user (Lena)
participant Portal as Self-Service Portal
participant API as Islandr API
participant WG as wg
participant Poller
Lena->>Portal: "Neues Gerät hinzufügen"
Portal->>Lena: Step 1 — pick platform (iOS/Android/macOS/Windows/Linux)
Lena->>Portal: pick iOS, "Weiter"
Portal->>API: POST /users/me/peers { platform: "ios", name: "iPhone" }
API-->>Portal: { conf, qr }
Portal->>Lena: Step 2 — QR + .conf + "nur jetzt sichtbar"
Lena->>Lena: scan QR with WireGuard app, toggle on
Portal->>API: GET /peers/{id}/activity/summary (poll)
Poller->>WG: wg show wg0 dump (every 30s)
Poller->>API: persist sample with new handshake
API-->>Portal: handshake detected
Portal->>Lena: Step 3 — "Verbindung erkannt" ✓
Shape only — request/response bodies are defined by the implementation. arc42 architecture documentation is planned separately.
# --- Users ---------------------------------------------------------------
GET /api/v1/users
POST /api/v1/users
GET /api/v1/users/{id}
DELETE /api/v1/users/{id}
PUT /api/v1/users/{id}/system-role # ADMIN | END_USER
# --- Peers ---------------------------------------------------------------
GET /api/v1/peers
POST /api/v1/users/{id}/peers # response: { peer, conf (string),
# qrPng (base64 data URL) }
# — private key embedded in conf + qrPng only
# — no follow-up endpoint returns this material
GET /api/v1/peers/{id} # metadata only (no key material)
GET /api/v1/peers/{id}/conf # re-render .conf + QR
# 200 only in retention=plaintext mode
# 404 in retention=never mode
PUT /api/v1/peers/{id}/enabled
DELETE /api/v1/peers/{id}
GET /api/v1/peers/{id}/activity?from=&to=
GET /api/v1/peers/{id}/activity/summary
# --- Roles ---------------------------------------------------------------
GET /api/v1/roles
POST /api/v1/roles
GET /api/v1/roles/{id} # incl. members + grants
PUT /api/v1/roles/{id} # name/description
DELETE /api/v1/roles/{id}
PUT /api/v1/roles/{id}/members # full set; user IDs
GET /api/v1/roles/{id}/grants
PUT /api/v1/roles/{id}/grants # full set; triggers nftables recompute
# --- Sites + Resources ---------------------------------------------------
GET /api/v1/sites
POST /api/v1/sites
PUT /api/v1/sites/{id} # name, cidr, description
DELETE /api/v1/sites/{id}
GET /api/v1/sites/{id}/resources
POST /api/v1/sites/{id}/resources
GET /api/v1/resources/{id}
PUT /api/v1/resources/{id}
DELETE /api/v1/resources/{id}
GET /api/v1/resources/{id}/ports
POST /api/v1/resources/{id}/ports
PUT /api/v1/ports/{id}
DELETE /api/v1/ports/{id}
# --- Self-Service (end user, scoped to own user) -------------------------
GET /api/v1/me # profile, system role, roles
GET /api/v1/me/peers
POST /api/v1/me/peers
GET /api/v1/me/access # resolved resource list, grouped by site
# --- System --------------------------------------------------------------
GET /api/v1/audit
GET /api/v1/status # wg interface state, nftables status, last reload
# --- Instance settings (admin-editable runtime config) -------------------
GET /api/v1/settings # current values + setupComplete flag
PUT /api/v1/settings # update all fields atomically, audit-logged
# See ADR-0008.
Summary only — a full STRIDE threat model is planned in the arc42 architecture documentation. The principle:
The hub VM is the trust boundary. It is internet-exposed and must be treated as a potential compromise target.
private_key_retention in the settings table with two values in v1: never (default — matches strict N-06) and plaintext (PiVPN-equivalent — re-display via GET /peers/{id}/conf). Editable in the Admin Console; audited. encrypted mode deferred to v2 to avoid master-key management complexity. Full rationale and tradeoff matrix in ADR-0007.<script type="importmap"> in dev, esbuild-bundled static assets in production. Quinoa rejected — it pulls npm under the hood, against the spirit of the no-build-toolchain constraint. Full rationale in ADR-0002.islandr admin add CLI, env var bootstrap)? Affects N-06 and the onboarding flow.IP ∈ subnet and IP not already used by another peer. No auto-allocation in v1. Rationale: admins of this scale already know which IP slot they want; auto-allocation adds a "next free IP" state machine for one click of saved typing.