Skip to content

Releases: GlassOnTin/openwrt-mcp

v0.5.0 — issue a WireGuard client with a scannable QR

Choose a tag to compare

@GlassOnTin GlassOnTin released this 12 Aug 11:21

Issue a WireGuard client and show a QR to scan it, plus uci_get to read configuration without a root shell.

wg_new_client

Adding a VPN client by hand is three fiddly steps — generate a keypair, find a free tunnel address, write a peer section the vendor UI still recognises — and then the config has to reach a phone. Transcription is what actually goes wrong, so the tool returns a UTF-8 QR next to the text config.

Three deliberate choices:

  • Hot-adds the peer with wg set rather than restarting the interface. A restart drops every established session, which is a poor trade for adding one client. If the running interface cannot be identified the peer is still committed, and the output says so rather than implying nothing happened.
  • Prefers the router's dynamic-DNS name over its WAN address for Endpoint. A dynamic address baked into a client config stops working at the next reconnect.
  • A full subnet is an error, never a recycled address. Two devices sharing one tunnel address breaks whichever connects second, and it fails silently.

The tool returns a new private key. It never reaches audit.jsonl — which records arguments and a summary, but not tool output — and wg pubkey reads the key on stdin so it is never in argv where /proc would expose it. It does land in the caller's context, so issue one client per device and consider gating the tool behind mfa_tools.

uci_get

Read current UCI configuration through the same policy gate as every other tool. Closes the read gap: an agent can inspect state before an uci_apply without being granted exec (a root shell) or a broad ubus uci.* scope just to look. Its policy scope mirrors uci_apply, so one grant reasons about reads and writes symmetrically.

Verification

Tested end-to-end on a GL-BE14000 (firmware 4.9.0): tools/list exposes the tool, a real call created a peer, hot-added it without disturbing the two existing peers, and rendered a 73×36 QR. Installing on a GL-BE10000 surfaced the error-message fix in this release — its WireGuard server section exists with an address and port but no keypair, and the old message sent you checking all three fields.

72 tests pass. The assertions pinning real defects were mutation-checked individually: address reuse, a content-independent QR, a policy scope containing a glob, and the error message naming fields that are present.

Install

opkg install openwrt-mcp_0.5.0_aarch64_cortex-a53.ipk

v0.4.0 — optional TOTP second factor

Choose a tag to compare

@GlassOnTin GlassOnTin released this 07 Aug 07:28

An optional TOTP second factor for the tools that can change or read anything.

Opt-in per policy — absent means off, so upgrading changes nothing until you configure it.

ssh root@192.168.8.1 'cat > /tmp/openwrt-mcp.ipk' < openwrt-mcp_0.4.0_aarch64_cortex-a53.ipk
ssh root@192.168.8.1 'opkg install /tmp/openwrt-mcp.ipk'

Upgrading keeps your policies and pairings — /etc/config/openwrt-mcp is a conffile.

Why

A broad grant plus a stolen bearer token is root on your router. The token is something your
workstation has. A TOTP code is something you have, somewhere else.

ssh root@192.168.8.1 'openwrt-mcp mfa enrol claude-code'   # QR-scannable otpauth:// URI
config policy
	option client 'claude-code'
	...
	list   mfa_tools  'exec'
	list   mfa_tools  'uci_apply'     # or '*' for every tool the policy grants
	option mfa_window '15m'

One code, not one per call

An agent works in bursts, and a control that demands six digits per call gets switched off —
a control too annoying to leave on protects nothing. So one code opens a time-boxed window
and the gated tools work normally until it lapses, the way sudo does:

exec {"argv":["uptime"]}
  -> denied: exec requires a second factor for "claude-code"
     call mfa_unlock with a current 6-digit code from your authenticator;
     it stays unlocked for 15m0s
mfa_unlock {"code":"552575"}   -> Unlocked until 2026-08-07T07:39:48Z (15m0s).
exec {"argv":["uptime"]}       -> 08:24:51 up 12:57, load average: 2.39 …
mfa_unlock {"code":"552575"}   -> code already used

That transcript is from a real router, not an illustration.

