Router-agnostic network configuration manager
Project description
routerless
Infrastructure-as-Code for your home network.
Declare DHCP reservations, port forwards, and firewall rules in YAML — plan to preview changes, apply to sync them to your router. Supports Bbox Ultim, Freebox, OpenWrt, and QNAP Qhora out of the box.
Like Terraform, but for your home network:
$ routerless plan --target bbox
Section: dhcp (+3 ~1)
+ ADD lease "NAS" AA:BB:CC:DD:EE:FF → 192.168.1.20
~ CHANGE lease "Pi" ip: 192.168.1.50 → 192.168.1.51
Section: nat ✓ no changes
Plan: 3 to add, 1 to change.
Supported Routers
| Type | Authentication | Protocol |
|---|---|---|
Bbox Ultim (bbox_ultim) |
Cookie (password) | HTTPS REST |
Freebox (freebox) |
HMAC-SHA1 (app_token) | HTTPS REST |
OpenWrt (openwrt) |
SSH key or password | SSH + UCI |
QNAP Qhora 301W (qnap_qhora) |
SSH password | SSH + UCI |
Installation
Requirements: Python 3.13+
# Install from PyPI
pip install routerless
# Or install from source (for development)
git clone https://github.com/you/routerless
cd routerless
pip install -e ".[dev]"
Quick Start
# 1. Create a new config from template
routerless init ~/my-network
cd ~/my-network
# 2. Fill in your router credentials
cp secrets.yaml.example secrets.yaml
# edit secrets.yaml
# 3. Validate
routerless validate
# 4. Preview what would change (like terraform plan)
routerless plan --target bbox
# 5. Apply
routerless apply --target bbox
Configuration path resolution
All commands accept an optional CONFIG argument. Routerless resolves it as follows:
| Argument | Resolved file |
|---|---|
| (omitted) | ./configuration.yaml (current directory) |
./my-network |
./my-network/configuration.yaml |
./my-network/prod.yaml |
./my-network/prod.yaml |
# All equivalent when run from the config directory
routerless plan --target bbox
routerless plan --target bbox .
routerless plan --target bbox configuration.yaml
# Point at a different directory
routerless plan --target bbox ~/my-network
routerless plan --target bbox ~/my-network/configuration.yaml
Commands
init
Scaffold a new configuration directory with ready-to-edit template files.
routerless init ~/my-network
routerless init ./config --force # overwrite existing files
Creates:
my-network/
configuration.yaml — main config (targets + !include sections)
secrets.yaml.example — credential template (copy to secrets.yaml)
dhcp.yaml — DHCP settings and static leases
nat.yaml — port-forwarding rules
firewall.yaml — firewall rules
.gitignore — pre-configured to ignore secrets.yaml
Existing files are never overwritten unless --force is passed.
validate
Parse and validate the configuration file and all its includes.
routerless validate
routerless validate ~/my-network
routerless validate ~/my-network/configuration.yaml
plan
Preview what apply would add, change, or delete — no writes to the device.
routerless plan --target bbox
routerless plan --target bbox ~/my-network
routerless plan --target openwrt --section dhcp --section nat ~/my-network/configuration.yaml
Output format:
Comparing local config against target 'bbox' (bbox_ultim)…
Section: dhcp (+1 ~2)
+ ADD lease "NAS-Server" AA:BB:CC:DD:EE:FF → 192.168.1.20
~ CHANGE lease "Hub" BB:CC:DD:EE:FF:00 ip: 192.168.1.10 → 192.168.1.11
~ CHANGE lease "Home Assistant" CC:DD:EE:FF:00:11 hostname: 'homeassistant' → 'home-assistant'
Section: nat ✓ no changes
Plan: 1 to add, 2 to change.
Run routerless apply --target bbox --section dhcp to apply.
apply
Apply one or more sections to a target device.
# Apply all sections (from current directory)
routerless apply --target bbox
# Apply specific sections only
routerless apply --target openwrt --section dhcp
routerless apply --target openwrt --section nat --section firewall
# Point at a specific directory or file
routerless apply --target openwrt ~/my-network
routerless apply --target openwrt --section nat --section firewall ~/my-network/configuration.yaml
import
Read the current device configuration and write it to section files.
# Import all sections to current directory
routerless import --target bbox
# Import only NAT rules
routerless import --target bbox --section nat
# Import from a specific config directory
routerless import --target bbox --output-dir ./imported ~/my-network
Writes dhcp.yaml, nat.yaml, and firewall.yaml into the output directory (. by default).
If a file already exists, a unified diff is displayed and you are prompted:
Section: dhcp file: ./dhcp.yaml
--- dhcp.yaml (current)
+++ dhcp.yaml (device)
@@ -1,4 +1,4 @@
static_leases:
- - name: OldName
+ - name: NAS
Action [o=override / a=append / s=skip] >
- Override — replace the file with the device content.
- Append — keep existing entries and add new ones from the device (deduplicated by MAC / port+protocol / rule name).
When importing to a new directory,
importalso generates aconfiguration.yamlwith the target block and!includereferences for each imported section.
# Bootstrap a new config dir from a live device
routerless import --target bbox ../new-site
# → creates new-site/dhcp.yaml, nat.yaml, firewall.yaml, configuration.yaml
dump
Read the current configuration from a device and print it as YAML.
routerless dump --target bbox
routerless dump --target openwrt --output backup.yaml ~/my-network
diff
Show a unified diff between the local config file and the running device config.
routerless diff --target bbox --section dhcp
routerless diff --target bbox ~/my-network
status
Show general device status (WAN/LAN IPs, WiFi state, uptime, connected devices).
routerless status --target bbox
routerless status --target openwrt ~/my-network
Model : Bbox Ultim (ABC123)
LAN IP : 192.168.1.254
WAN IP : 82.x.x.x
Internet : Connected
WiFi 2.4 GHz : ON
WiFi 5 GHz : ON
Devices : 14
Uptime : 12d 3h 42m
devices
List connected (or all known) devices.
routerless devices --target bbox
routerless devices --target openwrt --all ~/my-network
Device IP MAC Hostname Type Link
----------------------------------------------------------------
192.168.1.10 AA:BB:CC:DD:EE:FF mynas Ethernet port 1
192.168.1.20 11:22:33:44:55:66 laptop Wifi 5 RSSI -62
wifi status / on / off
Inspect or toggle WiFi radios.
routerless wifi status --target bbox
routerless wifi on --target bbox
routerless wifi off --target openwrt ~/my-network
Configuration
examples/configuration.yaml
version: "1.0"
targets:
bbox:
type: bbox_ultim
host: !secret bbox_host
password: !secret bbox_password
freebox:
type: freebox
host: !secret freebox_host
username: admin
password: !secret freebox_app_token
verify_ssl: true # Validate Freebox Root CA certificate (default: true)
openwrt:
type: openwrt
host: !secret openwrt_host
ssh_user: root
ssh_key: !secret openwrt_ssh_key
ssh_port: 22
qhora:
type: qnap_qhora
host: !secret qhora_host
ssh_user: admin
ssh_password: !secret qhora_ssh_password
ssh_port: 22200
dhcp: !include dhcp.yaml
nat: !include nat.yaml
firewall: !include firewall.yaml
examples/secrets.yaml (never committed)
bbox_host: 192.168.1.254
bbox_password: your_password
freebox_host: 192.168.1.254
freebox_app_token: your_freebox_app_token
openwrt_host: 192.168.1.1
openwrt_ssh_key: ~/.ssh/id_ed25519
qhora_host: 192.168.1.2
qhora_ssh_password: your_password
Copy examples/secrets.yaml.example to get started.
examples/dhcp.yaml
subnet: "192.168.1.0/24"
gateway: "192.168.1.1"
static_leases: !include_dir_merge_list static_leases
Each file in static_leases/ is a YAML list:
# static_leases/servers.yaml
- name: "Home Assistant" # friendly display label (any characters)
mac: "AA:BB:CC:DD:EE:FF"
ip: "192.168.1.20"
hostname: home-assistant # DNS-safe identifier sent to the router API
- name: NAS # if hostname is omitted, name is used as-is
mac: "11:22:33:44:55:66"
ip: "192.168.1.21"
hostname is the value pushed to the router (e.g. Bbox hostname field) and is
used by plan / apply to detect changes. It must be DNS-safe (letters, digits,
hyphens — no spaces or accents). When omitted, name is used in its place.
name is a human-readable label used only for display in CLI output.
examples/nat.yaml
port_forwards:
- name: "Home Assistant"
protocol: tcp
external_port: 8123
internal_ip: "192.168.1.21"
internal_port: 8123
examples/firewall.yaml
rules:
- name: "Block IoT to WAN"
direction: forward
src: iot
dest: wan
action: DROP
YAML Custom Tags
| Tag | Behaviour |
|---|---|
!include <file> |
Inline another YAML file (path relative to the current file) |
!include_dir_merge_list <dir> |
Merge all *.yaml files in a directory as a single list |
!include_dir_named <dir> |
Dict keyed by filename stem |
!secret <key> |
Look up a value from secrets.yaml (always resolved from config root) |
Project Structure
routerless/
├── cli.py # Click CLI entry point
├── yaml_loader.py # Custom YAML tags
├── models/
│ ├── config.py # Pydantic v2 models (NetworkConfig, DHCPConfig…)
│ └── status.py # Read-only dataclasses (AdapterStatus, WifiRadio…)
└── adapters/
├── base.py # BaseAdapter ABC
├── bbox_ultim.py # Bbox Ultim — HTTPS REST
├── freebox_router.py # Freebox — HTTPS REST
├── openwrt.py # OpenWrt — SSH + UCI
└── qnap_qhora.py # QNAP Qhora — delegates to OpenWrtAdapter
examples/
├── configuration.yaml
├── secrets.yaml.example
├── dhcp.yaml
├── nat.yaml
├── firewall.yaml
├── templates/ # Generic templates used by `init` command
└── static_leases/
tests/ # pytest — all device I/O mocked
.github/
└── prompts/
├── add-adapter.prompt.md # Guide: add a new router adapter
├── add-feature.prompt.md # Guide: add a new CLI command/feature
└── discover-bbox-interface.prompt.md # Capture Bbox XHR via mitmproxy
Development
# Install in editable mode with dev dependencies
pip install -e ".[dev]"
# Lint
ruff check .
# Run all tests
pytest
# Run a specific test file
pytest tests/test_import.py -v
pytest tests/test_bbox.py -v
All tests mock device I/O — no real router needed. Tests must stay green before and after every change.
Contributing
Contributions are welcome! Please read CONTRIBUTING.md for the full guide covering workflow, code style, test conventions, commit messages, and how to add new adapters or CLI features.
Adding a New Adapter
Use the built-in prompt:
# In VS Code with GitHub Copilot, reference:
# .github/prompts/add-adapter.prompt.md
Or follow these steps manually:
- Add a
TargetTypeenum value inrouterless/models/config.py - Create
routerless/adapters/my_router.pyextendingBaseAdapter - Register it in
_ADAPTER_MAPinrouterless/cli.py - Add tests in
tests/test_my_router.py
Adapter Notes
Bbox Ultim
- Auth is cookie-based. Login requires
RefererandOriginheaders; after login a CSRF btoken is fetched fromGET /device/tokenand appended to every mutating request. - The Bbox redirects local HTTP to
https://mabbox.bytel.fr/api/v1— this is the effective base URL. - Rate limit: 3 failed logins → up to 1200 s lockout.
- Firewall endpoint unknown —
apply_firewall()raisesNotImplementedError. - DHCP
hostnamefield:GET /dhcp/clientsreturnshostnamebut notdevice. The adapter sendshostname = lease.hostname or lease.name(DNS-safe, no spaces) to the Bbox API. The friendlynameis sent asdevice(write-only, not returned by GET).planandapplyboth compare onhostnameto avoid phantom changes.
Freebox
- Obtaining app_token: Use
python -m routerless.scripts.get_freebox_app_tokento interactively obtain your app_token, then store it insecrets.yaml. See CONTRIBUTING.md for details. - Auth is HMAC-SHA1 challenge-response using an app_token stored in
config.app_token.GET /login/→ fetch a challenge- Compute
password = hmac-sha1(app_token, challenge) POST /login/session/→ receive session_token- Include
X-Fbx-App-Auth: {session_token}in all subsequent requests
- Base URL:
https://mafreebox.freebox.fr/api/v4 - SSL/TLS: Freebox uses self-signed certificates. Routerless validates them using embedded Root CA certificates by default.
To disable verification (not recommended), set
verify_ssl: falsein your target config. - Protocol mapping:
Protocol.BOTH→"tcp_udp"(vs Bbox's"all") - Firewall: Not available in official Freebox OS API —
apply_firewall()raisesNotImplementedError - Status/Devices/WiFi: Not yet implemented — raises
NotImplementedError
OpenWrt / QNAP Qhora
- Uses UCI over SSH (paramiko). Host key policy:
RejectPolicy. - Qhora default SSH port: 22200. Enable SSH by holding the WPS button for 12 s.
apply_*methods are idempotent: they read current state before writing.
License
MIT — see the LICENSE file for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file routerless-0.2.0.tar.gz.
File metadata
- Download URL: routerless-0.2.0.tar.gz
- Upload date:
- Size: 69.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fa53038001e7a9829781e5bc389da0f3b8a54c8a13279f272b0b9d4e40c011c7
|
|
| MD5 |
f1703de4b8ad3ee17217ca589bb8f939
|
|
| BLAKE2b-256 |
108d97bbf68ff0a06c197876709d2cea860ee02d9a84e0a30797d1a9d60071bd
|
Provenance
The following attestation bundles were made for routerless-0.2.0.tar.gz:
Publisher:
release.yml on grogui42/routerless
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
routerless-0.2.0.tar.gz -
Subject digest:
fa53038001e7a9829781e5bc389da0f3b8a54c8a13279f272b0b9d4e40c011c7 - Sigstore transparency entry: 1519501534
- Sigstore integration time:
-
Permalink:
grogui42/routerless@169b727e1b1f3e38e98d4c9adcba732372c87822 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/grogui42
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@169b727e1b1f3e38e98d4c9adcba732372c87822 -
Trigger Event:
push
-
Statement type:
File details
Details for the file routerless-0.2.0-py3-none-any.whl.
File metadata
- Download URL: routerless-0.2.0-py3-none-any.whl
- Upload date:
- Size: 47.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
94e5f5d17e0d0026a0586e6dc3d0279f0cb134f001e324b8504321aa8a8a287e
|
|
| MD5 |
84985c35b4545c084e87cce4aa413ef0
|
|
| BLAKE2b-256 |
95b28d4a33b6a806314c2193c77cbce831d13af1ccda6d2ebb0ff0749e4fa5a6
|
Provenance
The following attestation bundles were made for routerless-0.2.0-py3-none-any.whl:
Publisher:
release.yml on grogui42/routerless
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
routerless-0.2.0-py3-none-any.whl -
Subject digest:
94e5f5d17e0d0026a0586e6dc3d0279f0cb134f001e324b8504321aa8a8a287e - Sigstore transparency entry: 1519501615
- Sigstore integration time:
-
Permalink:
grogui42/routerless@169b727e1b1f3e38e98d4c9adcba732372c87822 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/grogui42
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@169b727e1b1f3e38e98d4c9adcba732372c87822 -
Trigger Event:
push
-
Statement type: