Metadata-Version: 2.4
Name: routerless
Version: 0.2.1
Summary: Router-agnostic network configuration manager
Project-URL: Homepage, https://github.com/grogui42/routerless
Project-URL: Repository, https://github.com/grogui42/routerless
Project-URL: Issues, https://github.com/grogui42/routerless/issues
Author: routerless contributors
License-Expression: MIT
License-File: LICENSE
Keywords: configuration,dhcp,firewall,infrastructure-as-code,nat,network,router
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Networking
Requires-Python: >=3.13
Requires-Dist: click>=8.0
Requires-Dist: httpx>=0.27
Requires-Dist: paramiko>=3.0
Requires-Dist: pydantic>=2.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: pip-audit>=2.7; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.11; extra == 'dev'
Description-Content-Type: text/markdown

# routerless

[![CI](https://github.com/grogui42/routerless/actions/workflows/ci.yml/badge.svg)](https://github.com/grogui42/routerless/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/routerless)](https://pypi.org/project/routerless/)
[![Python 3.13+](https://img.shields.io/pypi/pyversions/routerless)](https://pypi.org/project/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+

```bash
# 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

```bash
# 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` |

```bash
# 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.

```bash
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.

```bash
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.

```bash
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.

```bash
# 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.

```bash
# 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, `import` also generates a `configuration.yaml`
with the target block and `!include` references for each imported section.

```bash
# 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.

```bash
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.

```bash
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).

```bash
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.

```bash
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.

```bash
routerless wifi status --target bbox
routerless wifi on  --target bbox
routerless wifi off --target openwrt ~/my-network
```

## Configuration

### `examples/configuration.yaml`

```yaml
version: "1.0"

targets:
  bbox:
    type: bbox_ultim
    host: !secret bbox_host
    password: !secret bbox_password

  freebox:
    type: freebox
    host: !secret freebox_host
    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)

```yaml
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`

```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:

```yaml
# 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`

```yaml
port_forwards:
  - name: "Home Assistant"
    protocol: tcp
    external_port: 8123
    internal_ip: "192.168.1.21"
    internal_port: 8123
```

### `examples/firewall.yaml`

```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

```bash
# 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](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:

```bash
# In VS Code with GitHub Copilot, reference:
# .github/prompts/add-adapter.prompt.md
```

Or follow these steps manually:
1. Add a `TargetType` enum value in `routerless/models/config.py`
2. Create `routerless/adapters/my_router.py` extending `BaseAdapter`
3. Register it in `_ADAPTER_MAP` in `routerless/cli.py`
4. Add tests in `tests/test_my_router.py`

## Adapter Notes

### Bbox Ultim

- Auth is **cookie-based**. Login requires `Referer` and `Origin` headers; after login a CSRF btoken is fetched from `GET /device/token` and 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()` raises `NotImplementedError`.
- **DHCP `hostname` field:** `GET /dhcp/clients` returns `hostname` but not `device`.
  The adapter sends `hostname = lease.hostname or lease.name` (DNS-safe, no spaces) to
  the Bbox API. The friendly `name` is sent as `device` (write-only, not returned by GET).
  `plan` and `apply` both compare on `hostname` to avoid phantom changes.

### Freebox

- **Obtaining app_token:** Use `python -m routerless.scripts.get_freebox_app_token` to interactively obtain your app_token, then store it in `secrets.yaml`.
  Set "_Modification des réglages de la Freebox_" permission on this token to be able to use _apply_ command.
  See [CONTRIBUTING.md](CONTRIBUTING.md#setting-up-freebox) for details.
- Auth is **HMAC-SHA1 challenge-response** using an app_token stored in `config.app_token`.
  1. `GET /login/` → fetch a challenge
  2. Compute `password = hmac-sha1(app_token, challenge)`
  3. `POST /login/session/` → receive session_token
  4. 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: false` in your target config.
- Protocol mapping: `Protocol.BOTH` → `"tcp_udp"` (vs Bbox's `"all"`)
- **Firewall:** Not available in official Freebox OS API — `apply_firewall()` raises `NotImplementedError`
- **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](LICENSE) — see the LICENSE file for details.

---

[Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [Code of Conduct](CODE_OF_CONDUCT.md)
