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.
- routerless-0.1.0/.github/dependabot.yml +15 -0
- routerless-0.1.0/.github/prompts/add-adapter.prompt.md +152 -0
- routerless-0.1.0/.github/prompts/add-feature.prompt.md +148 -0
- routerless-0.1.0/.github/prompts/discover-bbox-interface.prompt.md +128 -0
- routerless-0.1.0/.github/workflows/ci.yml +41 -0
- routerless-0.1.0/.github/workflows/release.yml +44 -0
- routerless-0.1.0/.gitignore +12 -0
- routerless-0.1.0/AGENTS.md +120 -0
- routerless-0.1.0/CODE_OF_CONDUCT.md +41 -0
- routerless-0.1.0/CONTRIBUTING.md +163 -0
- routerless-0.1.0/LICENSE +21 -0
- routerless-0.1.0/PKG-INFO +499 -0
- routerless-0.1.0/README.md +468 -0
- routerless-0.1.0/SECURITY.md +30 -0
- routerless-0.1.0/config/configuration.yaml +31 -0
- routerless-0.1.0/config/dhcp.yaml +13 -0
- routerless-0.1.0/config/firewall.yaml +13 -0
- routerless-0.1.0/config/nat.yaml +12 -0
- routerless-0.1.0/config/secrets.yaml.example +11 -0
- routerless-0.1.0/config/static_leases/domotique.yaml +14 -0
- routerless-0.1.0/config/static_leases/serveurs.yaml +9 -0
- routerless-0.1.0/pyproject.toml +63 -0
- routerless-0.1.0/routerless/__init__.py +3 -0
- routerless-0.1.0/routerless/adapters/__init__.py +11 -0
- routerless-0.1.0/routerless/adapters/base.py +90 -0
- routerless-0.1.0/routerless/adapters/bbox_ultim.py +665 -0
- routerless-0.1.0/routerless/adapters/openwrt.py +567 -0
- routerless-0.1.0/routerless/adapters/qnap_qhora.py +88 -0
- routerless-0.1.0/routerless/cli.py +977 -0
- routerless-0.1.0/routerless/models/__init__.py +23 -0
- routerless-0.1.0/routerless/models/config.py +180 -0
- routerless-0.1.0/routerless/models/status.py +42 -0
- routerless-0.1.0/routerless/version.py +1 -0
- routerless-0.1.0/routerless/yaml_loader.py +159 -0
- routerless-0.1.0/tests/__init__.py +0 -0
- routerless-0.1.0/tests/test_bbox.py +644 -0
- routerless-0.1.0/tests/test_import.py +304 -0
- routerless-0.1.0/tests/test_init.py +103 -0
- routerless-0.1.0/tests/test_models.py +123 -0
- routerless-0.1.0/tests/test_openwrt.py +498 -0
- routerless-0.1.0/tests/test_plan.py +293 -0
- 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,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.
|