Decisions worth knowing

  • mfa_unlock is itself ungated, or satisfying the factor would deadlock behind the
    factor. Safe because it grants nothing without a valid current code.
  • The check runs after authorisation, so an unauthorised caller learns nothing about
    which tools are gated.
  • Unenrolled and wrong-code fail identically. Distinguishing them tells an attacker which
    clients are worth attacking.
  • Codes are single-use. Without that a code is good for its whole ~90-second acceptance
    span, so one shoulder-surf is reusable.
  • Unlocks live in memory only, so a daemon restart re-locks everything — "we lost track"
    should resolve toward asking again.
  • mfa_tools naming an ungranted tool is rejected at load. A typo would otherwise sit
    there looking like protection.
  • Run mfa enrol yourself. The secret is stored recoverably at /etc/openwrt-mcp/mfa
    (0600) because TOTP is symmetric — unlike bearer tokens, which are kept only as digests. A
    secret that passes through anyone else, or through a chat window, is not a second factor.

Verified

End to end on a Flint 4: exec denied, a real code unlocked for 15 minutes, exec worked,
the same code was refused as replayed, and ubus_call — not listed in mfa_tools — was
unaffected throughout.

73 tests. TOTP is checked against RFC 6238's own test vectors, because a hand-rolled
implementation that is subtly wrong still agrees with itself: enrolment and unlock would
match each other while no real authenticator app could ever produce an accepted code.

Eight mutations, all killed — gate-always-allows, accepts-any-code, replay-removed,
window-never-expires, enrolment-keeps-old-unlock, leaks-enrolment-state, typo-accepted, and
0600→0644.

v0.3.2 — the page was hidden in AP mode

Choose a tag to compare

@GlassOnTin GlassOnTin released this 07 Aug 07:01

Fixes a bug that hid the status page on any router not in "router" net mode. Upgrade if
you run GL.iNet firmware and the page never appeared under Applications.

ssh root@192.168.8.1 'cat > /tmp/openwrt-mcp.ipk' < openwrt-mcp_0.3.2_aarch64_cortex-a53.ipk
ssh root@192.168.8.1 'opkg install /tmp/openwrt-mcp.ipk'

Upgrading keeps your policies and pairings — /etc/config/openwrt-mcp is a conffile.

The bug

menu.d/openwrt-mcp.json declared show_mode: ["router"]. I copied that from AdGuard Home
without checking whether it applied to this package. It does not: openwrt-mcp exposes ubus
and uci, which exist in every net mode, so there is no mode in which the daemon runs but
its status page should be invisible.

The key is now omitted entirely, matching plugins, and the page appears in every mode.

How it surfaced

Installing on a second device — a Slate 7 Pro (GL-BE10000) running firmware 4.8.4 in
ap mode. Everything worked except the part you can see: the package installed cleanly,
the RPC answered, ui.get_menu_list returned the entry, the view was served over HTTP — and
the nav had no MCP Server item.

The tell was that AdGuard Home, Tailscale and ZeroTier were missing from that nav too, all
declaring show_mode: ["router"], while the two entries that did show were plugins (which
omits the key) and dynamicdns (which lists every mode). The SPA was faithfully honouring a
declaration that was simply wrong.

Worth noting for anyone shipping a menu.d entry: a wrong show_mode fails silently and
only on hardware in the mode you did not test. Nothing logs it.

Portability, now actually tested

The v0.3.1 .ipk installed unchanged on the Slate 7 Pro — different SoC (mt7987 vs mt7988),
different firmware (4.8.4 vs 4.9.0), same aarch64_cortex-a53. The auth matrix (401/401/200),
the scope gate and its actionable refusal, response pruning (gl-clients list at 51,231
bytes), the RPC module and its access gate all behaved exactly as on the Flint 4.

Verified on hardware in both modes: the page renders in the Slate 7 Pro's panel in ap
mode and on a Flint 4 in router mode.

v0.3.1 — README screenshot (docs only)

Choose a tag to compare

@GlassOnTin GlassOnTin released this 07 Aug 06:44

