LocalNet

LocalNet Manager v4 — Documentation

⬡ LocalNet Manager v4

A self-hosted, browser-based control panel for managing a private LAN — DNS, reverse proxy, DHCP, SSL certificates, ad blocking, backups, and live logs — all from a single Python script.

v4 Alpha Python 3 · Flask BIND9 · Nginx · ISC-DHCP Port 8091 Requires root

📑 Table of Contents

📖 Introduction

LocalNet Manager is a single-file Python web application that turns any Linux machine into a fully managed home or office network infrastructure server. Instead of manually editing /etc/bind/ zone files, writing Nginx reverse-proxy configs, or remembering certbot flags, every operation is exposed through a clean browser UI running on port 8091.

The application is intentionally dependency-light: it ships as one .py file, relies only on Flask (a Python micro-framework), and drives all real work through shell scripts executed as root via subprocess. Every destructive operation is preceded by an automatic ZIP backup of /etc/bind and /etc/nginx so recovery is always one click away.

⚠️
Root required. Most operations (DNS install, proxy management, DHCP, SSL) require the application to run as root. Launch with sudo python3 localnet_v4_alpha_b.py. The UI will show a warning and disable write operations if run as a non-root user.

What problem does it solve?

Self-hosters running services like Home Assistant, Nextcloud, Jellyfin, or Gitea on a local server typically need:

  • Custom DNS names like nas.localnet or dash.localnet instead of bare IP addresses
  • Nginx reverse proxy so multiple services share port 80/443
  • Local HTTPS without browser warnings — via mkcert or Let's Encrypt
  • Network-wide ad blocking without a separate Pi-hole
  • DHCP so devices get addresses automatically and the DNS server is injected

LocalNet Manager automates all of this from a single web interface.

✨ Features

🌐
BIND9 DNS Server
Install, configure, and manage an authoritative private TLD. A/CNAME/TXT/SRV/wildcard records with one-click add/delete.
↔️
Nginx Reverse Proxy
Five built-in templates: Basic, WebSocket, Home Assistant, Nextcloud, and Load Balancer. Full upstream IP + port control.
📡
DHCP Server
ISC-DHCP-Server install and configure with range, gateway, lease time, and DNS injection. Live lease viewer.
🔒
SSL / TLS
Local CA via mkcert for *.localnet certs, plus Certbot DNS-01 for globally trusted Let's Encrypt certs — no port 80 required.
🚫
Ad Blocking
DNS-layer ad blocking via BIND9 RPZ using the StevenBlack unified hosts list. Blocks 100k–200k ad/tracker domains network-wide.
💾
Automatic Backups
ZIP snapshots of /etc/bind and /etc/nginx before every install, remove, or modify operation. One-click restore.
📜
Live System Logs
Server-Sent Events (SSE) stream of journalctl output for BIND9, Nginx, DHCP, and systemd-resolved in real time.
🛡️
Authentication
Session-based login with SHA-256 hashed passwords stored in JSON config. Default password is localnet.
🔌
VPN Compatible
Routes through systemd-resolved stub so VPN clients can still inject their own DNS while local names continue to resolve.
🗄️
SQLite Metadata
Optional notes on DNS records and proxies, plus a backup history log, all stored in a local SQLite database.

🏗 Architecture

LocalNet Manager is deliberately stateless at the application layer. All real configuration lives in system files (/etc/bind/, /etc/nginx/, etc.). The app reads those files on every request and writes them via Bash scripts executed by subprocess as root.

┌──────────────────── Browser ────────────────────────┐
│                 http://<server>:8091                │
│   Vanilla JS SPA  ←──SSE log stream──→  Flask App   │
└────────────────────────┬────────────────────────────┘
                         │ REST API (JSON)
                         ▼
┌───────────────── Flask (Python 3) ──────────────────┐
│  Auth  │  Config  │  Script Runner  │  SSE Queue    │
│  ├─ session-based  login            │               │
│  ├─ /etc/localnet/config.json       │               │
│  ├─ /etc/localnet/localnet.db       │               │
│  └─ threading.Thread (bash scripts) │               │
└──────────┬──────────────────────────┘               
           │ subprocess.Popen
           ▼
┌──────────────── System Services ────────────────────┐
│  BIND9 (named)     /etc/bind/                       │
│  Nginx             /etc/nginx/sites-{available,enabled}
│  ISC-DHCP-Server   /etc/dhcp/dhcpd.conf             │
│  mkcert / certbot  /etc/localnet/certs/             │
│  systemd-resolved  /etc/systemd/resolved.conf.d/    │
└─────────────────────────────────────────────────────┘

Key source modules (within single file)

