- Register, view, and deactivate ACME accounts via API and UI - 16-check validation framework for certificate issuance readiness - DNS resolution, port, nginx, and firewall pre-flight checks - External IP detection with NAT support and fallback providers - Account card and settings modal in certificates page - Guard certificate issuance behind account registration - Update modal CSS to overlay-based approach - 1000+ lines of tests for validation and account handlers
26 KiB
Configuration Reference
This document describes the JSON configuration files used by Vacuum Wall to manage each subsystem. All persistent configuration is stored in the config/ directory as declarative JSON. Runtime artifacts and generated files live in data/. The application renders these declarations into the format expected by each underlying service.
DHCP/DNS Configuration
File: config/dnsmasq/config.json
This file defines all DHCP server settings and DNS resolution behavior for the dnsmasq service. The application renders it into /etc/dnsmasq.d/vacuum-wall.conf.
{
"dhcp": {
"ranges": [
{
"interface": "eth1",
"start": "192.168.2.100",
"end": "192.168.2.200",
"lease_time": "12h",
"gateway": "192.168.2.1",
"dns": "192.168.2.1"
}
],
"static_leases": [
{
"mac": "aa:bb:cc:dd:ee:ff",
"ip": "192.168.2.50",
"hostname": "printer"
}
]
},
"dns": {
"upstreams": ["8.8.8.8", "1.1.1.1"],
"domain": "lan",
"custom_records": [
{
"name": "nas.lan",
"address": "192.168.2.10",
"hostname": "nas"
}
]
}
}
DHCP Fields
| Field | Type | Required | Description |
|---|---|---|---|
ranges |
array | No | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. Default: []. |
ranges[].interface |
string | Yes | Network interface on which to serve this DHCP range (e.g., eth1). |
ranges[].start |
string | Yes | First IP address in the pool. |
ranges[].end |
string | Yes | Last IP address in the pool. |
ranges[].lease_time |
string | No | DHCP lease duration. Accepts values like 12h, 1d, 30m. Default: 12h. |
ranges[].gateway |
string | No | Default gateway advertised to DHCP clients. Typically the router's LAN IP. |
ranges[].dns |
string | No | DNS server address advertised to DHCP clients. Typically the Vacuum Wall host's LAN IP. |
static_leases |
array | No | Fixed IP assignments tied to MAC addresses. Clients with matching MACs always receive the specified IP. Default: []. |
static_leases[].mac |
string | Yes | MAC address of the client (colon-separated lowercase hex). |
static_leases[].ip |
string | Yes | The IP address to assign to this MAC. Must be outside the dynamic pool ranges. |
static_leases[].hostname |
string | No | Hostname to associate with the lease. Used for reverse DNS and mDNS. |
DNS Fields
| Field | Type | Required | Description |
|---|---|---|---|
upstreams |
array | Yes | Upstream DNS servers to forward unresolved queries to. Supports IPv4 and IPv6 addresses. Default: ["8.8.8.8", "1.1.1.1"]. |
domain |
string | No | Local domain suffix. Hostnames without a FQDN are resolved within this domain (e.g., printer becomes printer.lan). Default: null. |
custom_records |
array | No | Static DNS A records for internal services and devices. Default: []. |
custom_records[].name |
string | Yes | Fully qualified domain name (e.g., nas.lan). |
custom_records[].address |
string | Yes | The IP address to resolve the name to. |
custom_records[].hostname |
string | No | Short hostname without the domain suffix. Adds a reverse DNS entry as well. |
Additional dnsmasq directives can be appended verbatim by placing plain-text files in data/dnsmasq/fragments/. Each file's contents are concatenated into the generated config. This is useful for advanced options not covered by the JSON schema (e.g., bogus-priv, cache-size, log-queries).
Nginx Configuration
File: config/nginx/config.json
This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in data/nginx/sites-enabled/ and into the shared SSL snippet at /etc/nginx/snippets/vacuum-wall-ssl.conf.
{
"domains": {
"app.example.com": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"force_ssl": true,
"cert": "acme",
"headers": {
"X-Forwarded-Proto": "https",
"X-Real-IP": "$remote_addr"
}
}
},
"management": {
"domain": "vacuum-wall.local",
"backend": {
"host": "127.0.0.1",
"port": 9090,
"proto": "http"
},
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
}
},
"ssl": {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305",
"prefer_server_ciphers": false
}
}
Domain Entries
The domains object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx server block.
| Field | Type | Required | Description |
|---|---|---|---|
backend |
object | Yes | The upstream service that receives proxied traffic. |
backend.host |
string | Yes | IP address or hostname of the backend service. |
backend.port |
integer | Yes | Port the backend service is listening on. |
backend.proto |
string | No | Protocol for the backend connection: http or https. Default: http. |
force_ssl |
boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: true. |
headers |
object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., $remote_addr). |
cert |
string | No | Certificate provisioning method. One of: "acme", "file", or "selfsigned". Omit for domains that don't need a dedicated cert. |
Certificate Types
The cert field is a string that selects the provisioning method:
| Value | Description |
|---|---|
acme |
Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration serves ACME challenge files at /.well-known/acme-challenge/. |
file |
Use a pre-existing certificate and private key from the local file system. Vacuum Wall will not attempt to renew these certificates. |
selfsigned |
Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored at data/certs/. |
Management Domain
The management block configures the Vacuum Wall admin interface itself. It follows the same structure as a domain entry but can include an auth block for HTTP Basic Authentication.
| Field | Type | Required | Description |
|---|---|---|---|
domain |
string | Yes | The hostname used to access the management WebUI (e.g., <hostname>.local). |
backend |
object | Yes | Points to the Flask app at 127.0.0.1:9090. |
auth |
object | No | HTTP Basic Authentication configuration. Only created if auth_user is provided when setting the management proxy. |
auth.user |
string | Yes | Username for the .htpasswd file. |
auth.htpasswd |
string | Yes | Full path to the .htpasswd file containing the username and hashed password. |
The application can create the .htpasswd file programmatically via write_htpasswd() (using passlib's apache_passwd with Apache-Round-12, falling back to SHA-256 crypt). Manual creation is also possible:
htpasswd -bc data/nginx/.htpasswd admin yourpassword
Global SSL Settings
The ssl block defines TLS parameters applied to all HTTPS server blocks via the shared snippet.
| Field | Type | Required | Description |
|---|---|---|---|
protocols |
string | No | nginx ssl_protocols directive value. Default: TLSv1.2 TLSv1.3. |
ciphers |
string | No | nginx ssl_ciphers directive value. Default is a curated AEAD-only cipher string. |
prefer_server_ciphers |
boolean | No | Whether to prefer server cipher order. Default: false. |
ACME (Certificate) Configuration
File: config/acme/config.json
This file stores the ACME account settings used by acme.sh for certificate provisioning. Account registration, modification, and deactivation are performed through the WebUI at the Certificates page — not by editing this file directly.
{
"email": "admin@example.com",
"ca": "letsencrypt"
}
ACME Fields
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | No | Contact email for the ACME account. Used for certificate expiry notifications and recovery. Populated automatically when an account is registered via the WebUI. Default: "". |
ca |
string | No | ACME CA provider. One of: "letsencrypt" (Let's Encrypt), "zerossl" (ZeroSSL). Populated automatically when an account is registered. Default: "". |
Account Registration
ACME account registration is handled entirely through the WebUI. When the user registers an account:
- The user navigates to the Certificates page and clicks "Register Account".
- Provides an email address and selects a CA provider (Let's Encrypt or ZeroSSL).
- The backend calls
acme.sh --register-accountwith the provided parameters. - On success, the
emailandcafields inconfig/acme/config.jsonare populated, and acme.sh writes its.account.conffile underdata/acme/.
Before any certificate can be issued, an ACME account must be registered. The certificate validation flow includes a blocking check (account_registered) that prevents issuance if no account exists.
Account Management
After registration, the account can be managed from the WebUI:
- Update email: The Settings modal allows changing the contact email, which triggers an update via
acme.sh --register-account -u. - Deactivate account: The Settings modal includes a button to deactivate the account via
acme.sh --deactivate-account, which clears theemailandcafields and removes the ACME account.
ACME Home Directory
acme.sh stores its state under data/acme/ (the ACME home directory). Key files:
.account.conf— ACME account credentials and settings (containsACME_LEEMAIL,ACME_MCA).<domain>/— Per-domain certificate and key files issued by acme.sh.
The application reads .account.conf to determine registration status. If the file is missing or lacks required keys, the account is considered unregistered.
ACME Configuration
File: config/acme/config.json
This file stores the ACME account settings used by vacuum-wall for automatic certificate issuance via acme.sh. Account registration is performed exclusively through the WebUI — the Certificates page provides a "Register Account" modal where the user enters an email and selects a CA provider.
{
"email": "",
"ca": ""
}
ACME Config Fields
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | No (WebUI) | Contact email for the ACME account, used for certificate expiry notifications and recovery. Populated when the user registers an account via the WebUI. Default: "". |
ca |
string | No (defaults to letsencrypt) |
ACME CA provider. One of: "letsencrypt", "zerossl". Populated during account registration. Default: "" (acme.sh defaults to Let's Encrypt if omitted). |
Account Registration Flow
- User navigates to the Certificates page in the WebUI.
- Clicks "Register Account" and provides an email address, optionally selecting a CA provider.
- The application calls
acme.sh --register-account -m <email> --server <ca>as a privileged operation via the daemon. - On success,
config/acme/config.jsonis updated with the email and CA. acme.sh writes its own state todata/acme/.account.conf. - The
account_registeredcheck in the certificate validation pipeline transitions from blocking to passing, enabling certificate issuance.
The account_registered check is blocking — certificate issuance and validation will fail until an ACME account is registered. The email_configured check is non-blocking — it produces a warning if the email is empty but does not prevent issuance.
Account Management API
| Endpoint | Method | Description |
|---|---|---|
/api/certs/account |
GET |
Returns account status: registered, email, CA provider. |
/api/certs/account/register |
POST |
Registers a new ACME account. Body: { "email": "..." }. Optional: { "server": "letsencrypt" }. |
/api/certs/account |
DELETE |
Deactivates the ACME account via acme.sh --deactivate-account. Clears email and CA from config. |
/api/certs/email |
POST |
Updates the contact email on an existing account. Body: { "email": "..." }. |
ACME Home Directory
acme.sh stores its operational state under data/acme/. The application reads data/acme/.account.conf to determine whether an account is registered. Required keys: ACME_LEEMAIL and ACME_MCA. Their absence or the file's absence means the account is unregistered.
WireGuard Configuration
File: config/wireguard/config.json
This file defines the WireGuard server interface and all connected peers. The application renders it into /etc/wireguard/wg0.conf and applies it with wg-quick. The file is created automatically when initialize() generates the server key pair via wg genkey / wg pubkey.
{
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "<generated>",
"public_key": "<generated>",
"addresses": ["10.137.0.1/24"],
"post_up": null,
"post_down": null
},
"peers": {
"alice": {
"public_key": "<auto-generated>",
"private_key": "<auto-generated>",
"endpoint": "203.0.113.1:51820",
"allowed_ips": ["0.0.0.0/0"],
"persistent_keepalive": 25,
"preshared_key": null
}
}
}
Interface Fields
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | No | WireGuard interface name. Default: wg0. |
listen_port |
integer | No | Port the WireGuard interface listens on. Default: 51820. Must be opened in the firewall. |
private_key |
string | Yes (after init) | Base64-encoded private key for the server interface. Generated automatically by initialize() via wg genkey. |
public_key |
string | Yes (after init) | Corresponding public key. Generated automatically by initialize() via wg pubkey. |
addresses |
array | No | IP address(es) assigned to the server interface in CIDR notation (e.g., 10.137.0.1/24). Default: ["10.137.0.1/24"]. |
post_up |
string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to null to omit. Default: null. |
post_down |
string | No | Shell command to run after the interface is brought down. Used to clean up rules added by post_up. Set to null to omit. Default: null. |
Peer Fields
Peers are stored in an object keyed by a human-readable identifier (e.g., alice, office-laptop). Each peer entry defines a WireGuard peer configuration. When add_peer() is called, the peer's key pair is auto-generated. The private_key is stored for client configuration generation but stripped from all API responses.
| Field | Type | Required | Description |
|---|---|---|---|
public_key |
string | Yes | The peer's public key. Auto-generated when the peer is added. |
private_key |
string | No | The peer's private key, stored for generating downloadable client configuration files. Auto-generated when the peer is added. Stripped from all API responses — the WebUI never exposes peer private keys over the network. |
endpoint |
string | No | The peer's public endpoint (IP:port). Required for server-initiated connections (e.g., the server reaching out to a peer behind a firewall). Leave empty or null for peer-initiated connections where the peer connects to the server. Default: null. |
allowed_ips |
array | No | CIDR blocks that traffic from this peer is allowed to route. Default: [] (no routing restrictions from the server side). ["0.0.0.0/0"] allows all traffic. ["10.137.0.0/16"] restricts traffic to the VPN subnet. |
persistent_keepalive |
integer | No | Keepalive interval in seconds. 25 is recommended for peers behind NAT. Set to 0 or null to disable. Default: null. |
preshared_key |
string | No | Optional pre-shared key for post-quantum resistance. Use wg genpsk to generate. Default: null. |
Client Configuration Generation
When a peer's private_key is set (which is the case when add_peer() auto-generates it), the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a [Peer] entry, and the appropriate Endpoint and AllowedIPs values. The private_key field is written into the client config file for download but is never returned by the API. generate_client_conf() computes the client IP address from the server's subnet and the peer's sorted index position.
Applying Configuration
When configuration is saved through the WebUI or API, the application:
- Renders the
wg0.conffile from the JSON configuration. - Writes the file to
/etc/wireguard/wg0.confwith600permissions viasudo cp. - Runs
sudo wg-quick up <name>to apply the configuration. - Returns success or error status to the caller.
If the interface is already up, wg-quick up will reconfigure it in place without dropping existing connections.
Firewall Configuration
File: config/firewall/config.json
This file defines the declarative firewalld zone configuration. The application compares it against the live firewalld state via _compute_pending_changes() and applies incremental changes. Runtime state backups are stored in data/firewall/rules.json.
{
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["dhcp", "dns", "https", "ssh"],
"target": "DEFAULT",
"masquerade": true,
"forward_ports": [
{
"id": "abc123",
"port": 443,
"proto": "tcp",
"toaddr": "192.168.2.50",
"toport": 8080
}
],
"rich_rules": [
{
"rule": "rule family=\"ipv4\" source address=\"10.0.0.0/8\" reject"
}
]
}
}
}
Zone Fields
The zones object maps zone names (keys) to zone configurations. Each zone corresponds to a firewalld zone applied via firewall-cmd.
| Field | Type | Required | Description |
|---|---|---|---|
interfaces |
array | No | Network interfaces assigned to this zone. Computed against live state to detect pending changes. Default: []. |
services |
array | No | Firewalld services to allow in this zone (e.g., ssh, https, dns, dhcp). Default: []. |
target |
string | No | Zone target policy. One of: DEFAULT, ACCEPT, DROP, REJECT. The code maps these to firewalld's canonical target values (default, ACCEPT, DROP, REJECT). Default: DEFAULT. |
masquerade |
boolean | No | Enable IP masquerading (NAT) for this zone. Default: false. |
forward_ports |
array | No | Port forwarding rules. Each entry has an auto-generated id field and the standard firewalld forward-port fields. Default: []. |
forward_ports[].id |
string | No | Auto-generated unique identifier for the port forwarding rule. Not user-settable. |
forward_ports[].port |
integer | Yes | Destination port to forward. |
forward_ports[].proto |
string | Yes | Protocol: tcp or udp. |
forward_ports[].toaddr |
string | No | Internal IP address to forward to. Omit for broadcast forwarding. |
forward_ports[].toport |
integer | No | Internal port to forward to. Omit to keep the same port. |
rich_rules |
array | No | Rich rule entries for advanced firewall policies. Default: []. |
rich_rules[].rule |
string | Yes | The full firewalld rich rule string, e.g., rule family="ipv4" source address="10.0.0.0/8" reject. |
Applying Firewall Configuration
The config_pending() function compares the declarative config in config/firewall/config.json against the live firewalld state returned by the daemon (via daemon.handlers.firewall.get_state()). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as unmanaged_zones.
Networkd (IP Configuration)
File: config/network/config.json
This file defines static IP configuration for network interfaces managed by systemd-networkd. The application renders each interface entry into a 50-<name>.network INI file in data/networkd/, which the handler copies to /etc/systemd/network/.
{
"interfaces": {
"eth0": {
"addresses": ["192.168.1.1/24"],
"gateway": "192.168.1.254",
"dns": ["8.8.8.8", "1.1.1.1"],
"dhcp": "no"
},
"eth1": {
"dhcp": "ipv4",
"dns_default_route": true,
"dhcp_client": {
"hostname": "router",
"use_dns": true
}
},
"wg0": {
"addresses": [{"address": "10.137.0.1/24"}],
"routes": [
{
"destination": "10.0.0.0/8",
"gateway": "10.137.0.2"
}
]
}
}
}
Interface Entry Fields
Each key in the interfaces object is an interface name (e.g., eth0, eth1, wg0). The value is a dict with the following keys:
| Field | Type | Description |
|---|---|---|
addresses |
array |
IPv4 addresses. Each item is either a bare CIDR string ("192.168.1.1/24") or a dict with address, label, scope, route_metric, duplicate_address_detection, manage_temporary_address, add_prefix_route. Renders to [Address] sections. |
ipv6_addresses |
array |
Same as addresses, but for IPv6. |
gateway |
string |
Default IPv4 gateway ([Network] Gateway=). |
ipv6_gateway |
string |
Default IPv6 gateway ([Network] IPv6Gateway=). |
dns |
array |
IPv4 DNS servers ([Network] DNS=, one per line). |
ipv6_dns |
array |
IPv6 DNS servers ([Network] IPv6DNS=). |
domains |
array |
Search domains ([Network] Domains=). |
ipv6_domains |
array |
IPv6 search domains ([Network] IPv6Domains=). |
dns_default_route |
boolean |
Whether DNS is the default route for resolution ([Network] DNSDefaultRoute=). |
dhcp |
string |
DHCP mode: "yes", "ipv4", "ipv6", "no". Controls [Network] DHCP= and whether [DHCPv4]/[DHCPv6] sections are rendered. |
routes |
array |
Static routes. Each dict has destination, gateway, metric, table, type, scope, gateway_on_link, ipv6_preference, initial_congestion_window, initial_advertised_receive_window, quick_ack, fast_open_no_cookie, mtu_bytes, protocol, next_hop, multi_path_route. Renders to [Route#N] sections. |
link |
object |
Link settings: mtu_bytes, mac_address, arp, multicast, all_multicast, promiscuous, unmanaged, activation_policy, required_for_online. Renders to [Link] section. |
dhcp_client |
object |
DHCP client settings. Shared keys for both [DHCPv4] and [DHCPv6]: hostname, duid_type, duid_raw_data, iaid, client_identifier, rapid_commit, anonymize, use_dns, use_ntp, use_sip, use_captive_portal, use_mtu, use_hostname, use_domains, use_routes, route_metric, send_decline, net_label, nft_set, ip_service_type, socket_priority, bootp, label, max_attempts, listen_port, server_port, mud_url, boot_filename, send_option, send_vendor_option, user_class, vendor_class_identifier, request_options. |
bind_carrier |
array |
Carrier interfaces to bind to. |
ignore_carrier_loss |
boolean |
Ignore carrier loss events. |
keep_configuration |
boolean |
Keep configuration on stop. |
configure_without_carrier |
boolean |
Configure even without carrier. |
link_local_addressing |
string |
Link-local addressing mode. |
ipv6_link_local_address_generation_mode |
string |
IPv6 link-local address generation mode. |
ipv6_stable_secret_address |
string |
Stable secret for IPv6 address generation. |
ipv4_ll_start_address |
string |
Link-local IPv4 start address. |
ipv4_ll_route |
boolean |
Add route to link-local IPv4 address. |
default_route_on_device |
boolean |
Always add default route via this device. |
ipv6_hop_limit |
int |
IPv6 hop limit. |
ipv6_retransmission_time_sec |
string |
IPv6 retransmission timeout. |
ipv4_duplicate_address_detection_timeout_sec |
string |
IPv4 DAD timeout. |
ipv4_reverse_path_filter |
string |
IPv4 reverse path filtering mode. |
ipv4_accept_local |
boolean |
Accept packets to local addresses as non-local. |
ipv4_route_localnet |
boolean |
Route local network traffic. |
ipv4_proxy_arp |
boolean |
Enable proxy ARP. |
ipv4_proxy_arp_private_vlan |
boolean |
Private VLAN proxy ARP. |
ipv6_proxy_ndp |
boolean |
Enable IPv6 proxy NDP. |
ipv6_proxy_ndp_address |
string |
IPv6 proxy NDP address. |
ipv6_send_ra |
boolean |
Send IPv6 Router Advertisements. |
m_pls_routing |
boolean |
Enable MPLS routing. |
keep_master |
boolean |
Keep master on stop. |
ip_family |
string |
IP family to use. |
Keys not in the recognized set will be saved to config.json but won't be rendered to .network files. A warning is logged identifying any unrecognized keys.
DNS Upstream Sync
When POST /api/network/apply is called, the handler automatically collects public DNS servers from all networkd interface configs (via collect_upstream_dns()), filters out local/private-range addresses, and syncs the deduplicated list to dnsmasq's upstream DNS configuration. This keeps dnsmasq's upstream resolvers in sync with whatever DNS the WAN interface receives (whether statically configured or via DHCP).
Generated Files
Each interface config entry produces a 50-<name>.network file in data/networkd/. During apply, these are copied to /etc/systemd/network/ and stale files (not matching any config entry) are removed. File generation uses the systemd.syntax(7) naming convention: first section is bare ([Address], [Route]), subsequent sections use # suffix ([Address#1], [Route#2]).