Documentation only — no functional change from v0.3.0. There is no reason to upgrade
unless you want the version string to match; the binary differs in that string and nothing
else. If you are already on v0.3.0, stay there.

The README now leads with a screenshot of the MCP Server page in GL.iNet's admin panel,
captured from a real Flint 4 rather than mocked up.

The capture shows the recommended narrow grant — ubus_list, ubus_call, logread on
network.*, iwinfo.*, system.* and gl-clients.* — and its audit tail contains a
genuine refusal:

DENIED  ubus_call  dnsmasq.metrics
        denied: ubus_call is granted to "claude-code" but no policy scope
        covers "dnsmasq.metrics"

That is the point. The refusal names the uncovered scope and prints the allow line that
would cover it, so starting narrow costs almost nothing while being too loose is the mistake
you never notice. An earlier capture showed scope: *, which quietly argued against the
README's own advice.

New to the project? Start at v0.3.0
or grab the .ipk below — they are functionally the same.

v0.3.0 — status page in the router UI

Choose a tag to compare

@GlassOnTin GlassOnTin released this 07 Aug 03:33

A status page inside the router's own web UI, under Applications → MCP Server — whether
the daemon is running, which clients are paired, what each is granted, and the recent audit
tail.

Install the .ipk below; no Go toolchain, no OpenWrt SDK, nothing to build:

ssh-copy-id root@192.168.8.1                      # key auth is required, see README
ssh root@192.168.8.1 'cat > /tmp/openwrt-mcp.ipk' < openwrt-mcp_0.3.0_aarch64_cortex-a53.ipk
ssh root@192.168.8.1 'opkg install /tmp/openwrt-mcp.ipk'

Upgrading from v0.2.0 keeps your policies and pairings — /etc/config/openwrt-mcp is a
conffile.

Built for aarch64_cortex-a53, which covers the GL.iNet Flint range. Verified on a Flint 4
(GL-BE14000)
, OpenWrt 21.02-SNAPSHOT / GL firmware 4.9.0.

The page is read-only, on purpose

pair, allow and unpair stay command-line only, so nothing reachable over the network
can widen a grant — the same property that keeps them out of the MCP tool list. The page
shows and explains grants; it never issues them.

Status is a CLI subcommand (openwrt-mcp status --json) rather than a second HTTP endpoint
for the same reason. The daemon's only listener is loopback and reachability is explicitly
not treated as identity, so another HTTP surface would mean either exposing policy and audit
data to every process on the router, or keeping a bearer token on the router for the UI to
present. oui-httpd already runs as root and can read the state directory, so a CLI read
grants its caller nothing new.

What's in the package

Two new files integrate with GL.iNet's UI — an oui-httpd RPC module exposing
openwrt-mcp.status, and the view plus menu entry the SPA loads. On stock OpenWrt both are
inert, nothing reads them, so it stays a single package for both.

openwrt-mcp status [--json] [--audit N] is also useful on its own at a terminal.

For anyone building a GL.iNet view

This took the longest to work out and is the least documented, so: no GL.iNet SDK and no
bundler are needed.
The SPA's router does

axios.get(`/views/gl-sdk4-ui-${view}.common.js`)
     .then(res => { const component = eval(res.data)
                    to.matched[index].components.default = component })

— it fetches the file as text, evals it, and uses whatever the eval evaluates to as the
component. The shipped views are minified webpack commonjs2 bundles beginning
module.exports=…, which works only because a direct eval inherits the enclosing webpack
wrapper's scope where module is a real binding, and an assignment expression evaluates to
the assigned value. A plain IIFE returning a Vue 2 options object satisfies the loader
directly. Vue is 2.6.12, so render functions avoid needing a template compiler at eval time.

Menu entries are declarative JSON in /usr/share/oui/menu.d/, served to the SPA by
ui.get_menu_list. RPC modules are Lua files in /usr/lib/oui-httpd/rpc/ returning a table
of functions — and note that the dispatcher does not apply ACLs on a module's behalf:
rpc.access is opt-in per method.

See ui/view.js, which is the whole thing, unminified.