SectionLinesResponsibility
Config52–69Load/save /etc/localnet/config.json with fallback to /tmp
Auth Helpers79–106SHA-256 password hashing, HMAC compare, Flask session guard decorator
SQLite DB108–182Record notes, proxy notes, backup history (3 tables)
Logging184–194In-memory ring buffer (1000 entries) + fan-out to SSE subscriber queues
Helpers196–296IP detection, interface enumeration, zone file parser, proxy lister
Backup System298–336ZIP /etc/bind + /etc/nginx; restore by extracting to /
Script Runner338–369Writes Bash to /tmp/_localnet_v4.sh, runs in daemon thread, pipes output to log
Nginx Templates371–5065 config templates rendered with plain .replace()
API Routes508–1522All REST endpoints (see API Reference)
HTML_PAGE1524–3197Entire frontend as an embedded Python string (SPA)
LOGIN_PAGE3199–3245Standalone login HTML

Concurrency model

Flask runs with threaded=True. Every shell script runs in a daemon thread so HTTP requests return immediately with {"ok": true} while the script executes in the background. Progress is streamed back to the browser via the SSE log endpoint (/api/logs/stream) using a per-client queue.Queue.

DNS resolution chain (VPN-compatible mode)

resolv.conf → 127.0.0.53 (systemd-resolved stub)
   ├── *.localnet  → 127.0.0.1 (BIND9)  ✔ local names
   └── everything else → upstream / VPN DNS  ✔ VPN works

In non-VPN mode, resolv.conf points directly to BIND9 at the server LAN IP with forward only, which is simpler but breaks VPN DNS injection.

📋 Requirements

Host system

RequirementDetails
OSUbuntu 22.04+ or Debian 12+ (uses apt-get, systemctl, journalctl)
Python3.10+ (uses str | None union type hints in internal helpers)
PrivilegeMust run as root (sudo) for all write operations
NetworkStatic LAN IP strongly recommended; the detected IP is baked into BIND9 config
InternetRequired during service installation (apt-get, mkcert download, StevenBlack list)

Python dependencies

# Runtime (auto-installed by pip)
flask          # Web framework — only external dependency

# Standard library (no install needed)
os, sys, json, glob, socket, subprocess, threading, re, time, base64
hashlib, secrets, sqlite3, zipfile, functools, hmac
queue.Queue, pathlib.Path, datetime

System packages installed on demand

PackageInstalled when
bind9 bind9utils dnsutilsDNS → Install DNS Server
nginxNginx Proxy → Install / Remove → Install Nginx
isc-dhcp-serverDHCP → Install & Configure DHCP
libnss3-tools wget + mkcert binarySSL / TLS → Install mkcert
certbot python3-certbot-dns-rfc2136SSL / TLS → Let's Encrypt → Install Certbot

⚡ Installation

1
Install Python & Flask
sudo apt-get update
sudo apt-get install -y python3 python3-pip
pip3 install flask
2
Copy the script to your server
sudo cp localnet_v4_alpha_b.py /usr/local/bin/localnet
sudo chmod +x /usr/local/bin/localnet
3
Run as root
sudo python3 /usr/local/bin/localnet

You will see startup output including the web UI URL and detected server IP.

4
Open the web UI
http://<your-server-ip>:8091

Or http://localhost:8091 if on the same machine.

5
(Optional) Run as a systemd service
# /etc/systemd/system/localnet.service
[Unit]
Description=LocalNet Manager v4
After=network.target

[Service]
ExecStart=/usr/bin/python3 /usr/local/bin/localnet
Restart=on-failure
User=root

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now localnet
ℹ️
When run as a non-root user, the application falls back to storing config in /tmp/localnet_v4/ and the SQLite database in the same location. All write operations that require root will return HTTP 403.

🔑 First Login

On first run, navigate to http://<server>:8091. You will be redirected to the login page.

⚠️
Default password is localnet. Change it immediately via Settings → Change Password. The password is stored as a SHA-256 hash in /etc/localnet/config.json.

After logging in:

  1. Navigate to Settings and change the password.
  2. Verify your server IP is detected correctly in the Network page.
  3. Go to DNS → Setup and install BIND9 with your chosen TLD (default: localnet).
  4. Add an A record for your server in DNS → Records.
  5. Install Nginx via Nginx Proxy → Install / Remove, then add your first proxy.

🖥 Dashboard

LocalNet Manager Dashboard
fig 1 — Dashboard showing service status, record counts, active domain, and /etc/resolv.conf

The dashboard provides a live overview of the entire stack. It polls GET /api/status every 8 seconds and the service status indicators in the top-right corner (BIND9 / NGINX) update in real time.

Dashboard widgets

WidgetData sourceDescription
BIND9 DNSsystemctl is-active bind9Green dot = active. "reload" button calls POST /api/service/reload
Nginxsystemctl is-active nginxSame as above for Nginx
systemd-resolvedsystemctl is-active systemd-resolvedStatus + reload button
DNS RecordsCount of A/CNAME/TXT entries in zone fileLinks to DNS → Records
Nginx ProxiesCount of sites in /etc/nginx/sites-enabled/Links to Nginx Proxy
SSL CertsCount of *.pem files in /etc/localnet/certs/Links to SSL / TLS
Active Domaincfg.domain from config.jsonShows TLD + server IP
/etc/resolv.confFirst 300 chars of the fileRead-only preview

