routerless 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. routerless-0.1.0/.github/dependabot.yml +15 -0
  2. routerless-0.1.0/.github/prompts/add-adapter.prompt.md +152 -0
  3. routerless-0.1.0/.github/prompts/add-feature.prompt.md +148 -0
  4. routerless-0.1.0/.github/prompts/discover-bbox-interface.prompt.md +128 -0
  5. routerless-0.1.0/.github/workflows/ci.yml +41 -0
  6. routerless-0.1.0/.github/workflows/release.yml +44 -0
  7. routerless-0.1.0/.gitignore +12 -0
  8. routerless-0.1.0/AGENTS.md +120 -0
  9. routerless-0.1.0/CODE_OF_CONDUCT.md +41 -0
  10. routerless-0.1.0/CONTRIBUTING.md +163 -0
  11. routerless-0.1.0/LICENSE +21 -0
  12. routerless-0.1.0/PKG-INFO +499 -0
  13. routerless-0.1.0/README.md +468 -0
  14. routerless-0.1.0/SECURITY.md +30 -0
  15. routerless-0.1.0/config/configuration.yaml +31 -0
  16. routerless-0.1.0/config/dhcp.yaml +13 -0
  17. routerless-0.1.0/config/firewall.yaml +13 -0
  18. routerless-0.1.0/config/nat.yaml +12 -0
  19. routerless-0.1.0/config/secrets.yaml.example +11 -0
  20. routerless-0.1.0/config/static_leases/domotique.yaml +14 -0
  21. routerless-0.1.0/config/static_leases/serveurs.yaml +9 -0
  22. routerless-0.1.0/pyproject.toml +63 -0
  23. routerless-0.1.0/routerless/__init__.py +3 -0
  24. routerless-0.1.0/routerless/adapters/__init__.py +11 -0
  25. routerless-0.1.0/routerless/adapters/base.py +90 -0
  26. routerless-0.1.0/routerless/adapters/bbox_ultim.py +665 -0
  27. routerless-0.1.0/routerless/adapters/openwrt.py +567 -0
  28. routerless-0.1.0/routerless/adapters/qnap_qhora.py +88 -0
  29. routerless-0.1.0/routerless/cli.py +977 -0
  30. routerless-0.1.0/routerless/models/__init__.py +23 -0
  31. routerless-0.1.0/routerless/models/config.py +180 -0
  32. routerless-0.1.0/routerless/models/status.py +42 -0
  33. routerless-0.1.0/routerless/version.py +1 -0
  34. routerless-0.1.0/routerless/yaml_loader.py +159 -0
  35. routerless-0.1.0/tests/__init__.py +0 -0
  36. routerless-0.1.0/tests/test_bbox.py +644 -0
  37. routerless-0.1.0/tests/test_import.py +304 -0
  38. routerless-0.1.0/tests/test_init.py +103 -0
  39. routerless-0.1.0/tests/test_models.py +123 -0
  40. routerless-0.1.0/tests/test_openwrt.py +498 -0
  41. routerless-0.1.0/tests/test_plan.py +293 -0
  42. routerless-0.1.0/tests/test_yaml_loader.py +141 -0