Also

  • running asks /health and requires the daemon to identify itself rather than just
    dialling the port. 8730 is an ordinary port for something else to hold — an ssh -L
    answers it happily — and reporting that as running would be a lie in exactly the case a
    status page exists to catch.
  • Empty and error states say what to do next rather than showing a blank table.
  • 60 tests. The status tests pin that no token or digest reaches the report, that audit rows
    stay oldest-first, and that a corrupt log line is skipped rather than fatal.

Known limitation: the .ipk targets opkg. Stock OpenWrt 24.10+ moved to apk and is
untested — GL.iNet firmware 4.x is opkg-based, so this is not a concern on the Flint range.

v0.2.0 — installable

Choose a tag to compare

@GlassOnTin GlassOnTin released this 06 Aug 22:18

First release with a downloadable package. Grab the .ipk below — no Go toolchain, no
OpenWrt SDK, nothing to build:

ssh-copy-id root@192.168.8.1                      # key auth is required, see README
ssh root@192.168.8.1 'cat > /tmp/openwrt-mcp.ipk' < openwrt-mcp_0.2.0_aarch64_cortex-a53.ipk
ssh root@192.168.8.1 'opkg install /tmp/openwrt-mcp.ipk'

Built for aarch64_cortex-a53, which covers the GL.iNet Flint range. Verified on a Flint 4
(GL-BE14000)
, OpenWrt 21.02-SNAPSHOT / GL firmware 4.9.0.

It installs on someone else's machine now

v0.1.0 could not be installed by anyone but the author, and the repo did not admit it:

  • Makefile hardcoded GO ?= /usr/local/go/bin/go. Go from apt, brew or asdf failed with
    make: *** Error 127 on the first command.
  • The whole files/ tree — the procd init script and the default UCI config — had never
    been committed
    . An unanchored .gitignore pattern swallowed it, so a fresh clone could
    not run make install at all.
  • SSH key auth was assumed but never documented, while make install pipes over
    non-interactive ssh. A factory router has no authorized_keys, so the documented command
    could not work on the hardware it documented.
  • $TOK appeared in the connect instructions and was never defined.

Packaging

  • Installs as an .ipk, so the daemon survives a firmware upgrade via
    /lib/upgrade/keep.d — previously it vanished on every flash.
  • /etc/config/openwrt-mcp is a conffile: upgrades never clobber live policies or pairings.
  • make install no longer fails with ETXTBSY on every install after the first. Writing to
    a sidecar and renaming replaces the inode atomically while the daemon keeps running.

uci_apply can add things, not just edit them

A change with type and no option now creates a section, and delete with no option
removes one. Adding a static DHCP lease, a firewall rule or an interface is one
rollback-armed call instead of dropping to a root shell:

{"changes": [
  {"config":"dhcp","section":"raspberrypi","type":"host"},
  {"config":"dhcp","section":"raspberrypi","option":"mac","value":"88:a2:9e:8a:e4:15"},
  {"config":"dhcp","section":"raspberrypi","option":"ip","value":"192.168.0.141"}]}

Section-level changes take the policy scope <config>.<section>, distinct from
<config>.<section>.<option> — a grant of dhcp.pi.* deliberately does not permit
creating dhcp.pi.

Oversized replies

ubus call gl-clients list returned 100,587 bytes on a 49-client network — 60 samples of
rx and tx history per client. Replies over 8 KB now have arrays capped at 16 elements,
pruned out of the decoded tree so the result stays valid JSON. That call is now 43,676 bytes.

Pruning only applies where it helps: a 17-element list in 196 bytes (iwinfo devices) is
left alone. The first attempt capped it anyway, discarded a real interface and made the
reply longer.

Also

  • CI runs vet, tests and a cross-build, and asserts the package contains the four files the
    router needs and declares its conffile.
  • 55 tests, mutation-checked.
  • Findings from the Flint 4 gl-* surface are documented in the README, including why
    gl_screen.* should not be granted and where the screen passcode is stored in plaintext.

Known limitation: the .ipk targets opkg. Stock OpenWrt 24.10+ moved to apk and is
untested — GL.iNet firmware 4.x is opkg-based, so this is not a concern on the Flint range.