Output log panel

The collapsible log panel at the bottom of every page shows real-time output from the SSE stream (GET /api/logs/stream). It displays the last 80 historical entries on connect, then streams new entries as scripts execute. Log entries are color-coded: success, error, and plain info.

🌐 DNS Server

The DNS section has three tabs: Setup, Records, and Test.

Setup tab

DNS Server Setup tab
fig 2 — DNS Setup: domain TLD, server IP, forwarders, VPN-compatible mode toggle

Configures and installs BIND9. The install script performs 8 steps:

  1. Installs bind9 bind9utils dnsutils via apt-get
  2. Writes /etc/bind/named.conf.options (ACL, recursion, forwarders, DNSSEC)
  3. Writes /etc/bind/named.conf.local (forward and reverse zone declarations)
  4. Writes the forward zone file (/etc/bind/db.{domain}) with SOA, NS, and ns1 A record
  5. Writes the reverse zone file (/etc/bind/db.{octet1}.{octet2}.{octet3})
  6. Validates with named-checkconf + named-checkzone and starts/enables named
  7. Writes /etc/systemd/resolved.conf.d/localnet.conf to route ~domain through BIND9
  8. Symlinks /etc/resolv.conf → systemd-resolved stub and flushes caches
FieldDefaultNotes
Domain (TLD)localnetYour private top-level domain. All records live under .localnet
Server IPAuto-detectedDropdown of non-loopback IPv4 interfaces. Used as BIND9's listen-on address
Forwarders1.1.1.2, 1.0.0.2Comma-separated upstream resolvers for non-local queries
VPN-compatible modeONRoutes through systemd-resolved stub; allows VPN to inject its own DNS

Records tab

DNS Records tab
fig 3 — DNS Records: A/CNAME sub-tabs, zone record table with delete buttons

Four record sub-tabs are available:

Sub-tabRecord typeFields
A / CNAMEA and CNAMEHostname + IP (A) or Alias + Target (CNAME)
TXTTXTHostname + value (auto-quoted). Used for SPF, DKIM, verification strings
SRVSRVService, Proto, Priority, Weight, Port, Target — e.g. _http._tcp
WildcardA with * hostIP only. Routes all unmatched subdomains to the given IP

Every add/delete operation appends or removes lines from the zone file, bumps the serial to $(date +%s), runs named-checkconf, and calls systemctl reload named. Reverse-zone PTR records are also updated automatically when adding/removing A records.

Test tab

DNS Test tab
fig 4 — DNS Lookup Test: runs a dig query against the BIND9 server

Runs dig @{server} {name} +short +time=2 and returns the result. Enter a hostname like nas.localnet to verify it resolves correctly. The DNS server IP field defaults to the detected server IP.

↔ Nginx Reverse Proxy

Proxies tab

Nginx Proxies tab
fig 5 — Nginx Proxies: active proxy list with domain, port, template, and delete button

Lists all active proxies by reading symlinks in /etc/nginx/sites-enabled/ (excluding default) and parsing the config for proxy_pass port and the # template: comment. Each proxy row shows domain, upstream port, and template badge, with a delete button that removes both the sites-available file and its symlink.

Add Proxy tab

Nginx Add Proxy tab
fig 6 — Add Proxy: template selection cards and configure form

Select a template card, then fill in Domain Name (e.g. movies.localnet), Upstream IP (usually 127.0.0.1), and Port. The generated config is written to /etc/nginx/sites-available/{domain}, symlinked to sites-enabled/, validated with nginx -t, and reloaded.

💡
Create the DNS record (CNAME or A) for the domain in the DNS section before or after adding the proxy — both work. The proxy only needs DNS to be resolvable from client devices.

📡 DHCP Server

DHCP Server page
fig 7 — DHCP Server: configure range, gateway, lease time, and DNS server IP

Installs and configures ISC-DHCP-Server (isc-dhcp-server). When enabled, network devices will receive their IP address from this server along with the LocalNet DNS server IP, making all .localnet hostnames resolve automatically on every device without manual configuration.

FieldDefaultNotes
Range Start192.168.1.100First IP in the DHCP pool. Derived from server IP subnet
Range End192.168.1.200Last IP in the DHCP pool
Router / Gateway192.168.1.1Injected as option routers. Should be your router's IP
Lease Time (seconds)3600Default lease; max lease is automatically set to 2× this value
Server IP (DNS)Auto-detectedThe LocalNet server's IP — pushed to all DHCP clients as their DNS resolver
⚠️
Only one DHCP server should run on a subnet. Disable DHCP on your router before enabling it here, or use non-overlapping ranges. Running two DHCP servers causes address conflicts.

The Active Leases tab reads /var/lib/dhcp/dhcpd.leases, parses all blocks with binding state active, and displays IP address, MAC, hostname, and expiry time for each current lease.

🔒 SSL / TLS

