MCP server that runs on an OpenWrt router: deny-by-default policy, audit log, rollback-armed changes
Runs on your machine from its package; the steps are below.
Not assessed yet.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally
This server runs on your machine over stdio, but its source registry didn't list a distribution package. Check the source repository for setup instructions.
An MCP server that runs on an OpenWrt router, so Claude Code (or any MCP client) can inspect and change it -- behind deny-by-default policies, an audit log, and an automatic rollback for configuration changes.
Local and private. The server makes no outbound connections of its own and sends no
telemetry; it listens on loopback and on a root-only socket, and talks to your client over an
SSH session you set up. The tools that can make the router itself reach out (net_diag,
pkg_query with refresh, pkg_change, sysupgrade check, the uci_apply probes, ubus_call,
exec) say so in their MCP annotations (openWorldHint), and each needs a grant from you.
Secret values are masked in tool output by default, and a new WireGuard key never enters the
conversation unless you ask for it.
Footprint. One static binary with no dependencies: 9.1 to 11.3 MB for the router architectures (9.6 MB on arm64, about 3.7 MB compressed in the release archive), about 10 MB resident on a GL-MT6000 right after start, and 23 tools in about 22 KB of tool descriptions. Every release file is checksummed and carries a build attestation.
Based on GlassOnTin/openwrt-mcp by Ian Williams, which targets GL.iNet firmware 4.x (OpenWrt 21.02,
opkg). This is a port to stock OpenWrt 25.12 (apk, fw4/nftables, procd, netifd, LuCI JS). The security core is upstream's; see Credits for what came from where.
apk releases). Nothing to install on the
router beforehand. 24.10 and older (opkg, 21.02-era tooling) are not supported -- use
upstream for GL.iNet 4.x firmware.ssh and tar (Linux, macOS, or Git
Bash on Windows).install.sh reads the router's
DISTRIB_ARCH and picks it, or cross-compiles when you build from source.Tested on a GL.iNet GL-MT6000 (mediatek/filogic, aarch64_cortex-a53) with OpenWrt 25.12.5.
Nothing in the code is specific to that board.
From each project's own README on 2026-10-07; "not stated" means the README does not say. Corrections are welcome.
| this project | openwrt_ssh_mcp | paulomac1000/openwrt-mcp | openwrt-luci-mcp | |
|---|---|---|---|---|
| Runs on | the router | a PC, in Docker | a PC, in Docker or Python | a PC, in Node.js |
| Reaches the router by | SSH to a key-bound forced command, or loopback HTTP | SSH | SSH | LuCI's HTTP /ubus |
| Changes configuration | yes, through one tool that checks first and arms a rollback | yes, including packages and a firmware flash | read-only unless ENABLE_WRITE_OPERATIONS=1 | no |
| Automatic rollback of a bad change | yes, and it survives a reboot | not stated | not stated | not applicable |
| Access control and audit | per-client grants with scopes, expiry and a rate limit, audit log, optional TOTP | command whitelist, audit log | audit log | secrets hidden, no audit log |
| Package manager | apk (OpenWrt 25.12+) | opkg | opkg | lists installed and available |
| Flashing firmware | never | yes | not stated | no |
| Needs on the router | the binary | SSH | SSH | uhttpd-mod-ubus, rpcd |
| upstream 0.5 (21.02 / GL.iNet) | this port (25.12) | |
|---|---|---|
| Packaging | .ipk for opkg | install.sh file install + keep.d (25.12's apk has no mkpkg; see below) |
| Web UI | GL.iNet oui-httpd page | LuCI page, Services -> MCP Server (read-only) |
| Transport | HTTP over an ssh -L tunnel | same, plus stdio over SSH via a key-bound forced command -- no tunnel to keep alive |
| Rollback snapshot | /tmp (lost on reboot) | on flash; an unconfirmed change is rolled back at the next start, including after a power cycle |
uci_apply | set / create / delete | + add_list, del_list, set_list, dry_run, shows the staged uci changes, strict name validation |
| Reload | ubus call uci reload_config (async) | /sbin/reload_config, plus explicit config.change events when it has no checksums (first run after boot) |
| WireGuard | GL wireguard_server / gl_ddns | stock network.<iface> + wireguard_<iface> peers, DDNS from ddns, CGNAT warning, PSK, list and remove |
| Packages | -- | pkg_query, pkg_change (simulate first), .apk-new review with a settings-level diff |
| Firewall | -- | firewall_show: nft ruleset, fw4 print, fw4 check |
| Services | -- | service_list / service_control via rpcd rc |
| Scope globs | [0] was a character class | brackets are literal: firewall.@rule[3].* means what it says |
| Presets | -- | `openwrt-mcp allow @readonly |
| Tool | Scope | |
|---|---|---|
system_status | tool | Model, release, uptime, load, memory, storage, temperatures, conntrack, every interface (state, addresses, default route, errors), radios/SSIDs, and what is outstanding. Call first. |
logread | tool | Whole log buffer filtered (substring, RE2, last N minutes) before the line limit. |
network_clients | tool | DHCP leases + static hosts + neighbour table + every AP's association list, joined by MAC: name, IP, SSID, signal, rates, connected time, lease. |
firewall_show | tool | nft list ruleset, fw4 print, fw4 check, one table or chain. |
net_diag | <action>[.<target>] | ping / traceroute / nslookup (optionally from a given interface), routes, policy rules, neighbours. |
uci_get | <config>[.<section>[.<option>]] | uci show, optionally with stable cfgXXXXXX ids. Ends with the config's revision. history=list / diff:<id> shows the kept versions from before each confirmed change. |
uci_apply | <config>.<section>[.<option>] per change; <config> for restore; probe.<kind>.<target> per probe | Stage, check, commit, reload, rollback armed. dry_run shows exactly what would change and what the service's own checker says. expected_revisions refuses if a config moved meanwhile, probe checks the router afterwards, restore=<id> puts a past version back. |
uci_confirm / uci_rollback | tool | Make a pending change permanent / undo it now. |
service_list / service_control | tool / <service>.<action> | procd services; stopping or disabling dropbear, network, rpcd or openwrt-mcp is refused. service_control waits for the state to settle (wait) and says if it did not. |
pkg_query | tool | installed, upgradable, search, info, files, owner, policy, apk audit, world. |
pkg_change | <action>.<pkg> / upgrade | apk add/del/upgrade; simulates unless commit=true; reports new .apk-new files. |
pkg_config_diff | tool | Every .apk-new as a diff against the live file -- for /etc/config/* by setting (uci show), so quoting/indentation noise disappears. |
pkg_config_resolve | live path | keep_current (drop the new default) or use_new (install it; rollback-armed for UCI configs). |
sysupgrade | <action> | list (preserved files), test (validate an image in /tmp), check (owut), backup (mode 0600; the previous archive this tool made is removed). Never flashes. |
wg_list_clients | tool | Peers with handshake age, endpoint, traffic, config/kernel mismatch, duplicate names. |
wg_new_client | wireguard.<iface> | Keypair, next free address, peer saved and hot-added. The private key and config go to a root-only file; you collect them with openwrt-mcp wg-show <name> (config and QR in your own terminal). reveal=true returns them in the result instead. |
wg_remove_client | wireguard.<iface>.<name> | By name, key or section; refuses a peer connected in the last 3 minutes unless forced. |
ubus_list | (ungated) | Discovery: every object, method and argument signature. |
ubus_call | <object>.<method> | Anything else on the bus. Replies over 8 KB have long arrays pruned. |
exec | argv[0] | One program, no shell. A grant for sh, find, awk, env, ssh and the like is a root shell: allow refuses it without --shell-equivalent. |
mfa_unlock | (ungated) | Opens the TOTP window for MFA-gated tools. |
Prompts. Clients that show MCP prompts (as slash commands, usually) get five recipes:
router-health, who-is-online, secure-my-router, wifi-doctor and upgrade-plan. Each is a
few lines that name the tools to call and end in a report or a proposal, never a change. They use
only what the @readonly preset grants, so a read-only client can follow them to the end, and they
cost nothing until used. Listing or fetching one runs nothing on the router, so no policy applies
and nothing is audited.
On the router, from a release (nothing needed beyond a default 25.12 image):
wget -O /tmp/install-router.sh https://github.com/stanislav-testhub/openwrt-mcp/releases/latest/download/install-router.sh
sh /tmp/install-router.sh
It picks the archive for the router's DISTRIB_ARCH, checks it against the release's
SHA256SUMS (refusing on a mismatch), and installs. VERSION=v1.2.0 selects a release other
than the latest. Each release file also carries a build attestation:
gh attestation verify <file> --repo stanislav-testhub/openwrt-mcp.
From a PC, building from source (needs Go, ssh and tar; Git Bash works on Windows):
git clone https://github.com/stanislav-testhub/openwrt-mcp
cd openwrt-mcp
ROUTER=root@192.168.1.1 SSH_PORT=22 SSH_KEY=~/.ssh/id_ed25519 ./install.sh install
ROUTER, SSH_PORT and SSH_KEY are your router's root login (defaults: root@192.168.1.1,
22, your ssh defaults). ./install.sh install --release [vX.Y.Z] skips the build and has the
router fetch a verified release instead; that is also what happens when no Go toolchain is found.
Either way the same installer runs on the router.
/usr/bin/openwrt-mcp;keep.d entry;uci_apply awaits confirmation, because that would roll it back,
unless FORCE=1../install.sh uninstall [--purge] reverses it.
Why not an .apk: 25.12 packages are apk-tools v3 ADB archives. Building one needs the
OpenWrt SDK or a host apk-tools with mkpkg (the router's apk has no mkpkg), and an
unsigned package then needs --allow-untrusted anyway. A static binary loses nothing by being
installed as files: /etc/config survives sysupgrade on its own, and /lib/upgrade/keep.d/openwrt-mcp
carries the binary, init script, rc.d links, state and LuCI page. (An owut/ASU image
rebuilds packages, not loose files -- the keep.d entry is what preserves this across one.)
openwrt-mcp connect (recommended)The release has a binary for your PC too (openwrt-mcp_<version>_windows_amd64.zip, darwin_arm64.tar.gz
and so on; a Linux PC can use the linux_ archive). Unpack it anywhere on your PATH; it needs
OpenSSH, which Windows 10 and later, macOS and Linux have. Then, on your PC:
openwrt-mcp connect --client claude-code --host 192.168.1.1 # or claude-desktop, codex, cursor, gemini, vscode
It makes a dedicated ed25519 key (~/.ssh/openwrt_mcp, never overwriting one), prints the two
commands to run on the router once, and shows the entry to add to your client. Add --write and it
does that last step: for Claude Code and Codex by running their own mcp add, for Claude Desktop,
Cursor, Gemini CLI and VS Code by merging one openwrt entry into their JSON file (other servers
stay; the original is kept once as <file>.before-openwrt-mcp; a file with comments is left alone and
the snippet printed instead). The entry stores ssh and its arguments as a list, so spaces in a key
path need no quoting, and it sets keep-alives so a router that rebooted is noticed.
openwrt-mcp connect doctor --host 192.168.1.1
checks the chain in order and stops at the first break, with one fix for it:
[ ok ] ssh reachable 192.168.1.1:22
[ ok ] host key
[ ok ] key accepted /home/you/.ssh/openwrt_mcp
[FAIL] forced command the key logged in, but nothing answered as an MCP server (...)
fix: the key is probably authorized without its forced command ...
[skip] daemon socket
The steps are: SSH reachable, host key, key accepted, forced command, daemon socket, initialize,
tools/list. Wrong key, a missing forced command, a stopped daemon and a disabled socket each name
their own step. The weekly reboot disconnects the bridge; the client reconnects on its next start.
A dedicated key, bound on the router to the MCP bridge and nothing else:
ssh-keygen -t ed25519 -N '' -f ~/.ssh/openwrt_mcp
ssh root@router "openwrt-mcp authorize-key claude-code '$(cat ~/.ssh/openwrt_mcp.pub)'"
ssh root@router "openwrt-mcp allow claude-code @readonly 30d"
claude mcp add openwrt -- ssh -T -i ~/.ssh/openwrt_mcp -o BatchMode=yes root@router
authorize-key writes a line like
command="/usr/bin/openwrt-mcp stdio --client claude-code",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 ...
into /etc/dropbear/authorized_keys: that key gets the MCP server as client claude-code and
cannot open a shell or forward ports. It refuses a key that is already a root login.
The bridge pipes the session into a root-only unix socket (/var/run/openwrt-mcp/mcp.sock,
0600 in a 0700 directory). The daemon runs the tools, so a rollback armed in one session
outlives it. The client name on the socket is asserted, not proven -- sound because only root
can connect, and root can already edit the policy file; the SSH key is the real
authentication.
On Windows, the built-in OpenSSH client works for this (ssh.exe); use a full path to the key.
Unchanged from upstream: openwrt-mcp pair <client> prints a bearer token once;
ssh -N -L 8730:127.0.0.1:8730 root@router; then
claude mcp add --transport http openwrt http://127.0.0.1:8730/mcp --header "Authorization: Bearer $TOK".
The daemon refuses to bind anything but loopback.
If the agent can also run ssh root@router ... through its shell tool, the policy is
advisory. Deny that in the client (Claude Code: a permissions.deny rule for
Bash(ssh * root@<router>*)), or keep the root key where the agent cannot read it.
Deny by default. A policy grants one client a list of tools, scope globs (* and ? are
wildcards; brackets are literal), a calls/minute ceiling and an expiry. Changes to
/etc/config/openwrt-mcp take effect without a restart.
openwrt-mcp allow claude-code @readonly 30d # every read tool; ubus_call on an allow-list of read methods
openwrt-mcp allow claude-code @operator 7d # + uci_apply/confirm/rollback, service_control, pkg_change,
# pkg_config_resolve, wg_new/remove_client, sysupgrade backup
openwrt-mcp allow claude-code uci_apply 'dhcp.* wireless.*.disabled' 30d
openwrt-mcp allow claude-code service_control 'dnsmasq.restart adguardhome.*' 30d
openwrt-mcp revoke claude-code # remove every policy for the client
openwrt-mcp prune --older-than 7d # delete grants that expired over a week ago (audited)
openwrt-mcp policies | status [--all] | clients
Granting the same tools and scopes again replaces the client's expired grant instead of adding
a block. status counts expired grants in one line; --all lists them.
Neither preset includes exec or unrestricted ubus_call; both bypass the safety rails. The
read-only ubus list is explicit object.method pairs, not *.list-style globs, because
session.list returns LuCI session ids. uci_get '*' reads every config, but the values of
secret options (Wi-Fi keys, WireGuard keys, passwords, tokens) come back as <redacted>; see
Safety model. exec is not masked.
A refusal names the uncovered scope and prints the exact allow line that would cover it.
An exec scope is argv[0] taken literally, so a grant for find or awk looks harmless and
is a root shell: both run other programs, and so do the shells, env, nice, flock,
timeout, xargs, tar, ssh, lua, ucode, apk and opkg. allow refuses such a grant
until you add --shell-equivalent, judging by base name so /usr/bin/../bin/sh and /bin/*
count too; a ubus_call grant that can reach file.exec is treated the same way.
openwrt-mcp policies and status mark these grants, which also covers a policy written by
hand. The list is a floor, not a promise: a grant that writes files (wget, cp, dd) or
starts an interpreter that is not on it can still be abused.
Unchanged from upstream: openwrt-mcp mfa enrol <client> prints an otpauth:// URI; then in a
policy list mfa_tools 'uci_apply', option mfa_window '15m'. One code opens a time-boxed
window; codes are single-use; unlocks live in memory only.
Wrong codes are limited: in the config server section, option mfa_max_failures '5' consecutive
wrong codes lock that client out of mfa_unlock for option mfa_lockout '300' seconds ('5m' also
works), doubling on each repeat to one hour. A correct code during the lockout is refused and not
spent. A client with no secret counts the same, so the limit reveals nothing about enrolment, and
the counters are in memory (a restart, mfa enrol or a rotated secret clears them). Each wrong code
is audited as ERROR and a lockout as DENIED; the code itself is never logged. The price: whoever
holds a client's token can keep that client locked out for up to an hour.
uci_apply snapshots the touched
configs to /etc/openwrt-mcp/rollback/<token>/ (fsynced), writes the pending record before
committing, commits, reloads, and arms a timer (default 90 s, max 600 s). No uci_confirm ->
files restored atomically, staged changes reverted, services reloaded. If the router is
power-cycled inside the window, procd starts the daemon after network (S95) and it rolls back
at startup, forcing a config.change for each restored config.fw4 check for the firewall) and lists only the problems the change adds.
A real apply refuses them unless force=true; a config that was already broken can still be
fixed. dnsmasq, dropbear and the rest have no checker that sees staged changes: they are
reported "not checked", not "passed".uci_get ends with the config's revision. Pass it back as
expected_revisions and the apply is refused with CONFLICT if the config changed meanwhile
(LuCI, another client).probe (ping the gateway, resolve a name) or force=true. Probes run after the reload and are
reported with the result; the rollback window defaults to 180 s for such a change. The rule set
is static (LAN plus whatever dropbear names), so it covers the common layout, not every
topology.history_keep, default 5, per config, in /etc/openwrt-mcp/history/).
uci_get history=list shows them, history=diff:<id> compares one with now, and
uci_apply restore=<id> puts one back through the same snapshot, checks and rollback timer.
Entries contain secrets, like the snapshots: 0600, masked when shown, and part of a
sysupgrade backup. option history_keep '0' turns it off.uci_apply and pkg_config_resolve refuse the openwrt-mcp policy
config whatever the grant, so a client cannot write itself a wider policy. Policies change
only through openwrt-mcp allow / revoke on the router.@operator is powerful. uci_apply on * reaches every UCI config, including
dropbear, rpcd and uhttpd, and pkg_change installs packages as root. Grant it for a
working session (2h), and keep long-lived grants to narrow scopes such as
'dhcp.* wireless.*.disabled'.lan.ipaddr=x cannot make
uci act on a different key from the scope the policy approved.-f) are refused.service_control will not stop or disable dropbear, network, rpcd or
openwrt-mcp. sysupgrade has no flash action -- validate an image with test, flash by hand.
wg_remove_client will not drop a tunnel that handshook in the last 3 minutes unless forced.wg_new_client writes the new client's config
(private key included) to a root-only file in RAM, beside the socket, and returns the public
key and the command to run. openwrt-mcp wg-show <name> on the router prints the config and
a QR code in your terminal and deletes the file; an uncollected file goes after 24 hours or at
the next reboot. Only reveal=true puts the key in the result, and so in the model's context
and the provider's logs. A sysupgrade backup is created 0600 before anything is written to it
and only the newest one is kept. The audit log records arguments and a summary, never tool
output, and redacts secret-looking fields. The server private key is read from wg show dump
and discarded.uci_get, uci_apply (diff and errors), pkg_config_diff,
pkg_config_resolve, uci_confirm, uci_rollback, system_status and ubus_call keep the shape
of a line and replace the value of a secret option (key, key1..key4, psk, password,
sae_password, passphrase, private_key, preshared_key, token, pwd, anything containing
secret...) with <redacted>. The audit log uses the same rule. exec, logread and the
diagnostics are not masked, and wg_new_client is exempt on purpose. In the config server
section: list redact_extra 'vendor_blob' adds names; option redact_output '0' turns masking
off, and is audited. A masked diff does not show what changed in a secret.exec, ubus_call,
wg_new_client or the configuration tools). network_clients, logread and net_diag start with
an [untrusted text: ...] line. That lowers the odds a model obeys such text; it does not remove
them, so keep write scopes out of sessions that read it./www, a cgi-bin directory or uhttpd's home, even through a symlink.On a GL-MT6000, stock OpenWrt 25.12.5, installed with install.sh's steps and driven from
Windows exactly as Claude Code does (ssh.exe with a forced-command key, stdio bridge):
S95/K10 links, socket 0600 in a 0700
directory, HTTP on 127.0.0.1 only, forced-command line in dropbear's authorized_keys,
@readonly + time-limited @operator policies picked up without a restart.system_status, network_clients, wg_list_clients (duplicate-name warning),
service_list, pkg_query upgradable, pkg_config_diff (11 real .apk-new files,
settings-level diffs), firewall_show check, net_diag ping from br-WAN, logread
filtering, uci_get, sysupgrade list, ubus_call.uci_apply including set_list ->
automatic rollback at the deadline, file restored byte-identical and pending.json removed;
a second apply refused while one is pending; uci_confirm; uci_rollback on demand;
dry_run.wg_new_client with a preshared key (next free address, peer committed to UCI
and hot-added to the kernel, config + QR, CGNAT endpoint warning), listed, then
wg_remove_client -- /etc/config/network back byte-identical, kernel peer gone.SIGKILLed with an unconfirmed apply -> procd respawned it
after 6 s and it rolled the change back at startup.exec, ubus_call session.list, out-of-scope uci_apply (each naming the
grant line), any uci_apply on the policy config, unauthenticated HTTP (audited).uci_get wireless and ubus_call network.wireless status return <redacted> for every Wi-Fi key, while ssid,
encryption and look-alike names such as wpa_disable_eapol_key_retries are untouched
(numbers, booleans and arrays in the JSON reply too). A uci_apply dry run that sets a key
shows it redacted and leaves nothing staged. network_clients and logread start with the
untrusted-text marker. Five wrong mfa_unlock codes from a client with no secret lock it out
for 5 minutes (the sixth is refused with a retry time); the audit log records four ERROR
entries then DENIED, and none of the submitted codes appear in it. A syslog line written
with ubus call log write that held CSI and OSC sequences, a colour code, BEL and a bidi
override (all present in the router's own log) came back through logread with only the
text and the tab left, and an over-long line was cut at 1024 bytes with a [+N bytes] note.
The syntax of uci changes (+=, -=, '\'', a bare -path for a delete) was captured
from the router and is a test fixture.fw4 check sees changes staged in uci (it exits 0 for an invalid value and prints [!] lines,
which is what validation reads): a dry run that sets a bogus zone value reports it as a NEW
problem, the real apply is refused, and nothing is left staged or snapshotted. A stale
expected_revisions is a CONFLICT; the current one passes; one for a config the call does not
change is refused. A dry run on the LAN interface names the management path, and a real apply
without a probe is refused with nothing staged. add_list of an element already in
system.ntp.server is skipped. On a scratch option in luci: an apply with a ping and a
resolve probe reports both OK after the reload and arms the rollback; confirming it leaves a
history entry (0700 directory, 0600 files); history=diff shows what the change added;
restore brings the file back byte-identical with its mode, runs its probe, and leaves an entry
of its own; an apply with a failing probe reports PROBE FAILED with the rollback still armed,
and uci_rollback restores the file byte-identical. service_control restart of a daemon
reports "Settled after" its state.install.sh and openwrt-mcp version reported 1.3.0. connect doctor from the Windows PC,
with its real OpenSSH, passed all seven steps and listed 23 tools; with a key the router does
not know it stopped at key accepted, and with a closed port at ssh reachable, each with its
fix. connect --write for Cursor made the key with the PC's own ssh-keygen, merged the entry
into a JSON file, and a second run reported the entry unchanged. diag printed the version,
board, policy shape and audit lines with the SSH client's address as ip-1 and no audit
arguments; --detail added the masked summaries. sysupgrade backup created the archive
-rw------- (4.3 MB, so sysupgrade -b keeps the mode of a file that already exists), and the
second backup removed the first. The daemon held about 10 MB resident right after the restart.status folded 45 expired grants into one line; prune removed them, wrote one
prune audit entry, and the daemon reloaded with the live grants only.Unit and end-to-end tests (real MCP client over in-memory transport and over the bridge
handshake) run on any OS against a fake router: apply/confirm/rollback/timeout, restart
inside the window, dry run, refusal on foreign staged edits, list ops, WireGuard
add/list/remove against a captured 25.12 config, client join, log filtering, apk simulation,
.apk-new resolution with rollback, preset contents, scope glob semantics.
Not yet verified on hardware (covered by the fake-router tests only): pkg_change with commit,
the LuCI page rendering, the web-root guard on a real /www path or symlink, a real power cycle (the
recovery path is the same one the SIGKILL test exercises), and keep.d across a real sysupgrade. For
1.2.0 also: a management-path change applied with probes (only its refusal without one was run),
a probe failure left to the rollback timer instead of uci_rollback, and history across a
sysupgrade. For 1.3.0 also: the wg_new_client hand-over and wg-show (the test router's wg0 was
not up, and starting it was not part of the check), connect --write against a real Claude Code,
Codex, Claude Desktop, Gemini or VS Code (only the file merge and the command line are tested),
anything on macOS, and the release workflow and the installer's download path (a release has to
exist first). The file modes of the WireGuard hand-over are asserted on Linux in CI. Reports from
other boards are welcome.
GlassOnTin/openwrt-mcp (Ian Williams, MIT) --
the base of this project. Kept from it: the on-router static Go daemon under procd, standing
deny-by-default policies with scope globs, rate limits and expiry, bearer tokens stored as
digests, TOTP windows, the JSONL audit log with secret redaction, loopback-only HTTP, ubus
reply pruning, and the confirm-or-roll-back idea for uci_apply. The git history of the
upstream project is preserved in this repository.
jsebgiraldo/openwrt_ssh_mcp -- an off-router Python server (SSH per call, regex command whitelist), reviewed for ideas. Four were taken and reimplemented here:
sysupgrade -T -> sysupgrade action test);system_status);apk: pkg_query, pkg_change);net_diag).Not taken: OpenThread border-router tools (hardware-specific), opkg and iptables commands
(gone in 25.12), a flash-firmware tool (irreversible and unrecoverable remotely), and regex
whitelisting of shell strings (argv + policy scopes instead).
Bug reports and pull requests are welcome -- see CONTRIBUTING.md. Please report vulnerabilities privately as described in SECURITY.md, not in a public issue. Planned work is in ROADMAP.md.
Reporting a bug: on the router run openwrt-mcp diag and paste the output into the issue. It
prints the version, board, daemon state, the shape of the policy and the last 20 audit lines
(--audit N), with every IPv4, IPv6 and MAC address, host name, DHCP name, SSID, domain and
WireGuard peer name replaced by a placeholder (ip-1, mac-1, host-1, ssid-1) that stays
the same for the same value. Audit arguments are never printed, and the scope, summary and error
text of an entry only with --detail, masked the same way. Names are matched against what the
router itself says they are, so read it once before pasting: a name that is only in free text, not
in the router's configuration, is not recognised.
MIT -- see LICENSE. Copyright (c) 2026 Ian Williams (upstream) and Stanislav Chupin (OpenWrt 25.12 port).