@@ -0,0 +1,15 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: "/"
5
+ schedule:
6
+ interval: weekly
7
+ open-pull-requests-limit: 5
8
+ labels: ["dependencies"]
9
+
10
+ - package-ecosystem: github-actions
11
+ directory: "/"
12
+ schedule:
13
+ interval: weekly
14
+ open-pull-requests-limit: 5
15
+ labels: ["dependencies"]
@@ -0,0 +1,152 @@
1
+ ---
2
+ mode: agent
3
+ description: "Add a new router adapter (new router brand/model) with full CLI support"
4
+ ---
5
+
6
+ # Add a New Router Adapter
7
+
8
+ Use this prompt to scaffold a complete new adapter for a router brand or model not yet supported.
9
+
10
+ > **Before writing any code**, read the current versions of:
11
+ > - `routerless/adapters/base.py` — source of truth for abstract methods and optional overrides
12
+ > - `routerless/cli.py` — source of truth for `_ADAPTER_MAP` and which CLI commands exist
13
+ >
14
+ > Use their actual content rather than the examples below, which may lag behind.
15
+
16
+ ## What you need to provide
17
+
18
+ - **Router name / brand** (e.g. "FritzBox", "Asus Merlin", "Synology Router")
19
+ - **Communication protocol** (REST API, SSH + CLI, SNMP, Telnet…)
20
+ - **Authentication method** (cookie, token, basic auth, SSH key…)
21
+ - **Base URL or access method** (e.g. `http://192.168.1.1/api`, SSH to port 22)
22
+
23
+ ---
24
+
25
+ ## Checklist — 5 files to create or modify
26
+
27
+ ### 1. `routerless/models/config.py` — Add TargetType enum value
28
+
29
+ ```python
30
+ class TargetType(str, Enum):
31
+ BBOX_ULTIM = "bbox_ultim"
32
+ OPENWRT = "openwrt"
33
+ QNAP_QHORA = "qnap_qhora"
34
+ MY_ROUTER = "my_router" # ← add this
35
+ ```
36
+
37
+ Also ensure `TargetConfig` has any new required fields (e.g. `api_key: str | None = None`).
38
+
39
+ ### 2. `routerless/adapters/my_router.py` — New adapter class
40
+
41
+ ```python
42
+ """MyRouter adapter."""
43
+ from __future__ import annotations
44
+
45
+ from routerless.adapters.base import BaseAdapter
46
+ from routerless.models.config import (
47
+ DHCPConfig, FirewallConfig, NATConfig, NetworkConfig, TargetType,
48
+ )
49
+ from routerless.models.status import AdapterStatus, ConnectedDevice, WifiRadio
50
+
51
+
52
+ class MyRouterAdapter(BaseAdapter):
53
+ TARGET_TYPE = TargetType.MY_ROUTER
54
+
55
+ # ── Abstract (mandatory) — inspect base.py for current list ─────────
56
+
57
+ def apply_dhcp(self, config: DHCPConfig) -> None: ...
58
+ def apply_nat(self, config: NATConfig) -> None: ...
59
+ def apply_firewall(self, config: FirewallConfig) -> None: ...
60
+ def dump(self) -> NetworkConfig: ...
61
+
62
+ # ── Optional overrides (default: raise NotImplementedError) ─────────
63
+ # Implement only if the router supports the feature.
64
+ # Inspect base.py for the current list and exact signatures.
65
+
66
+ def get_status(self) -> AdapterStatus: ...
67
+ def get_devices(self, only_active: bool = True) -> list[ConnectedDevice]: ...
68
+ def get_wifi(self) -> list[WifiRadio]: ...
69
+ def wifi_enable(self, enable: bool) -> None: ...
70
+ ```
71
+
72
+ **Patterns to follow:**
73
+ - **REST API** → copy `bbox_ultim.py`: httpx client, `_make_client()`, `_login()`, `_logout()`, `with _make_client() as client`
74
+ - **SSH + CLI** → copy `openwrt.py`: paramiko, `@contextmanager _ssh()`, `_run(client, cmd)` raising on non-zero exit
75
+ - Use `_extract_list(data, *keys)` pattern for nested API responses
76
+ - Declarative sync in `apply_*`: read current state → delete stale → create new → update changed
77
+
78
+ ### 3. `routerless/cli.py` — Register in adapter map
79
+
80
+ ```python
81
+ from routerless.adapters.my_router import MyRouterAdapter
82
+
83
+ _ADAPTER_MAP: dict[TargetType, type[BaseAdapter]] = {
84
+ TargetType.BBOX_ULTIM: BboxUltimAdapter,
85
+ TargetType.OPENWRT: OpenWrtAdapter,
86
+ TargetType.QNAP_QHORA: QnapQhoraAdapter,
87
+ TargetType.MY_ROUTER: MyRouterAdapter, # ← add this
88
+ }
89
+ ```
90
+
91
+ No other CLI changes are needed. All commands work generically through `BaseAdapter`:
92
+ - `apply`, `dump`, `diff` — available once the 4 abstract methods are implemented
93
+ - `plan` — compares local config against `dump()` output; free for any adapter
94
+ - `status`, `devices`, `wifi` — available if the optional methods are overridden
95
+
96
+ Verify the full command list in `cli.py` (`@cli.command(...)`) in case new commands were added since this prompt was written.
97
+
98
+ ### 4. `config/configuration.yaml` — Add a target example
99
+
100
+ ```yaml
101
+ targets:
102
+ myrouter:
103
+ type: my_router
104
+ host: !secret myrouter_host
105
+ password: !secret myrouter_password
106
+ ```
107
+
108
+ Add the corresponding secrets to `config/secrets.yaml`.
109
+
110
+ ### 5. `tests/test_my_router.py` — Tests (all I/O mocked)
111
+
112
+ ```python
113
+ """Tests for MyRouterAdapter — all network calls mocked."""
114
+ from __future__ import annotations
115
+ from unittest.mock import MagicMock, patch
116
+ import pytest
117
+ from routerless.adapters.my_router import MyRouterAdapter
118
+ from routerless.models.config import DHCPConfig, NATConfig, StaticLease, TargetConfig, TargetType
119
+
120
+ TARGET = TargetConfig(type=TargetType.MY_ROUTER, host="192.168.1.1", password="secret")
121
+
122
+ def _adapter() -> MyRouterAdapter:
123
+ return MyRouterAdapter(TARGET)
124
+
125
+ # For REST adapters — mock httpx:
126
+ # p_make, p_login, p_logout = _mock_http(adapter, mock_client)
127
+ # with p_make, p_login, p_logout:
128
+ # adapter.apply_dhcp(config)
129
+
130
+ # For SSH adapters — mock paramiko:
131
+ # with patch.object(adapter, "_ssh", return_value=_ssh_ctx(mock_client)):
132
+ # adapter.apply_dhcp(config)
133
+ ```
134
+
135
+ ---
136
+
137
+ ## AGENTS.md — Update adapter notes
138
+
139
+ Add a section to `AGENTS.md` under the appropriate heading describing:
140
+ - Auth method and base URL
141
+ - Confirmed endpoints
142
+ - Any quirks (rate limits, SSL, redirect behaviour)
143
+
144
+ ---
145
+
146
+ ## Validation
147
+
148
+ ```bash
149
+ pytest # must stay green
150
+ routerless validate config/configuration.yaml # config parses
151
+ routerless status --target myrouter config/configuration.yaml # live test
152
+ ```
@@ -0,0 +1,148 @@
1
+ ---
2
+ mode: agent
3
+ description: "Add a new CLI command, option, or feature and implement it on all configured adapters"
4
+ ---
5
+
6
+ # Add a New Feature / CLI Command
7
+
8
+ Use this prompt to add a new capability to routerless — a new command, a new option on an existing command, or a new section type — and wire it up across all adapters.
9
+
10
+ ## What you need to provide
11
+
12
+ - **Feature description**: what the command does (e.g. "reboot router", "show bandwidth stats", "backup config")
13
+ - **CLI shape**: command name, options, arguments (e.g. `routerless reboot --target bbox`)
14
+ - **Scope**: which adapters should implement it (all / bbox only / openwrt only)
15
+
16
+ ---
17
+
18
+ ## Architecture reminder
19
+
20
+ ```
21
+ CLI command (cli.py)
22
+ └── adapter.new_method() ← defined in BaseAdapter (raises NotImplementedError)
23
+ ├── BboxUltimAdapter ← implements via HTTPS REST
24
+ ├── OpenWrtAdapter ← implements via SSH + UCI / shell commands
25
+ └── QnapQhoraAdapter ← delegates to self._openwrt.new_method()
26
+ ```
27
+
28
+ If the feature returns structured data, put its dataclass in `routerless/models/status.py`.
29
+
30
+ ---
31
+
32
+ ## Step-by-step
33
+
34
+ ### 1. Define the data model (if needed)
35
+
36
+ In `routerless/models/status.py`, add a dataclass for the return value:
37
+
38
+ ```python
39
+ @dataclass
40
+ class MyFeatureResult:
41
+ field_a: str
42
+ field_b: int
43
+ optional_c: str = ""
44
+ ```
45
+
46
+ ### 2. Add the method to `BaseAdapter`
47
+
48
+ In `routerless/adapters/base.py`, add a concrete method with a clear `NotImplementedError`:
49
+
50
+ ```python
51
+ def my_feature(self, param: str) -> MyFeatureResult:
52
+ raise NotImplementedError(
53
+ f"{type(self).__name__} does not implement my_feature()."
54
+ )
55
+ ```
56
+
57
+ Import `MyFeatureResult` at the top of `base.py` alongside `AdapterStatus` etc.
58
+
59
+ ### 3. Implement in each adapter
60
+
61
+ **BboxUltimAdapter** (`bbox_ultim.py`) — REST pattern:
62
+ ```python
63
+ def my_feature(self, param: str) -> MyFeatureResult:
64
+ with self._make_client() as client:
65
+ self._login(client)
66
+ try:
67
+ data = self._get(client, "/some/endpoint")
68
+ finally:
69
+ self._logout(client)
70
+ # parse data → return MyFeatureResult(...)
71
+ ```
72
+
73
+ **OpenWrtAdapter** (`openwrt.py`) — SSH pattern:
74
+ ```python
75
+ def my_feature(self, param: str) -> MyFeatureResult:
76
+ with self._ssh() as client:
77
+ out = self._run(client, "some-command")
78
+ # parse out → return MyFeatureResult(...)
79
+ ```
80
+
81
+ **QnapQhoraAdapter** (`qnap_qhora.py`) — delegate:
82
+ ```python
83
+ def my_feature(self, param: str) -> MyFeatureResult:
84
+ self._assert_uci_available()
85
+ return self._openwrt.my_feature(param)
86
+ ```
87
+
88
+ ### 4. Add the CLI command in `cli.py`
89
+
90
+ ```python
91
+ @cli.command("my-feature")
92
+ @click.argument("config", default="configuration.yaml", type=click.Path(exists=True))
93
+ @click.option("--target", "-t", required=True, help="Target name")
94
+ @click.option("--param", default="", help="Description of param")
95
+ def cmd_my_feature(config: str, target: str, param: str) -> None:
96
+ """One-line description shown in --help."""
97
+ cfg = _load(config)
98
+ adapter = _get_adapter(cfg, target)
99
+ try:
100
+ result = adapter.my_feature(param)
101
+ except NotImplementedError as exc:
102
+ raise click.ClickException(str(exc)) from exc
103
+ click.echo(f"Result: {result.field_a} ({result.field_b})")
104
+ ```
105
+
106
+ If it belongs in a group (e.g. `wifi on/off`), use `@cli.group` + `@grp.command`.
107
+
108
+ ### 5. Write tests
109
+
110
+ **For Bbox** (`tests/test_bbox.py`) — mock HTTP:
111
+ ```python
112
+ class TestMyFeature:
113
+ def test_returns_result(self) -> None:
114
+ adapter = _adapter()
115
+ mock_client = MagicMock()
116
+ mock_client.get.return_value = _bbox_resp([{"section": {"key": "value"}}])
117
+ p_make, p_login, p_logout = _mock_http(adapter, mock_client)
118
+ with p_make, p_login, p_logout:
119
+ result = adapter.my_feature("param")
120
+ assert result.field_a == "value"
121
+ ```
122
+
123
+ **For OpenWrt** (`tests/test_openwrt.py`) — mock SSH:
124
+ ```python
125
+ class TestMyFeature:
126
+ def test_returns_result(self) -> None:
127
+ adapter = _make_adapter()
128
+ mock_client = _mock_ssh_client({"some-command": b"output"})
129
+ with patch.object(adapter, "_ssh", return_value=_ssh_ctx(mock_client)):
130
+ result = adapter.my_feature("param")
131
+ assert result.field_b == 42
132
+ ```
133
+
134
+ ### 6. Validate
135
+
136
+ ```bash
137
+ pytest # all tests green
138
+ routerless my-feature --target bbox config/configuration.yaml # live smoke test
139
+ routerless my-feature --target openwrt config/configuration.yaml
140
+ ```
141
+
142
+ ---
143
+
144
+ ## Notes
145
+
146
+ - Features that only one adapter supports: implement in that adapter, let `BaseAdapter.my_feature()` raise `NotImplementedError` — the CLI already handles it gracefully.
147
+ - New **section types** (beyond dhcp/nat/firewall): also add the Pydantic model to `routerless/models/config.py`, add the field to `NetworkConfig`, and add `apply_<section>` to `BaseAdapter` as an abstract method.
148
+ - Keep `AGENTS.md` updated with any new confirmed endpoints or adapter-specific behaviour.
@@ -0,0 +1,128 @@
1
+ ---
2
+ mode: agent
3
+ description: "Capture Bbox XHR endpoints via mitmproxy to implement unknown API features (firewall, etc.)"
4
+ ---
5
+
6
+ # Discover Bbox API Endpoints via mitmproxy
7
+
8
+ This prompt guides you through setting up a local HTTPS proxy to intercept Bbox web-interface XHR traffic and identify undocumented REST endpoints.
9
+
10
+ ## Prerequisites
11
+
12
+ Install mitmproxy in the project virtualenv:
13
+
14
+ ```bash
15
+ pip install mitmproxy
16
+ ```
17
+
18
+ ## Step 1 — Start the intercepting proxy
19
+
20
+ Run this script to start mitmproxy and log all Bbox API calls:
21
+
22
+ ```python
23
+ # scripts/bbox_intercept.py
24
+ """mitmproxy addon: log all Bbox API requests to bbox_captured.jsonl"""
25
+ import json
26
+ from pathlib import Path
27
+ from mitmproxy import http
28
+
29
+ LOG_FILE = Path("bbox_captured.jsonl")
30
+ BBOX_HOST = "mabbox.bytel.fr"
31
+
32
+
33
+ class BboxCapture:
34
+ def request(self, flow: http.HTTPFlow) -> None:
35
+ if BBOX_HOST not in flow.request.pretty_host:
36
+ return
37
+ if "/api/v1/" not in flow.request.path:
38
+ return
39
+ entry = {
40
+ "method": flow.request.method,
41
+ "path": flow.request.path,
42
+ "query": dict(flow.request.query),
43
+ "content_type": flow.request.headers.get("content-type", ""),
44
+ "body": flow.request.text,
45
+ }
46
+ with LOG_FILE.open("a", encoding="utf-8") as f:
47
+ f.write(json.dumps(entry) + "\n")
48
+ print(f"[bbox] {entry['method']} {entry['path']} body={entry['body'][:120]}")
49
+
50
+ def response(self, flow: http.HTTPFlow) -> None:
51
+ if BBOX_HOST not in flow.request.pretty_host:
52
+ return
53
+ if "/api/v1/" not in flow.request.path:
54
+ return
55
+ entry = {
56
+ "path": flow.request.path,
57
+ "status": flow.response.status_code,
58
+ "response_body": flow.response.text[:500],
59
+ }
60
+ with LOG_FILE.open("a", encoding="utf-8") as f:
61
+ f.write(json.dumps(entry) + "\n")
62
+
63
+
64
+ addons = [BboxCapture()]
65
+ ```
66
+
67
+ Launch it:
68
+
69
+ ```bash
70
+ mitmdump -s scripts/bbox_intercept.py --listen-port 8080 --ssl-insecure
71
+ ```
72
+
73
+ ## Step 2 — Route browser traffic through the proxy
74
+
75
+ **Firefox / Chrome:** Set HTTP proxy to `127.0.0.1:8080`, then visit `http://mitm.it` and install the mitmproxy CA certificate.
76
+
77
+ **Or use curl for targeted probing:**
78
+
79
+ ```bash
80
+ # All requests via the proxy, trusting mitmproxy CA
81
+ export https_proxy=http://127.0.0.1:8080
82
+ curl -k https://mabbox.bytel.fr/api/v1/firewall/rules
83
+ ```
84
+
85
+ ## Step 3 — Trigger the feature in the Bbox UI
86
+
87
+ 1. Open `http://mabbox.bytel.fr` in the proxied browser
88
+ 2. Log in, navigate to the relevant section (e.g. Firewall → Rules)
89
+ 3. Add, modify, or delete a rule to trigger POST/PUT/DELETE requests
90
+ 4. Watch the mitmproxy terminal output and `bbox_captured.jsonl`
91
+
92
+ ## Step 4 — Analyse captured requests
93
+
94
+ ```python
95
+ # scripts/analyse_capture.py
96
+ import json
97
+ from pathlib import Path
98
+
99
+ for line in Path("bbox_captured.jsonl").read_text().splitlines():
100
+ e = json.loads(line)
101
+ if "method" in e and e["method"] in ("POST", "PUT", "DELETE", "PATCH"):
102
+ print(f"{e['method']:6} {e['path']}")
103
+ if e["body"]:
104
+ print(f" body: {e['body'][:200]}")
105
+ ```
106
+
107
+ ## Step 5 — Implement the discovered endpoint
108
+
109
+ Once you have confirmed the endpoint, implement it in `routerless/adapters/bbox_ultim.py`:
110
+
111
+ 1. Add a private `_list_<feature>()`, `_create_<feature>()`, `_delete_<feature>()` method
112
+ 2. Implement `apply_<feature>(config)` following the same declarative-sync pattern as `apply_dhcp()`
113
+ 3. Remove the `NotImplementedError` from the existing stub
114
+ 4. Add the endpoint details to the `AGENTS.md` confirmed endpoints list
115
+ 5. Add tests in `tests/test_bbox.py` using `_mock_http` + `_bbox_resp`
116
+
117
+ ## Current stubs needing discovery
118
+
119
+ | Method | Status | Notes |
120
+ |--------|--------|-------|
121
+ | `apply_firewall()` | `NotImplementedError` | Guessed: `GET/POST/DELETE /firewall/rules` |
122
+
123
+ ## Safety
124
+
125
+ - **Never use this proxy on a shared/production network** — it is a MITM proxy
126
+ - The script only logs requests to `mabbox.bytel.fr` to minimise exposure
127
+ - Captured files may contain session cookies — delete `bbox_captured.jsonl` after analysis
128
+ - The Bbox rate-limits login: 3 failures → 300–1200 s lockout; do not probe `/login` repeatedly
@@ -0,0 +1,41 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ concurrency:
13
+ group: ci-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ test:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+
22
+ - uses: actions/setup-python@v6
23
+ with:
24
+ python-version: "3.13"
25
+ cache: pip
26
+ cache-dependency-path: pyproject.toml
27
+
28
+ - name: Install dependencies
29
+ run: pip install -e ".[dev]"
30
+
31
+ - name: Lint
32
+ run: ruff check .
33
+
34
+ - name: Run tests
35
+ run: pytest --tb=short -q
36
+
37
+ - name: Audit dependencies
38
+ # Ignore CVEs in the runner's bootstrap pip (not part of our shipped
39
+ # dependencies). Skip editable installs so the unpublished routerless
40
+ # package isn't audited.
41
+ run: pip-audit --skip-editable --ignore-vuln CVE-2026-3219 --ignore-vuln CVE-2026-6357
@@ -0,0 +1,44 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ id-token: write
9
+ contents: write
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ environment: pypi
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+
18
+ - uses: actions/setup-python@v6
19
+ with:
20
+ python-version: "3.13"
21
+
22
+ - name: Verify tag matches package version
23
+ run: |
24
+ TAG=${GITHUB_REF#refs/tags/v}
25
+ PKG=$(python -c "exec(open('routerless/version.py').read()); print(__version__)")
26
+ if [ "$TAG" != "$PKG" ]; then
27
+ echo "::error::Tag v$TAG does not match routerless/version.py version $PKG"
28
+ exit 1
29
+ fi
30
+
31
+ - name: Install build tools
32
+ run: pip install build
33
+
34
+ - name: Build package
35
+ run: python -m build
36
+
37
+ - name: Publish to PyPI
38
+ uses: pypa/gh-action-pypi-publish@release/v1
39
+
40
+ - name: Create GitHub Release
41
+ uses: softprops/action-gh-release@v3
42
+ with:
43
+ generate_release_notes: true
44
+ files: dist/*
@@ -0,0 +1,12 @@
1
+ secrets.yaml
2
+ .env
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ dist/
9
+ *.egg-info/
10
+ .venv/
11
+ .my-network
12
+ _patch_tests.py
@@ -0,0 +1,120 @@
1
+ # Routerless — Agent Instructions
2
+
3
+ Router-agnostic network configuration manager. Declarative YAML config → applied to Bbox Ultim, OpenWrt, and QNAP Qhora routers via adapter plugins.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # Setup (Python 3.13+ required)
9
+ pip install -e ".[dev]"
10
+
11
+ # Run all tests (must stay green before and after any change)
12
+ pytest
13
+
14
+ # Run CLI
15
+ routerless validate config/configuration.yaml
16
+ routerless apply --target bbox --section dhcp config/configuration.yaml
17
+ routerless status --target bbox config/configuration.yaml
18
+ ```
19
+
20
+ ## Project Layout
21
+
22
+ ```
23
+ routerless/
24
+ cli.py # Click CLI: validate, apply, dump, diff, status, devices, wifi
25
+ yaml_loader.py # Custom YAML loader: !include, !secret, !include_dir_*
26
+ models/config.py # Pydantic v2 models: NetworkConfig, DHCPConfig, NATConfig, FirewallConfig
27
+ adapters/
28
+ base.py # BaseAdapter ABC — defines the contract
29
+ bbox_ultim.py # Bbox Ultim via HTTPS REST (cookie auth + btoken CSRF)
30
+ openwrt.py # OpenWrt via SSH + UCI commands
31
+ qnap_qhora.py # QNAP Qhora 301W — delegates to OpenWrtAdapter
32
+ config/
33
+ configuration.yaml # Main config (targets + !include sections)
34
+ secrets.yaml # NOT committed — copy from secrets.yaml.example
35
+ tests/ # pytest, all HTTP mocked via unittest.mock.patch
36
+ ```
37
+
38
+ ## Architecture
39
+
40
+ ```
41
+ CLI → load_config (yaml_loader) → parse_config (Pydantic) → NetworkConfig
42
+ → _get_adapter(cfg, target_name) → BaseAdapter subclass
43
+ → adapter.apply_dhcp / apply_nat / apply_firewall / dump()
44
+ ```
45
+
46
+ Adapter registry is in `cli.py → _ADAPTER_MAP`. Adding a new adapter requires:
47
+ 1. New `TargetType` enum value in `models/config.py`
48
+ 2. New class in `adapters/` inheriting `BaseAdapter` (implement 4 abstract methods)
49
+ 3. Register in `_ADAPTER_MAP` in `cli.py`
50
+ 4. Tests mocking device I/O
51
+
52
+ ## Config System (YAML Custom Tags)
53
+
54
+ | Tag | Behaviour |
55
+ |-----|-----------|
56
+ | `!include <file>` | Inline another YAML file (relative to current file) |
57
+ | `!include_dir_merge_list <dir>` | Merge all `*.yaml` in dir as a list — each file must be a list |
58
+ | `!include_dir_named <dir>` | Dict keyed by filename stem |
59
+ | `!secret <key>` | Resolve from `secrets.yaml`, searching upward, stopping at config root |
60
+
61
+ `!secret` always resolves relative to the config root directory (the directory of `configuration.yaml`), even when used inside an included sub-file.
62
+
63
+ ## Bbox Ultim Adapter — Critical Notes
64
+
65
+ - **Auth:** Cookie-based. `POST /login` with `password` + `remember=1` and `Referer`/`Origin` headers.
66
+ After login, `GET /device/token` returns a CSRF **btoken** stored as `self._btoken`.
67
+ Every mutating request appends `?btoken=<token>` to the URL.
68
+ - **Base URL:** `https://mabbox.bytel.fr/api/v1` — the router redirects its local HTTP to this cloud relay.
69
+ - **Endpoints confirmed (reverse-engineered):**
70
+ - `GET/POST/DELETE /dhcp/clients` — static DHCP reservations
71
+ - `PUT /dhcp/clients/<id>` — update existing reservation in-place (avoids IP conflict vs delete+create)
72
+ - `PUT /dhcp` — update dynamic pool range; fields: `dhcp.minaddress`, `dhcp.maxaddress`, `dhcp.leasetime`
73
+ - `GET/POST/DELETE /nat/rules` — port-forward rules
74
+ - `GET /firewall/rules` — firewall rules (**GET confirmed**; POST/DELETE not yet captured)
75
+ - `GET /wan/ip`, `/lan/ip`, `/device`, `/wireless`, `/voip`, `/hosts` — read-only status
76
+ - `PUT /wireless` with `radio.enable=1|0` — WiFi on/off
77
+ - **Response shape:** `[{"<section>": {"<subsection>": {"list": [...]}}}]` — use `_extract_list(data, *keys)`.
78
+ - **Rate limit:** 3 failed logins → 300 s (or 1200 s) lockout. Don't iterate passwords.
79
+ - **Firewall:** `GET /firewall/rules` is confirmed working (returns live rules with `description`, `direction`, `src`, `dest`, `action` fields). `POST`/`DELETE` endpoints not yet captured — use mitmproxy to discover (see `.github/prompts/discover-bbox-interface.prompt.md`). `apply_firewall()` raises `NotImplementedError` until then.
80
+ - **Protocol mapping:** `Protocol.BOTH` → `"all"` in NAT form body (not `"tcpudp"` — confirmed from GET response).
81
+ - **DHCP hostname vs name:**
82
+ - `StaticLease.name` — friendly label (spaces allowed). Sent as `device` in POST/PUT (write-only, **not returned by GET**).
83
+ - `StaticLease.hostname` — DNS-safe identifier (no spaces). Sent as `hostname` in POST/PUT and **returned by GET**.
84
+ - `apply_dhcp` compares `lease.hostname or lease.name` against `existing["hostname"]` to decide create/update/skip.
85
+ - `_plan_dhcp` compares `lease.hostname or lease.name` against `d.hostname or d.name`; reports `hostname: 'old' → 'new'`.
86
+ - A `hostname` with spaces causes Bbox to return 400 "Invalid parameter" — always use `lease.hostname or lease.name`.
87
+
88
+ ## OpenWrt / QNAP Qhora Adapter
89
+
90
+ - Communicates over **SSH + UCI commands** via `paramiko`.
91
+ - Host key policy: `RejectPolicy` (rejects unknown hosts).
92
+ - Idempotent: reads existing state before writing; adds/updates only changed entries.
93
+ - Qhora default SSH port: **22200**. SSH access requires WPS button hold (12 s).
94
+
95
+ ## Testing Conventions
96
+
97
+ All tests in `tests/`. HTTP is mocked — never make real network calls in tests.
98
+
99
+ ```python
100
+ # Helpers defined in tests/test_bbox.py
101
+ _adapter() # Creates BboxUltimAdapter with test target
102
+ _mock_http(adapter, mock_client) # Returns (p_make, p_login, p_logout) patches
103
+ _bbox_resp(data, status=200) # MagicMock response with .json() + .raise_for_status()
104
+
105
+ # Pattern
106
+ mock_client = MagicMock()
107
+ mock_client.get.return_value = _bbox_resp([{"dhcp": {"clients": {"list": [], "number": 0}}}])
108
+ p_make, p_login, p_logout = _mock_http(adapter, mock_client)
109
+ with p_make, p_login, p_logout:
110
+ adapter.apply_dhcp(config)
111
+ mock_client.post.assert_called_once()
112
+ ```
113
+
114
+ ## Key Conventions
115
+
116
+ - **Python 3.13+** — use modern syntax: `str | None`, `list[...]`, `dict[...]`. No `Optional`, `List`, `Dict`.
117
+ - **Pydantic v2** — `model_dump(exclude_none=True, exclude_unset=True)` for serialization.
118
+ - **MAC addresses** normalized to uppercase colon-separated in Pydantic validators.
119
+ - **Secrets** never logged or printed. `secrets.yaml` is gitignored.
120
+ - Apply only what is requested — don't add docstrings, comments, or error handling for impossible scenarios beyond what exists.