Two approaches to HTTPS certificates are provided, each on its own sub-tab.

mkcert (Local CA) tab

SSL TLS mkcert tab
fig 8 — mkcert: local CA install, certificate generation, issued certs panel, browser trust guide

mkcert creates a local Certificate Authority that is trusted by the machine that installs it. Any cert it signs is trusted automatically — no port 80 needed, works entirely offline.

1
Install mkcert
Downloads the latest mkcert binary for your architecture (amd64/arm64/armhf), installs the local CA into OS and browser trust stores via mkcert -install. CA files are stored in /etc/localnet/ca/.
2
Generate a certificate
Enter a domain (e.g. mypc.localnet) or leave blank for a wildcard (*.localnet). The cert is generated with three SANs: the entered domain, *.{tld}, and {tld} — stored in /etc/localnet/certs/.
3
Auto-configure Nginx
After cert generation, the script checks if a matching Nginx site config exists. If found and not already SSL-enabled, it injects listen 443 ssl; and the cert/key paths, then reloads Nginx.
4
Trust on other devices
Download the Root CA from the "Click here to download 'Root CA'" link and import /etc/localnet/ca/rootCA.pem into each device's browser/OS certificate store.

Let's Encrypt tab

SSL TLS Let's Encrypt tab
fig 9 — Let's Encrypt: Certbot DNS-01 challenge via BIND9 TSIG key

Issues globally browser-trusted Let's Encrypt certificates using the DNS-01 ACME challenge via your local BIND9 server. This means no port 80 or public IP is required — Certbot writes a temporary TXT record into your BIND9 zone, proves domain ownership, then removes it.

❗
Domain must be publicly resolvable. Let's Encrypt's validation servers must be able to look up a TXT record on your domain. This means you must own a real, registered domain (e.g. *.yourdomain.com) and have its NS records pointing somewhere public. This tab does not work with private .localnet TLDs.

The implementation generates a TSIG key (tsig-keygen), writes an rfc2136.ini credentials file, and runs certbot certonly --dns-rfc2136. The resulting cert appears in /etc/letsencrypt/live/{domain}/.

🚫 Ad Blocking

Ad Blocking page
fig 10 — Ad Blocking: 175,540 blocked domains via BIND9 RPZ, enable/disable/update controls

Network-wide ad blocking implemented entirely at the DNS layer using BIND9's Response Policy Zone (RPZ) feature. No separate proxy or additional software is needed.

How it works

  1. Downloads the StevenBlack unified hosts list (100,000–200,000 ad/tracker/malware domains)
  2. Builds a BIND9 RPZ zone file at /etc/bind/adblock/db.rpz.adblock with CNAME . entries for every domain (and its wildcard)
  3. Declares the zone in named.conf.local and adds a response-policy directive to named.conf.options
  4. Reloads BIND9 — blocked domains return NXDOMAIN to all clients on the network instantly

The current entry count is read live from the zone file by counting CNAME occurrences. Click Update List to re-download and regenerate the zone with the latest blocklist.

ℹ️
Ad blocking requires BIND9 to be installed and running. It integrates directly with your existing DNS setup — no additional ports or services.

💾 Backups & Restore

Backups and Restore page
fig 11 — Backups: automatic snapshot history with restore and delete per entry

Before every install, remove, or modify operation, LocalNet Manager automatically creates a ZIP snapshot of /etc/bind and /etc/nginx. Backups are stored in /etc/localnet/backups/ and logged to the SQLite database.

Backup file naming

backup_{YYYYMMDD}_{HHMMSS}_{sanitized_label}.zip

Example:
backup_20280417_170918_generate_cert___auto_configure__mypc_loc.zip

Operations

ActionBehavior
Create Snapshot NowManually trigger a backup with label "manual"
RestoreExtracts the ZIP to /, then restarts BIND9 and Nginx automatically
DeleteRemoves the ZIP file and its database record
RefreshRescans the backup directory for any ZIPs not in the database
✅
Up to 20 most recent backups are shown. The backup directory is also scanned on each load in case backups exist that aren't tracked in the database (e.g. manually copied files).

📜 System Logs

System Logs page
fig 12 — System Logs: live journalctl stream for DHCP service

Streams journalctl -fu {service} output live via Server-Sent Events. Available log tabs:

Tabjournalctl unit
BIND9named
Nginxnginx
DHCPisc-dhcp-server
Resolvedsystemd-resolved

The stream starts with the last 100 journal lines, then follows new entries in real time. Navigation away from the Logs page automatically kills the SSE connection via the overridden navigate() function.

🔌 Network Interfaces

Network Interfaces page
fig 13 — Network Interfaces: detected non-loopback IPv4 interfaces with state and subnet

Displays all non-loopback, non-Docker, non-bridge IPv4 network interfaces detected via ip -j addr. Shows interface name, IP address, prefix length, state (UP/DOWN), and computed subnet.

The multi-subnet tip at the bottom explains how to add additional subnets to the BIND9 trusted_network ACL for multi-VLAN environments.

