Skip to content

Commit abb2487

Browse files
authored
segments the web interface will actually list (#12)
* feat: build segments the web interface will actually list A bridge with an address, NAT and a Wi-Fi binding gives an isolated network that carries traffic perfectly and never appears under /access-points or in the segment list. It is reported as "it works but I cannot see it", which is much harder to diagnose after the fact than before. What the web interface calls a segment is VLAN-backed. Three steps are missing from the obvious sequence: a GigabitEthernet0/VlanN subinterface, that VLAN trunked over every switch port, and the subinterface bridged in. The router then computes `iseg` itself - vlan, port, vlan-port - and that block is what the web interface reads. Nothing sets those fields by hand. The keenetic-segments skill carries the whole sequence, including the discovery reads that find free identifiers and the teardown. `create_segment` does it in one call: it allocates a free bridge, VLAN id, subnet, pool and Wi-Fi key, applies every step, and verifies against `iseg` rather than against the return value, because the router answers a wrong field name with `{}` and no error. A failure anywhere rolls the whole thing back and says what it could not undo. Two things this deliberately does not do. It never saves: a segment is gone on reboot until save_config runs, which makes it cheap to build one, look at it in the web interface and change your mind. And delete_segment leaves any routing policy alone, because policies are shared and removing one with its segment can break another. `switchport trunk vlan` is additive - it does not disturb the port's access vid - so trunking every port leaves the home network on those ports untouched. Ports are discovered rather than assumed: five is a KN-1811, not a Keenetic. The leak detector caught two things worth keeping: a DHCP range copied out of the real router into a fixture, and then the address I wrote into its own comment while explaining the exemption. * review: what the live router said that the mocks did not Built a segment on a KN-1811 running 5.1.3, looked at it, and tore it down. Three things came out of that. `switchport trunk vlan` is additive in practice and not only on paper: the ports went from access=1 trunk=[2] to access=1 trunk=[2,3] and back, so the home network and the existing guest segment on those same ports were never disturbed. That was the one step in this whole sequence with any real risk to a router someone is currently using, and it is now measured rather than assumed. Bridge0 carries an address and an empty `iseg`, because the home network is the untagged one rather than a VLAN. The test that correctly identifies every other invisible bridge called the home segment invisible. It is now reported as `home` and never as missing. A segment with no Wi-Fi does not appear under /access-points, and that is not a fault. The page lists Wi-Fi networks; the two `include WifiMaster*/AccessPoint` lines only appear when `mws wlan` binds to the bridge. The skill says so now, with the two config blocks side by side, because the first thing anyone will report is that the segment is missing from a page it was never going to be on. .gitignore now covers scratch-* as well as scratch/. Eight throwaway probes written against the live router sat next to the tracked files during this, and that is exactly where a captured address or key gets committed by accident.
1 parent 0d26827 commit abb2487

10 files changed

Lines changed: 1658 additions & 4 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ docs/superpowers/
1818
config.local.toml
1919
*.cookies
2020
scratch/
21+
# Throwaway probes written against a live router, which is where captured
22+
# addresses and keys turn up before anyone means to commit them.
23+
scratch-*

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ Prefer environment variables? `KEENETIC_HOST`, `KEENETIC_USER` and
108108
| `list_policies` | connection policies for selective routing |
109109
| `get_system_info` | model, firmware, CPU, memory, installed components |
110110
| `get_config_state` | unsaved changes, who changed what and when |
111+
| `list_segments` | every bridge, and whether the web interface lists it as a segment |
111112
| `backup_config` | download the configuration to a local file |
112113

113114
**Change**
@@ -116,6 +117,8 @@ Prefer environment variables? `KEENETIC_HOST`, `KEENETIC_USER` and
116117
|---|---|
117118
| `update_device` | rename, block or allow, assign a routing policy, schedule or priority |
118119
| `set_interface_state` | bring an interface up or down |
120+
| `create_segment` | a guest or IoT network the web interface actually lists, with Wi-Fi, DHCP and optional VPN routing |
121+
| `delete_segment` | remove a segment and everything created with it |
119122
| `save_config` | make pending changes survive a reboot |
120123

121124
**Escape hatch**
@@ -126,7 +129,7 @@ Prefer environment variables? `KEENETIC_HOST`, `KEENETIC_USER` and
126129

127130
## Skills included
128131

129-
The plugin ships three skills, so the agent knows how your router behaves rather
132+
The plugin ships four skills, so the agent knows how your router behaves rather
130133
than guessing. One plugin directory serves both Claude Code and Codex: they read
131134
different manifests but share the same skills and the same server definition.
132135

@@ -136,6 +139,10 @@ different manifests but share the same skills and the same server definition.
136139
- **keenetic-safe-changes** teaches the change workflow: what the router's
137140
fail-safe does and does not protect against, and which interfaces will cut off
138141
your own access.
142+
- **keenetic-segments** covers building an isolated network the router will
143+
admit exists. The obvious way produces a guest network that carries traffic
144+
perfectly and never appears in the web interface, because a segment is
145+
VLAN-backed and the VLAN is the part everyone leaves out.
139146
- **keenetic-troubleshoot** is an ordered diagnostic playbook for "the internet
140147
is down", "Wi-Fi is bad" and "one device cannot connect".
141148

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
---
2+
name: keenetic-segments
3+
description: Use when creating an isolated network on a Keenetic - a guest network, an IoT network, a segment routed through a VPN - explains why the obvious way produces a working network the web interface refuses to list, and the VLAN it is actually missing
4+
---
5+
6+
# Creating a segment the router will admit exists
7+
8+
## The trap
9+
10+
The obvious way to build an isolated network is a bridge with an address, NAT,
11+
and a Wi-Fi network bound to it:
12+
13+
```
14+
interface Bridge2
15+
interface Bridge2 ip address 192.168.3.1 255.255.255.0
16+
interface Bridge2 up
17+
ip nat Bridge2
18+
```
19+
20+
This works. Clients associate, get addresses, reach the internet, and stay off
21+
the home network. It is also **invisible**: nothing appears under
22+
`/access-points`, nothing appears in the segment list, and the user cannot
23+
manage from the web interface the thing they just asked you to create.
24+
25+
Reported as "it works but I can't see it", which is a much harder bug to find
26+
after the fact than before.
27+
28+
## What the web interface means by a segment
29+
30+
A segment is **VLAN-backed**. The bridge is only half of it. Three things are
31+
missing from the sequence above:
32+
33+
1. a VLAN subinterface, `GigabitEthernet0/VlanN`
34+
2. that VLAN trunked over **every** port of the switch
35+
3. the subinterface bridged into the segment
36+
37+
Do those and the router fills in the rest itself. `iseg` - the block the web
38+
interface reads - is computed, not written:
39+
40+
```json
41+
"iseg": { "vlan": "2", "port": "1,2,3,4,5", "vlan-port": "1,2,3,4,5",
42+
"free-port": "", "busy-vlan": "1" }
43+
```
44+
45+
You never set those fields. If they are empty, the VLAN is missing.
46+
47+
## Use the tool
48+
49+
`create_segment` does all of it, picks free identifiers, verifies the result
50+
against `iseg`, and rolls the whole thing back if any step fails:
51+
52+
```
53+
create_segment { "name": "iot", "ssid": "…", "psk": "…" }
54+
create_segment { "name": "vpn", "permit_interfaces": ["Wireguard1"] }
55+
create_segment { "name": "wired-lab", "subnet": 40 }
56+
```
57+
58+
`list_segments` shows what exists and, per bridge, whether `uiVisible` is true.
59+
Run it first: a bridge that is already there but not visible has the same
60+
missing VLAN, and is worth mentioning to the user.
61+
62+
Everything below is for doing it by hand through `rci_call`, which is worth
63+
reading anyway, because it is what the tool is doing.
64+
65+
## Doing it by hand
66+
67+
Commands go through `parse`, which takes a CLI line:
68+
69+
```
70+
rci_call { "method": "POST", "body": { "parse": "interface Bridge2 up" } }
71+
```
72+
73+
An array executes several in order:
74+
75+
```
76+
rci_call { "method": "POST", "body": [ { "parse": "…" }, { "parse": "…" } ] }
77+
```
78+
79+
### 1. Find what is free
80+
81+
| Read | Tells you |
82+
|---|---|
83+
| `show/rc/interface/Bridge<n>` | which bridges exist, and their subnets. 404 means free |
84+
| `show/rc/interface/GigabitEthernet0/<n>` | one switch port: `rename` is its label, `switchport.trunk` its VLANs. 404 means the switch ended |
85+
| `show/rc/ip/dhcp` | pools, keyed by name, with the ranges they hand out |
86+
| `show/rc/ip/policy` | policies, keyed by name |
87+
| `show/rc/mws/wlan` | Wi-Fi networks, keyed by `wlanN`, each with `bind.interface` |
88+
89+
Probe the numbered paths one at a time. `show/interface` gives the same answers
90+
in 32 KB.
91+
92+
Bridge0 and VLAN 1 are the home segment on every Keenetic. Start bridges at 1
93+
and VLAN ids at 2. A VLAN id is free if it is in no port's `trunk` list and is
94+
no port's `access.vid`.
95+
96+
### 2. Build it
97+
98+
```
99+
interface GigabitEthernet0/Vlan3
100+
interface GigabitEthernet0/Vlan3 up
101+
102+
interface GigabitEthernet0/0 switchport trunk vlan 3
103+
interface GigabitEthernet0/1 switchport trunk vlan 3
104+
interface GigabitEthernet0/2 switchport trunk vlan 3
105+
…one line per port…
106+
107+
interface Bridge2
108+
interface Bridge2 description iot
109+
interface Bridge2 security-level protected
110+
interface Bridge2 include GigabitEthernet0/Vlan3
111+
interface Bridge2 ip address 192.168.3.1 255.255.255.0
112+
interface Bridge2 up
113+
ip nat Bridge2
114+
```
115+
116+
`switchport trunk vlan` **adds** to the port. It does not disturb `access.vid`,
117+
so the home network on that port keeps working. Trunk every port, not the ones
118+
you expect to be used: `iseg.vlan-port` is built from them, and a partial trunk
119+
gives a partial segment.
120+
121+
`security-level protected` is what the web interface sets on its own segments.
122+
123+
### 3. DHCP, which will not go through `parse`
124+
125+
`ip dhcp pool <name>` comes back as an argument parse error. Send JSON, with the
126+
range in the same call:
127+
128+
```json
129+
{ "ip": { "dhcp": { "pool": { "_WEBADMIN_BRIDGE2": {
130+
"range": { "begin": "192.168.3.33", "end": "192.168.3.152" },
131+
"lease": 25200,
132+
"bind": { "interface": "Bridge2" },
133+
"enable": true } } } } }
134+
```
135+
136+
The `_WEBADMIN_BRIDGE<n>` name is the one the web interface uses for its own
137+
pools. Matching it keeps the segment editable there.
138+
139+
### 4. Wi-Fi, if it needs any
140+
141+
Use `mws wlan`, not the legacy per-`AccessPointN` configuration. The legacy form
142+
configures a radio without adding it to the bridge, so the Wi-Fi ends up outside
143+
the segment it is supposed to serve. `mws wlan` adds both radios to `include`
144+
itself:
145+
146+
```json
147+
{ "mws": { "wlan": { "wlan1": {
148+
"band": ["0", "1"],
149+
"bind": { "interface": "Bridge2" },
150+
"ssid": { "name": "" },
151+
"encryption": "wpa2+3",
152+
"wpa": { "psk": "" },
153+
"enable": true } } } }
154+
```
155+
156+
### 5. A policy, if it routes somewhere specific
157+
158+
```
159+
ip policy Policy1
160+
ip policy Policy1 description through-the-tunnel
161+
ip policy Policy1 permit global Wireguard1
162+
ip hotspot policy Bridge2 Policy1
163+
```
164+
165+
## Verify against `iseg`, not against the return value
166+
167+
The router answers a wrong field name with `{}` and no error. The only proof is
168+
the computed block:
169+
170+
```
171+
rci_call { "method": "GET", "path": "show/rc/interface/Bridge2" }
172+
```
173+
174+
```json
175+
"iseg": { "vlan": "3", "port": "1,2,3,4,5", "vlan-port": "1,2,3,4,5" },
176+
"include": [ { "interface": "GigabitEthernet0/Vlan3" },
177+
{ "interface": "WifiMaster0/AccessPoint1" },
178+
{ "interface": "WifiMaster1/AccessPoint1" } ]
179+
```
180+
181+
`iseg.vlan` and `iseg.vlan-port` both non-empty means the web interface will
182+
list it. Empty means you have built the invisible version. `include` should hold
183+
the VLAN and, if Wi-Fi was configured, one access point per radio.
184+
185+
Two exceptions to that test, both measured rather than assumed:
186+
187+
**Bridge0 has an empty `iseg` and is listed anyway.** The home network is the
188+
untagged one rather than a VLAN, so the check that identifies every other
189+
invisible bridge would libel this one. `list_segments` reports it as `home`.
190+
191+
**A segment with no Wi-Fi will not appear under `/access-points`.** That page is
192+
"My networks and Wi-Fi": it lists Wi-Fi networks, so a wired-only segment has
193+
nothing to show there. It still appears in the segment list. If the user says
194+
they cannot see it, ask which page they are looking at before assuming the
195+
segment is broken - the two access-point lines only enter `include` when
196+
`mws wlan` binds to the bridge:
197+
198+
```
199+
interface Bridge1 interface Bridge2
200+
description guest description wired-only
201+
include GigabitEthernet0/Vlan2 include GigabitEthernet0/Vlan3
202+
include WifiMaster1/AccessPoint1
203+
include WifiMaster0/AccessPoint1
204+
security-level protected security-level protected
205+
```
206+
207+
Both are segments. Only the first is on `/access-points`.
208+
209+
The name shown is the `description`, not the bridge.
210+
211+
## Tearing one down
212+
213+
Removing the bridge is not enough. The VLAN stays trunked over every port of the
214+
switch with nothing to belong to, and the next segment inherits the mess.
215+
216+
```
217+
no mws wlan wlan1
218+
no ip hotspot policy Bridge2
219+
no ip policy Policy1
220+
no ip dhcp pool _WEBADMIN_BRIDGE2
221+
no ip nat Bridge2
222+
no interface Bridge2
223+
interface GigabitEthernet0/0 no switchport trunk vlan 3
224+
interface GigabitEthernet0/1 no switchport trunk vlan 3
225+
…one line per port…
226+
no interface GigabitEthernet0/Vlan3
227+
```
228+
229+
Read `show/rc/mws/wlan` and `show/rc/ip/dhcp` first and match on
230+
`bind.interface` rather than assuming the names: a segment made in the web
231+
interface will not be called what this skill calls it.
232+
233+
`delete_segment` does the whole list, in order, and refuses Bridge0.
234+
235+
A policy is shared configuration. Leave it unless nothing else references it.
236+
237+
## None of this is saved
238+
239+
A segment created here disappears on reboot until `save_config` runs, which is a
240+
useful property while testing: build it, look at it in the web interface, and if
241+
it is wrong, reboot rather than unpick it.
242+
243+
Tell the user it is unsaved, let them look, and call `save_config` only once
244+
they say it is right. Read [keenetic-safe-changes](../keenetic-safe-changes/SKILL.md)
245+
before any of this.
246+
247+
## Confirmed on
248+
249+
Keenetic Ultra (KN-1811), KeeneticOS 5.1.3, five switch ports, by building a
250+
segment and tearing it down again.
251+
252+
Worth knowing from that run: `switchport trunk vlan` really is additive. The
253+
ports went from `access=1 trunk=[2]` to `access=1 trunk=[2,3]` and back, so
254+
neither the home network nor the existing guest segment on those ports noticed.
255+
The two access points appeared in `include` on their own when `mws wlan` bound
256+
to the bridge, and the teardown left no subinterface, pool or Wi-Fi entry.
257+
258+
The shape is not model-specific, but the port count is: read the ports rather
259+
than assuming five.

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { registerInterfaceTools } from './tools/interfaces.js';
1515
import { registerNetworkTools } from './tools/network.js';
1616
import { registerRawTool } from './tools/raw.js';
1717
import type { ToolContext } from './tools/registry.js';
18+
import { registerSegmentTools } from './tools/segments.js';
1819
import { registerSystemTools } from './tools/system.js';
1920
import { loadLocalEnv, resolveVersion } from './version.js';
2021

@@ -24,6 +25,7 @@ export function createServer(ctx: ToolContext): McpServer {
2425
registerDeviceTools(server, ctx);
2526
registerInterfaceTools(server, ctx);
2627
registerNetworkTools(server, ctx);
28+
registerSegmentTools(server, ctx);
2729
registerConfigTools(server, ctx);
2830
registerRawTool(server, ctx);
2931
return server;

0 commit comments

Comments
 (0)