⚙ Settings

Settings page
fig 14 — Settings: general config, change password, quick service controls

General Settings

Saved to /etc/localnet/config.json but do not apply to a running DNS install — re-run Install DNS Server to apply domain or forwarder changes.

SettingKeyDefault
Default Domain (TLD)domainlocalnet
DNS Forwardersforwarders["1.1.1.2","1.0.0.2"]
VPN-Compatible Modevpn_safetrue

Change Password

Requires the current password. New password must be at least 6 characters. Stored as hashlib.sha256(pw.encode()).hexdigest() in config.json under the key password_hash.

Quick Service Controls

Reload BIND9, Reload Nginx, or Restart systemd-resolved without leaving the page. These call POST /api/service/reload with the service name.

🔐 API — Auth

All API endpoints (except GET /login and POST /login) require an active session cookie. Unauthenticated requests to /api/* return HTTP 401 {"error":"Unauthorized"}. Non-API routes redirect to /login.

POST /login Authenticate and create session ▼

Form data: password (string)

Validates against stored SHA-256 hash (or default localnet on first run). On success, sets session["authed"] = True and redirects to /. On failure, re-renders login page with error.

GET /logout Clear session and redirect to login ▼

Calls session.clear() and redirects to /login.

POST /api/auth/change-password Change the web UI password ▼

Body:

{ "current": "oldpassword", "new": "newpassword" }

Returns 403 if current password is wrong. Returns 400 if new password is shorter than 6 characters. On success, updates password_hash in config.json.

{ "ok": true }

📊 API — Status & Config

GET /api/status Overall system status snapshot ▼

Returns service states (via systemctl is-active), record/proxy/cert counts, server IP, domain, and the first 300 chars of /etc/resolv.conf.

{
  "services":     { "bind9": "active", "nginx": "active", "systemd-resolved": "active" },
  "record_count": 3,
  "proxy_count":  2,
  "cert_count":   1,
  "resolv":       "# This is /run/systemd/resolve/stub-resolv.conf...",
  "is_root":      true,
  "server_ip":    "192.168.1.112",
  "domain":       "localnet"
}
GET /api/config Read current configuration + detected interfaces ▼
{
  "domain":        "localnet",
  "vpn_safe":      true,
  "server_ip":     "192.168.1.112",
  "forwarders":    ["1.1.1.2", "1.0.0.2"],
  "adblock_enabled": false,
  "interfaces":    [{ "name": "wlp3s0", "ip": "192.168.1.112", "prefix": 24, "state": "UP" }]
}
POST /api/config Save configuration (non-destructive) ▼

Body: Any subset of config keys (domain, forwarders, vpn_safe). Only keys present in DEFAULT_CONFIG are merged.

{ "ok": true }
GET /api/network/interfaces List detected network interfaces ▼

Returns all non-loopback, non-Docker IPv4 interfaces as parsed by ip -j addr.

[{ "name": "wlp3s0", "ip": "192.168.1.112", "prefix": 24, "state": "UP" }]
POST /api/service/reload Reload or restart a system service ▼

Body:

{ "service": "bind9" }

Allowed values: bind9 (reload), nginx (reload), systemd-resolved (restart). Returns 400 for any other value.

🌐 API — DNS

POST /api/dns/install Install and configure BIND9 (root required) ▼

Body:

{
  "domain":   "localnet",
  "server_ip": "192.168.1.112",
  "vpn_safe":  true,
  "forwarders": ["1.1.1.2", "1.0.0.2"]
}

Runs an 8-step bash install script asynchronously. Progress streams through /api/logs/stream.

POST /api/dns/remove Purge BIND9 and restore system DNS (root required) ▼

Stops and purges bind9 bind9utils bind9-doc dnsutils, removes /etc/bind and the resolved drop-in config, restarts systemd-resolved.

GET /api/dns/records List all zone records ▼

Parses /etc/bind/db.{domain} and returns A, CNAME, MX, TXT, AAAA records (excludes SOA/NS/@).

[
  { "host": "ns1",  "type": "A",     "value": "192.168.1.112" },
  { "host": "mypc", "type": "A",     "value": "192.168.1.112" },
  { "host": "dash", "type": "CNAME", "value": "mypc.localnet." }
]
POST /api/dns/records/a Add an A record (root required) ▼
{ "host": "nas", "ip": "192.168.1.20" }

Appends nas IN A 192.168.1.20 to the zone file, appends a PTR record to the reverse zone, bumps serial, and reloads BIND9.

POST /api/dns/records/cname Add a CNAME record (root required) ▼
{ "alias": "movies", "target": "nas" }

Creates movies.localnet → nas.localnet.

POST /api/dns/records/txt Add a TXT record (root required) ▼
{ "host": "_dmarc", "value": "v=DMARC1; p=none" }

Value is automatically wrapped in double quotes if not already present.

POST /api/dns/records/srv Add an SRV record (root required) ▼
{
  "service":  "_http",
  "proto":    "_tcp",
  "priority": 10,
  "weight":   5,
  "port":     8080,
  "target":   "mypc"
}

Writes: _http._tcp IN SRV 10 5 8080 mypc.localnet.

POST /api/dns/records/wildcard Add a wildcard A record (root required) ▼
{ "ip": "192.168.1.112" }

Writes: * IN A 192.168.1.112 — all unmatched subdomains resolve to this IP.

DELETE /api/dns/records/<host> Remove all records for a hostname (root required) ▼

Runs sed -i '/^{host}/d' on the forward zone and removes the matching PTR entry from the reverse zone. Bumps serial and reloads.

POST /api/dns/test Run a DNS lookup test via dig ▼
{ "name": "nas.localnet", "server": "192.168.1.112" }

Runs dig @{server} {name} +short +time=2. Returns:

{ "result": "192.168.1.20", "ok": true }
POST /api/dns/notes Set a note on a DNS record ▼
{ "host": "nas", "note": "My NAS server" }

Stored in SQLite record_notes table keyed by (host, domain).

↔ API — Nginx

POST /api/nginx/install Install Nginx (root required) ▼

Runs apt-get install -y nginx, removes the default site, and enables the service.

POST /api/nginx/uninstall Purge Nginx (root required) ▼

Stops, purges nginx nginx-common nginx-full, and removes /etc/nginx and /var/log/nginx.

GET /api/nginx/proxies List active proxies ▼
[
  { "domain": "dash.localnet", "port": "8091", "template": "basic" },
  { "domain": "mypc.localnet", "port": "8091", "template": "basic" }
]
POST /api/nginx/proxies Add a new proxy (root required) ▼
{
  "domain":      "movies.localnet",
  "template":    "basic",
  "upstream_ip": "127.0.0.1",
  "port":        "8096",
  "extras":      {}
}

For the loadbalancer template, pass "extras": { "upstreams": "192.168.1.10:8080\n192.168.1.11:8080" }.

DELETE /api/nginx/proxies/<domain> Remove a proxy (root required) ▼

Deletes /etc/nginx/sites-available/{domain} and its symlink in sites-enabled/, then reloads Nginx.

GET /api/nginx/templates List available proxy templates ▼
{
  "basic":         { "label": "Basic Proxy",      "desc": "Simple HTTP reverse proxy", "icon": "⇌", "color": "#4a9eff" },
  "websocket":     { "label": "WebSocket",         "desc": "WebSocket-capable (Grafana, Jupyter, etc.)", ... },
  "homeassistant": { "label": "Home Assistant",    "desc": "Long-poll + WebSocket for HA", ... },
  "nextcloud":     { "label": "Nextcloud",         "desc": "Large uploads, CalDAV/CardDAV", ... },
  "loadbalancer":  { "label": "Load Balancer",     "desc": "Round-robin multiple upstreams", ... }
}
POST /api/nginx/notes Set a note on a proxy ▼
{ "domain": "movies.localnet", "note": "Jellyfin media server" }

Stored in SQLite proxy_notes table keyed by domain.

📡 API — DHCP

POST /api/dhcp/install Install and configure ISC-DHCP-Server (root required) ▼
{
  "server_ip":   "192.168.1.112",
  "router":      "192.168.1.1",
  "range_start": "192.168.1.100",
  "range_end":   "192.168.1.200",
  "lease_time":  3600
}

All fields are optional with sensible defaults derived from the detected server IP.

POST /api/dhcp/remove Purge ISC-DHCP-Server (root required) ▼

Stops, disables, and purges isc-dhcp-server.

GET /api/dhcp/leases List active DHCP leases ▼

Parses /var/lib/dhcp/dhcpd.leases. Returns only leases with binding state active.

[{ "ip": "192.168.1.105", "mac": "aa:bb:cc:dd:ee:ff", "hostname": "laptop", "starts": "2028/04/17 17:00:00", "ends": "2028/04/17 18:00:00", "state": "active" }]
GET /api/dhcp/status Check if DHCP server is running ▼
{ "active": true }

🔒 API — SSL / TLS

POST /api/ssl/mkcert/install Install mkcert and create local CA (root required) ▼

Downloads the latest mkcert binary for the system architecture, places it at /usr/local/bin/mkcert, and runs CAROOT=/etc/localnet/ca mkcert -install to create and trust the local CA.

POST /api/ssl/mkcert/cert Generate a mkcert certificate (root required) ▼
{ "domain": "mypc.localnet" }

If domain is blank or omitted, defaults to the configured TLD (effectively a wildcard). The cert is signed with three SANs: {domain}, *.{tld}, {tld}. Files are saved to /etc/localnet/certs/{domain}.pem and {domain}-key.pem. Automatically injects SSL config into a matching Nginx site if found.

GET /api/ssl/certs List issued mkcert certificates ▼
[{ "name": "mypc.localnet", "path": "/etc/localnet/certs/mypc.localnet.pem" }]
DELETE /api/ssl/certs/<domain> Delete a cert and clean Nginx config (root required) ▼

Removes the .pem and -key.pem files, then strips listen 443 ssl; and all ssl_* directives from the matching Nginx site config and reloads.

GET /api/ssl/rootca Download the local Root CA certificate ▼

Serves /etc/localnet/ca/rootCA.pem as a download named LocalNet-Root-CA.pem. Returns 404 if mkcert hasn't been installed yet.

POST /api/ssl/certbot/install Install Certbot with DNS-RFC2136 plugin (root required) ▼

Installs certbot and python3-certbot-dns-rfc2136 via apt-get.

POST /api/ssl/certbot/cert Request a Let's Encrypt cert via DNS-01 (root required) ▼
{ "email": "you@example.com", "domain": "*.yourdomain.com" }

Generates a TSIG key, writes /etc/localnet/certbot/rfc2136.ini, and runs certbot certonly --dns-rfc2136. The certificate is placed in /etc/letsencrypt/live/{domain}/.

GET /api/ssl/letsencrypt/certs List issued Let's Encrypt certificates ▼
[{ "name": "yourdomain.com", "cert": "/etc/letsencrypt/live/yourdomain.com/fullchain.pem", "key": "/etc/letsencrypt/live/yourdomain.com/privkey.pem", "exists": true }]

🚫 API — Ad Blocking

POST /api/adblock/enable Download blocklist and enable RPZ ad blocking (root required) ▼

Downloads https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts, builds a BIND9 RPZ zone file, configures BIND9, and reloads. Sets adblock_enabled: true in config.json.

POST /api/adblock/disable Remove RPZ zone and disable ad blocking (root required) ▼

Removes the zone declaration from named.conf.local, removes the response-policy line from named.conf.options, deletes /etc/bind/adblock/, and reloads BIND9.

GET /api/adblock/status Get ad blocking status and blocked domain count ▼
{ "enabled": true, "entry_count": 350880 }

entry_count is read live from the RPZ zone file by counting CNAME occurrences (each blocked domain has two entries: itself and its wildcard).

💾 API — Backups

GET /api/backups List all backups ▼
[{ "id": 1, "path": "/etc/localnet/backups/backup_20280417_170918_generate_cert.zip", "label": "Generate cert & auto-configure: mypc.localnet", "created_at": "2028-04-17T17:09:18" }]

Merges SQLite records with a filesystem scan — ZIPs present on disk but missing from the DB are included.

POST /api/backups/create Create a manual backup (root required) ▼
{ "label": "pre-migration" }
{ "ok": true, "path": "/etc/localnet/backups/backup_20280417_171000_pre_migration.zip" }
POST /api/backups/restore Restore a backup (root required) ▼
{ "path": "/etc/localnet/backups/backup_20280417_171000_pre_migration.zip" }

Extracts the ZIP to / (restoring both /etc/bind and /etc/nginx), then restarts BIND9 and Nginx asynchronously.

POST /api/backups/delete Delete a backup (root required) ▼
{ "path": "/etc/localnet/backups/backup_20280417_171000_pre_migration.zip" }

Deletes the ZIP file and removes the database record.

📜 API — Logs

GET /api/logs/stream SSE stream of LocalNet application log ▼

Content-Type: text/event-stream

Replays the last 80 log entries from the in-memory ring buffer (max 1000 entries), then streams new entries as they are produced. Sends a ping event every 20 seconds to keep the connection alive. Each event is a JSON object:

data: {"t": "18:51:52", "msg": "LocalNet Manager v4 started", "level": "success"}

data: {"type": "ping"}

level values: info, success, error.

GET /api/logs/journal?service=bind9&tail=100 SSE stream of journalctl for a system service ▼

Query params:

  • service: one of bind9, nginx, dhcp, resolved (default: bind9)
  • tail: number of historical lines to show (default: 100)

Runs journalctl -fu {unit} --no-pager -n {tail} --output=short as a subprocess and streams each line as:

data: {"msg": "Apr 17 18:51:52 named[1234]: zone localnet/IN: loaded serial 1713376312"}

The subprocess is killed when the SSE connection closes.

📄 Configuration File

Stored at /etc/localnet/config.json (falls back to /tmp/localnet_v4/ when not root).

{
  "domain":         "localnet",       // Private TLD for all DNS records
  "vpn_safe":       true,             // Route through systemd-resolved stub
  "server_ip":      "192.168.1.112",  // Auto-detected; baked into BIND9 config
  "forwarders":     ["1.1.1.2", "1.0.0.2"], // Cloudflare for Families (default)
  "password_hash":  "sha256hex...",   // SHA-256 of web UI password
  "secret_key":     "hex64...",       // Flask session secret (auto-generated)
  "adblock_enabled":false             // Persisted adblock toggle state
}

Important paths

PathPurpose
/etc/localnet/config.jsonApplication configuration
/etc/localnet/localnet.dbSQLite: notes, backup history
/etc/localnet/certs/mkcert-issued certificates
/etc/localnet/ca/mkcert local CA root
/etc/localnet/backups/ZIP backup snapshots
/etc/localnet/certbot/Certbot TSIG key and rfc2136.ini
/etc/bind/db.{domain}BIND9 forward zone file
/etc/bind/named.conf.optionsBIND9 global options (ACL, forwarders)
/etc/bind/named.conf.localBIND9 zone declarations
/etc/bind/adblock/db.rpz.adblockAd blocking RPZ zone
/etc/nginx/sites-available/Nginx proxy config files
/etc/nginx/sites-enabled/Nginx active proxy symlinks
/etc/dhcp/dhcpd.confISC-DHCP-Server configuration
/etc/systemd/resolved.conf.d/localnet.confsystemd-resolved DNS routing
/tmp/_localnet_v4.shTemporary script file for each operation

📐 Nginx Proxy Templates

Templates are rendered with simple .replace() substitution (not f-strings) so Nginx variables like $host and $remote_addr are preserved literally.

basic

# template: basic
server {
    listen 80;
    server_name DOMAIN;
    location / {
        proxy_pass http://UPIP:PORT;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

websocket

# template: websocket — adds Upgrade headers and 24h timeouts
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_send_timeout 86400;

homeassistant

# template: homeassistant — WebSocket + dedicated /api/websocket location, 1h timeouts

nextcloud

# template: nextcloud — 10GB body size, CalDAV/CardDAV redirects, 5min timeouts, no buffering
client_max_body_size 10G;
proxy_request_buffering off;
location /.well-known/carddav { return 301 $scheme://$host/remote.php/dav; }
location /.well-known/caldav  { return 301 $scheme://$host/remote.php/dav; }

loadbalancer

# template: loadbalancer — upstream block with keepalive, round-robin by default
upstream {DOMAIN}_backend {
    server 192.168.1.10:8080;
    server 192.168.1.11:8080;
    keepalive 32;
}
server { proxy_pass http://{DOMAIN}_backend; }

The upstream name is derived from the domain with non-alphanumeric characters replaced by underscores.

🛡 Security Notes

❗
This application is designed for trusted LAN use only. It runs on port 8091 with no TLS by default. Do not expose port 8091 to the internet.

Authentication implementation

  • Password stored as hashlib.sha256(pw.encode()).hexdigest() — not salted. Adequate for a single-user LAN tool, but not production-grade.
  • Comparison uses hmac.compare_digest() to prevent timing attacks.
  • Flask secret key is a 64-character hex string generated with secrets.token_hex(32) and persisted in config.json so sessions survive restarts.
  • All API endpoints except /login are guarded by @app.before_request → require_auth.

Shell injection considerations

All DNS hostnames, IPs, and domain values passed to shell scripts are inserted using Python f-strings. Values from user input are used directly in sed and echo commands. For extra safety, consider sanitizing inputs before submission — the UI performs basic client-side validation but the backend trusts the values it receives.

Backup security

Backups contain your full BIND9 and Nginx configuration. Store the /etc/localnet/backups/ directory securely or restrict access.

Self-hosting the UI over HTTPS

To serve the LocalNet Manager itself over HTTPS, generate a mkcert cert for dash.localnet (or your chosen name), then add an Nginx proxy pointing to localhost:8091 using the mkcert cert for TLS termination.

🔧 Troubleshooting

DNS names don't resolve from other devices

  1. Make sure DHCP is installed and configured with your LocalNet server's IP as the DNS server.
  2. Or manually set each device's DNS server to the LocalNet server IP.
  3. Verify with: dig @192.168.1.112 nas.localnet +short

BIND9 fails to start after editing records

sudo named-checkconf
sudo named-checkzone localnet /etc/bind/db.localnet

Use the Backups page to restore the last known-good configuration.

Nginx returns "502 Bad Gateway"

The upstream service is not running or the port is wrong. Verify the upstream: curl http://127.0.0.1:<port>.

mkcert cert not trusted in browser

You must import the Root CA (/etc/localnet/ca/rootCA.pem) into the browser's or OS's certificate store on each device that needs to trust the certs. The "Click here to download 'Root CA'" link on the SSL page serves this file directly.

Application shows "(Root: NO)" warning

The app was not started with sudo. Most write operations will return HTTP 403. Restart with: sudo python3 localnet_v4_alpha_b.py

Log stream disconnects frequently

The SSE stream sends a ping every 20 seconds. Reverse proxies with short read timeouts (e.g. Nginx default 60s) may close the connection. If running LocalNet Manager behind Nginx, add proxy_read_timeout 3600; to the proxy config.

Config saved in /tmp instead of /etc/localnet

Running without root. Configuration and database are stored in /tmp/localnet_v4/ and will be lost on reboot. Always run as root for persistent storage.

⬡ LocalNet Manager v4 — Alpha Documentation localnet_v4_alpha_b.py