Skip to content

Releases: stanislav-testhub/openwrt-mcp

openwrt-mcp 1.5.0 -- Reach and resilience

Choose a tag to compare

@github-actions github-actions released this 10 Oct 08:26

Reach routers the 25.12-only server could not, run it from a plain shell, let a client ask for less than its grants allow,
and survive the things a router does (reboots, restarts, dropped sessions). The one behaviour change to know about: adding
or removing a WireGuard peer is now a normal rollback-armed change (see Changed).

Added

  • opkg and 24.10 (ROADMAP 5.1). The package tools use opkg when the router has no apk (pkg_query, pkg_change,
    pkg_config_diff, pkg_config_resolve, the doctor's new-defaults and kernel checks). system_status shows
    packages: apk|opkg; firewall: fw4|fw3.
    • New doctor finding opkg-new-pending (the apk one stays apk-new-pending); the audit skips the package check on opkg.
    • firewall_show on an fw3 router: ruleset and rendered; other views are refused with a reason.
    • CI: a real-target job runs the package and uci tools in the openwrt/rootfs 24.10 and 25.12 containers.
    • Catalogue budget raised from 23,500 to 23,650 bytes (prose 12,450 to 12,650) for the descriptions that now name both managers.
  • openwrt-mcp call and a skill file (ROADMAP 5.3). ssh ROUTER call TOOL '{json}' runs one tool and prints its text:
    no MCP client needed. call --list names the tools of the session, call TOOL --help prints a tool's schema. It runs
    through the same server as an MCP session, so policy, audit, redaction and rollback are the existing path. A refusal goes
    to stderr with the allow line, exit status 1. skills/openwrt-mcp/SKILL.md is generated from the catalogue
    (UPDATE_SKILL=1 go test -run TestSkillFileIsCurrent).
  • Client-chosen tool profile (ROADMAP 5.4). --read-only and --toolset diag,config,pkg,wg narrow one session: read-only
    is the @readonly preset intersected with the client's grants; a toolset lists only its tools (system_status is always
    there, exec is in none). It is enforced when a tool is called, not only in tools/list. connect --read-only and
    connect --toolset write the profile into the client entry (after --, because OpenSSH reads --read-only as its own option).
  • Entry grammar. The words after the forced command (SSH_ORIGINAL_COMMAND) are parsed by the daemon against an
    allow-list: --toolset, --read-only and call, with a length cap and no control characters, never through a shell.
    Anything else is refused and audited. Existing authorized_keys lines keep working.
  • Deadlines and a workload cap (ROADMAP 5.7). Each tool has a deadline above its own waits; at most 8 calls run at once,
    the rest queue for up to 30 s and then get a busy refusal (audited DENIED, code TIMEOUT). uci_confirm, uci_rollback
    and mfa_unlock never queue. A cancelled call kills the command it started and is audited as cancelled by the client; a
    deadline hit as <tool> hit its <limit>.
  • Session resilience (ROADMAP 5.6).
    • The bridge waits up to 10 s for the daemon's socket (reboot, upgrade, crash) instead of failing on the first refused dial,
      and ends with [code: TIMEOUT; next: ... wait a few seconds and retry] when it gives up.
    • Audit: a stdio session closed after <duration>: <reason> line per stdio session and a daemon started line per start.
    • status (text and --json last_disconnect) shows the last disconnect and its reason; a session still open when the
      daemon started again is reported as cut by the restart.
  • Add-on status (ROADMAP 5.2). service_list takes detail=<service>: a read-only status for installed add-ons
    (tailscale, AdGuard Home, podkop). The list ends with the names that apply on the router. Scope <service>.detail. Fixed
    fields only, no credentials. Catalogue budget raised from 22,100 to 22,300 bytes (prose 11,500 to 11,550): one parameter
    serves every adapter, instead of a tool per add-on. The other six adapters (adblock, adblock-fast, https-dns-proxy, mwan3,
    pbr, ddns) are in 1.6.
  • Cross-config references (ROADMAP 5.10). uci_get takes refs=<name>: finds what defines an interface, zone, device,
    radio or mwan3 member/policy of that name and every reference field in network, firewall, dhcp, wireless, sqm and mwan3
    that uses it. config narrows the search (and is optional with refs). Scope refs. Catalogue budget raised from 23,650
    to 23,900 bytes (prose 12,650 to 12,875); uci_get gets a per-tool budget of 1,650 bytes.
  • Warnings in a dry run (ROADMAP 5.9). uci_apply dry run lists warnings from this change: the audit's firewall, SSH,
    LuCI, UPnP and Wi-Fi checks run on the live and on the staged configuration, and only what the change adds is reported, with
    the audit's next steps and wiki pages. Two new findings: ref-removed / ref-unknown (a deleted, renamed or misspelled name
    that other sections still use, with a case-mismatch hint) and radio-in-use (the change turns off the Wi-Fi network this
    session is connected through). A real apply reads no warnings.
  • The management path follows the session. The stdio and call bridges send the client address from SSH_CLIENT, the daemon
    resolves it with ip route get, and the interface it leaves by is treated like the LAN, so a session over a WireGuard tunnel
    is protected by the probe and force rules too. A WireGuard interface's addresses, listen_port and private_key are
    management options.

Changed

  • wg_new_client with reveal=true has its own scope, wireguard.<iface>.reveal (wireguard.reveal without an
    interface), so a policy can allow the tool without ever putting the private key in the conversation. A grant for
    wireguard.<iface> alone no longer covers reveal=true; wireguard.*, * and the presets are unchanged.
  • wg_new_client and wg_remove_client no longer hot-add or hot-remove with wg set. They save the peer in the network
    config through the standard rollback path (snapshot, armed rollback, reload, read-back) and need uci_confirm within 90 s;
    otherwise the change is reverted (a new client's key file is deleted too).
    • netifd loads the peer; if it has not after a short wait, the interface is asked to renew (ubus call network.interface.X renew), and a rollback does the same.
    • A commit that fails restores the snapshot and reverts the staged edit. One apply can be pending at a time
      (ROLLBACK_PENDING); staged network edits are refused (CONFLICT).
    • Removal refuses the peer the session arrives through unless force=true. A peer that only the running interface knows is
      still removed with wg set (no rollback possible).
  • Busy and deadline refusals carry TIMEOUT, toolset and read-only refusals POLICY_DENIED.
  • wg_remove_client deletes the removed peer's uncollected client config (it holds the private key) instead of leaving it
    for the 24 h sweep.
  • A command the daemon refuses (--toolset bogus) now ends the bridge with command refused: <reason>, not
    daemon closed the connection, which read like a crash.

Tests

  • The write-path table covers both wg_* tools. Every item was mutation-tested (80 to 90 mutants per item, survivors killed or
    explained as equivalent); FuzzParseEntry and the adapter parsers were fuzzed.

Install on the router (it picks the archive for the architecture and checks SHA256SUMS):

wget -O /tmp/install-router.sh https://github.com/stanislav-testhub/openwrt-mcp/releases/latest/download/install-router.sh
sh /tmp/install-router.sh

or from a PC with ./install.sh install --release. Verify where a file was built with
gh attestation verify <file> --repo stanislav-testhub/openwrt-mcp.

On your PC (Windows, macOS), openwrt-mcp connect --client <name> --host <router> makes the
SSH key and sets up your MCP client; unpack the windows_* or darwin_* archive for it.

openwrt-mcp 1.4.0 -- Diagnose

Choose a tag to compare

@github-actions github-actions released this 08 Oct 12:04

Answer "is it healthy, is it exposed, what is going on" in a few calls, and check that a change landed.
Nothing here adds a way to change the router: the new modes are read-only, and the existing write tools
now read back what they wrote.

Added

  • Verify after write (ROADMAP 4.9). A write the router accepts and then does not keep is
    NOT_APPLIED, a new error code, instead of a success the caller has no reason to doubt. Each
    write path reads back what it changed:
    • uci_apply: after the commit and before any service is reloaded, each config is re-read
      with uci show and every change is compared with what the batch must have left. The last
      write to a key wins; a set to the empty string means unset; add_list, del_list and
      set_list are checked against the list (order included); create against the section type;
      delete against absence. A difference returns NOT_APPLIED with the keys, reloads nothing and
      leaves the rollback armed; the message names uci_rollback and says not to confirm. A
      success says Verified: re-read dhcp after commit, 3 of 3 change(s) match.
    • Sections addressed by position (@rule[3]) or by a generated name (cfg0a1b2c) are not
      compared, because an earlier add or delete in the same batch moves them. The result counts
      them. The value of a secret option (key, private_key, psk, password, ...) is never
      printed in a mismatch.
    • wg_new_client: after the commit the peer is looked up by its public key, with its name
      and address, before anything touches the running interface. After wg set it must be on the
      interface. A failed hot-add stays the note it was (the interface may simply be down); a
      wg set that exits 0 and leaves no peer is NOT_APPLIED, and the client's config file is
      kept for openwrt-mcp wg-show.
    • wg_remove_client: the peer must be gone from wg show before the config is deleted
      (otherwise a live peer would be listed nowhere), and the section must be gone after the commit.
    • pkg_change: after an add or del with commit=true, /etc/apk/world must list the
      package, or no longer list it. Version constraints and repository tags in the file are
      ignored; upgrade is not checked because it changes versions, not what was asked for.
    • A read-back that cannot run is not a failed write: the result says Not verified: and why.
    • TestEveryWritePathVerifiesAfterWrite lists every tool that is not read-only. Each one runs
      against a backend that accepts the write and keeps none of it and must answer
      NOT_APPLIED, or is exempt with a written reason (uci_confirm, uci_rollback,
      pkg_config_resolve, sysupgrade, exec, ubus_call, mfa_unlock). A tool added later
      fails the test until it is in one list.
  • net_diag wifi_survey, traffic and usage; airtime in network_clients (ROADMAP 4.4, 4.5).
    All read-only, so the existing net_diag grants cover them without widening.
    • wifi_survey[.<device>]: per radio, busy, rx and tx airtime and noise for the channels the
      radio has stayed on for at least 10 s, from iwinfo survey. The driver only keeps real figures
      for the channel it is on (every other channel holds the milliseconds of a boot-time scan), so
      the result says the rest are not measured and does not recommend a channel: that needs an
      active scan, which moves the radio off its channel and is not read-only. The noise byte is a
      signed dBm value read as unsigned (170 is -86 dBm). One survey per radio, however many SSIDs
      share it.
    • traffic[.<interface>]: /proc/net/dev sampled twice (count seconds, default 3, max 10),
      rates per interface (loopback and idle interfaces left out, a counter that went backwards
      counts as no traffic), and the top five sources in the conntrack table. Sources are ranked by
      bytes when the kernel counts them (nf_conntrack_acct), otherwise by connections, and it
      says which. The source is the original direction's, so through NAT it is the LAN client.
    • usage[.<YYYY-MM-DD>]: nlbwmon's totals per device (summed over its addresses), the 25
      largest, with the accounting periods it has. nlbwmon keeps a period per month by default, so
      this is "this month", not "today" or "this week". Without nlbwmon the error names the package
      and pkg_change as the way to install it.
    • network_clients: an AIR column, each Wi-Fi client's share of its radio's airtime
      (receive plus transmit, from hostapd). A dash when hostapd does not report it. Retries are
      not here: neither iwinfo nor hostapd exposes them.
    • Not in this release: the active neighbour scan and the channel recommendation that would
      need it, and Wake-on-LAN. Both change what the radio or the LAN does, so as net_diag actions
      they would silently reach every stored net_diag '*' grant.
    • net_diag and its untrusted-text label now also cover host names (leases).
    • Tool catalogue: 23,254 bytes; net_diag may be 1,700 bytes (was 1,500); the budgets move to
      23,300 and 12,300.
  • system_status mode=doctor and mode=audit (ROADMAP 4.2, 4.3). Two ranked lists of
    findings, severity first (high, medium, low, info), advisory only: nothing is changed.
    Each finding has a stable id, a message, the evidence, a next read-only call and an OpenWrt
    wiki link (every link was opened and read for content before it went in; the wiki answers 200
    for pages that do not exist). Each list ends with what was checked, what could not be
    (not checked: radios (ubus ...)), and where a check sees only part of the picture.
    • Doctor: radio-down, iface-no-address, iface-error, service-crashed,
      conntrack-high (80%, high at 95%), overlay-full and tmp-full (10% free, high at 5%),
      apk-new-pending, ntp-unsynced, clock-unset, reboot-needed.
      • A service counts as crashed only when procd has it with instances, none running and a
        non-zero exit code. rc list alone cannot tell a stopped daemon from a one-shot init
        script that ran and exited. On the router this was developed against, rc list shows 24
        of 45 enabled scripts as stopped: mostly one-shots, and one (AdGuard Home) that procd
        shows running. A daemon that is enabled but was never started has no procd instance and
        is not reported; the result says so on its limits: line.
      • NTP is asked of chrony (chronyc -c tracking) when it is installed. Otherwise only
        "was the clock ever set" is checked, and the limits line says so.
    • Audit: wan-zone-input-accept, wan-zone-forward-accept, wan-port-open,
      wan-forward-open, wan-redirect, ssh-wan, luci-wan, ssh-password-auth,
      luci-all-addresses, upnp-on, wps-on, ssid-open, ssid-wep, ssid-weak-cipher,
      ssid-wpa1, root-no-password, apk-audit-modified, wg-stale-peer.
      • The internet-facing zones are the ones named wan* and the ones holding an interface
        with a default route. The stock DHCP, ICMP and IGMP rules are not reported. An
        explicit SSH or web-interface port is reported as ssh-wan or luci-wan only when the
        service listens on every address.
      • The root password is checked by reading whether its field in /etc/shadow is empty.
        The hash is never stored or shown, and an error never carries file contents.
      • apk audit changes under etc/, tmp/, var/, overlay/, root/ and mnt/ are
        configuration, not tampering, and are ignored; a changed or removed file elsewhere is
        reported.
    • Not in this release: the upstream-DNS check (it needs an active probe, which would
      make system_status leave the router) and a "reboot needed after a kmod update" beyond the
      kernel version.
    • Scopes. doctor and audit are scopes of system_status. A * grant, which is what
      both presets give, covers them; a grant for one of them does not cover the other, and the
      denial prints the exact allow line. Plain system_status is unchanged.
    • Untrusted text. system_status now carries the untrusted-text label and says so in its
      description, because audit findings quote SSIDs. They are quoted, cut to 32 bytes and kept
      to one line.
    • Tests: a healthy router that raises nothing, one fixture per finding that triggers it and
      one per boundary that must not (one below, at and one above each threshold), the
      not-checked path, ranking, a test that every next step names a real tool and every link is
      in the verified list, the read-only command oracle over everything the two modes run, and
      27 mutation rows.
    • Tool catalogue: 22,967 bytes; the budget moves to 23,000 and the prose budget to 12,050.
  • logread mode=summary and baselines (ROADMAP 4.1). After a change the useful question is
    "what is new in the log", not "show me 500 lines".
    • mode=summary collapses the filtered log to its distinct messages, grouped by process
      (worst severity first, then busiest). Each row has the severity, a count, the first and last
      time and the message with MACs, IPv4/IPv6 addresses, clock times, hex ids, durations and
      numbers replaced by <mac>, <ip>, <time>, <hex>, <dur> and <n>. A duration is
      digits and a unit that stand alone (90s, 12h, 8d7h34m): an uptime in a periodic message
      would otherwise make every occurrence a new message in a baseline diff, which the first
      check on a router showed. A number that is part of a name
      (phy0-ap1, eth1) is kept, so two radios are never merged. lines limits the messages
      and offset pages through them.
    • baseline=save returns an 8-hex-digit token for the messages in the log now.
      baseline=<token> returns only the messages that were not there (in either mode, with the
      same filters), headed since baseline <token>: N of M distinct messages are new.
    • A bas...
Read more

openwrt-mcp 1.3.0 -- Easy to adopt

Choose a tag to compare

@github-actions github-actions released this 07 Oct 22:12

Install from a release, set a client up with one command, a smaller and portable tool catalogue, and
no credentials in the conversation. Nothing here adds a way to change the router.

Changed

  • Tool titles and hints (ROADMAP 3.4).
    • Every tool now has a display title, set both at the top level and in annotations.title
      for older clients.
    • Every tool states openWorldHint explicitly; the spec default is true, so every tool
      used to claim it. It is true for the seven tools that can reach past the router:
      • net_diag;
      • uci_apply, whose probes ping and resolve;
      • pkg_query, whose refresh runs apk update;
      • pkg_change;
      • sysupgrade, whose check runs owut;
      • ubus_call;
      • exec.
    • wg_remove_client is now marked idempotent: removing the same peer again changes nothing.
    • A contract test pins the title and the four hints of every tool.
  • Expired grants no longer pile up.
    • allow replaces the client's expired grant with the same tools and scopes instead of
      adding another block, so a daily allow claude-code @operator 2h keeps four blocks, not
      four more each day.
    • status folds expired grants into one count line; status --all lists them. --json is
      unchanged and still carries every grant with its expired flag.
    • Expired grants were already ignored when authorising, so this is cleanup, not security.
  • MCP Go SDK 1.6.1 to 1.8.0 (ROADMAP 3.10).
    • It still negotiates 2026-07-28 at most; nothing here narrows the protocol versions.
    • tools/list differs from 1.6.1 only in annotations: readOnlyHint and idempotentHint are
      now sent when false instead of being left out. Their meaning is unchanged, because
      false is the spec default. Input schemas and descriptions are byte for byte the same.
    • We set none of the MCPGODEBUG flags the SDK removed in 1.8.0. The HTTP Origin check is
      ours (server.go), not the SDK's, and its test passes unchanged.
    • The per-frame cap on inbound messages is 16 MiB by default; requests are a few hundred
      bytes, so it never applies.
  • Portable input schemas (ROADMAP 3.3).
    • Five optional or nested slices were advertised as "type": ["null","array"]:
      uci_apply.changes, its values, uci_apply.probe, pkg_change.packages and exec.argv.
      They are now a plain "type": "array". Type arrays break Gemini's OpenAPI subset and
      older VS Code.
    • A call that sends null for one of them (OpenAI-style clients do, for "not set") is still
      accepted and treated as omitted. Members the schema declares are dropped when null before
      validation; free-form objects such as ubus_call.args keep their nulls.
    • TestToolSchemasArePortable fails on a type array, $ref/$defs/$dynamicRef, a
      top-level anyOf/oneOf/allOf, a bare {"type":"object"}, or a server plus tool name
      over 60 characters. ubus_call.args and uci_apply.expected_revisions are free-form maps
      on purpose and pass because they state additionalProperties.
  • Smaller tool descriptions (ROADMAP 3.5).
    • tools/list went from 23,731 to 21,982 bytes (23 tools; 22,086 once wg_new_client
      gained reveal below). The text for operators is gone
      from the descriptions: the "Policy scope: ..." sentences, exec's warning about broad
      grants, and the uci_apply paragraphs that repeated its own parameter descriptions
      (ops, probe, restore, expected_revisions). A refusal already prints the exact
      scope to grant, and the README Tools table lists the scope syntax.
    • uci_get no longer says its output "includes secrets such as Wi-Fi keys": they are masked
      by default, so the sentence told a model the wrong thing.
    • TestCatalogueStaysWithinBudget fails above 22,100 bytes in total, above 11,500 bytes of
      descriptions, or above 1.5 KB for one tool (4 KB for uci_apply).
    • The limit is not the 18 KB ROADMAP 3.5 first named. That figure was set before 3.4's
      titles and hints and the SDK's explicit false hints, which together add about 2 KB that
      no description edit can remove; reaching it would have cut about 38% of all prose.
    • TestDocsOnlyNameToolsThatExist fails when a description, the server instructions or the
      README name a tool-like identifier that is not a tool.
  • Credentials stay off the transcript (ROADMAP 3.6). Behaviour change.
    • wg_new_client no longer returns the client's private key, preshared key or QR code. It
      writes the config to a file the owner alone can read, in RAM beside the daemon socket
      (/var/run/openwrt-mcp/wg/<name>.conf), and returns the public key and the command
      openwrt-mcp wg-show '<name>'. The roadmap named /tmp, which any local user can write
      into; the runtime directory is 0700, and the file is created with O_EXCL, so it never
      replaces a file or follows a planted symlink.
    • reveal=true restores the old result, key and QR included, and writes no file. Use it
      only when the key may enter the conversation and the provider's logs.
    • The file is created before the peer is committed, and removed again if anything after
      that fails; a name that already has a waiting file is refused. Files nobody collected are
      swept after 24 hours (at the next wg_new_client) and vanish at reboot.
    • openwrt-mcp wg-show <name> [--keep], run on the router, prints the config and the QR
      code in the operator's own terminal and deletes the file.
    • sysupgrade action=backup creates the archive 0600 before sysupgrade writes into it
      (it used to take the default umask), refuses to replace an existing file, treats an empty
      archive as a failure, and removes the archives this tool made earlier. Only files named
      exactly like its own (backup-<host>-<date>-<time>.tar.gz) are removed, and only
      regular files; the result says how many went.
    • mfa_unlock is unchanged: the operator still types the code into the chat. Moving it to
      elicitation is ROADMAP 6.1.
  • Shell-equivalent exec grants need a flag (ROADMAP 3.7).
    • openwrt-mcp allow <client> exec <programs> <duration> now refuses a grant that lets the
      client run a program that runs other programs, and says so. Add --shell-equivalent to
      grant it anyway; the grant is made and a warning is printed.
    • The list: sh ash bash dash busybox env nice flock timeout nohup setsid chroot su watch time start-stop-daemon taskset ionice chrt find awk sed xargs tar ssh dbclient lua ucode apk opkg ubus. The roadmap named the first dozen; the rest are BusyBox wrappers and shells of
      the same class. A test pins the list, so adding a name is a recorded decision.
    • Names are judged by base name, so sh, /bin/sh and /usr/bin/../bin/sh are the same
      grant, and by glob: *, s*, ?sh and /bin/* can reach a listed program and count.
    • A ubus_call grant that can reach file.exec (file.*, *, ...) counts too.
    • policies prints shell-equivalent: ... under such a grant, status marks it, and
      status --json carries shell_equivalent (additive; empty grants omit it). That also
      covers a policy file written by hand, which the allow gate never sees.
    • Existing grants are not changed or revoked.

Fixed

  • The stdio bridge outlived the daemon (ROADMAP 5.6). After a daemon restart, each
    openwrt-mcp stdio process stayed alive until its client's next request, blocked reading
    stdin. It now exits as soon as the daemon closes the socket, with "daemon closed the
    connection" on stderr.

Added

  • openwrt-mcp prune [--older-than <duration>] deletes expired grants, writes one
    prune line to the audit log when it removes any, and refuses a negative age (that would
    reach live grants). Comments, the server section and other blocks are kept byte for byte.
  • Release workflow (ROADMAP 3.1).
    • A pushed vX.Y.Z tag builds all ten router architectures. Each archive holds the binary
      and the files/ payload.
    • The workflow writes SHA256SUMS, attests the build (gh attestation verify), and
      publishes a GitHub Release with the CHANGELOG section as notes.
    • It refuses a tag that disagrees with main.go.
    • CI now also cross-builds mips64 and mips64le, which install.sh already supported.
  • Install without Go (ROADMAP 3.1).
    • install-router.sh runs on the router and is published with every release. It downloads
      the archive for the router's architecture, checks it against SHA256SUMS, refuses on a
      mismatch, checks the overlay has room, then installs.
    • install.sh now hands its own payload to the same script, so there is one install
      procedure.
    • install.sh install --release [vX.Y.Z] has the router fetch a release instead of
      building one. It is also the fallback when no Go toolchain is found.
    • install-router_test.sh exercises the download and verification against a local mirror
      in CI. It covers: latest, a pinned version, a malformed version, a missing architecture,
      a missing SHA256SUMS, and an archive swapped for another architecture.
    • CI checks that install.sh, install-router.sh and the release workflow list the same
      architectures.
  • openwrt-mcp connect and connect doctor (ROADMAP 3.2). They run on the operator's PC.
    • connect --client claude-code|claude-desktop|codex|cursor|gemini|vscode --host <router>:
      • makes an ed25519 key with the PC's ssh-keygen if there is none (never overwrites);
      • prints the two commands to run on the router (authorize-key, allow ... @readonly 30d),
        built from <type> <base64> of the public key only, because the key's comment is free text
        that would otherwise end up inside a pasted command;
      • shows the client entry, and with --write adds it: Claude Code and Codex through their own
        mcp add, Claude Desktop, Cursor, Gemini CLI and VS Code by merging one openwrt entry into
        thei...
Read more

openwrt-mcp 1.2.0 -- Reliable changes

Choose a tag to compare

@github-actions github-actions released this 07 Oct 22:08

Closes ROADMAP milestone 2 (items 2.1 to 2.6). No tool is added: every item is a parameter or a
mode of an existing tool.

Added

  • Validation before reload (2.1). uci_apply runs each config's own checker before staging
    (the baseline) and after (the candidate) and reports validation in the dry run. For the
    firewall that is fw4 check. Measured on the router: it sees changes staged in /tmp/.uci, but
    it exits 0 even for an invalid value and only prints [!] lines for what it will ignore, so the
    verdict is those lines, compared as counted lines with section indexes normalised (a warning
    that merely moved is not new). A real apply is refused when the candidate has a problem the
    baseline did not, unless force=true; a config that was already broken can still be fixed.
    Configs without a checker (dnsmasq has none that sees staged changes: dnsmasq --test reads a
    file generated at service start) are reported "not checked".
  • Revision locking (2.2). uci_get ends with # revision of <config>: <12 hex>, the digest of
    the committed file, even for a narrowed read. uci_apply takes expected_revisions
    ({config: revision}) and refuses with CONFLICT if a config changed since; a revision for a
    config the call does not change, or one that is not 12 hex digits, is an error rather than
    ignored. Dry runs print the revisions to pass; applies print the new ones.
  • Probes and management-path detection (2.3). probe (up to 5 of {kind: ping|resolve, target, server?}) runs after the reload, each retried for probe_wait seconds (default 15, max 60) and
    never longer than half the rollback window. A failed probe is reported and the change stays
    armed: the timer undoes it, as for a caller that lost its connection. The staging lock is
    released before probes run. A change to the LAN interface or its bridge, the SSH listener, or
    the firewall zone and rule that let SSH in is refused unless it names a probe or force=true,
    and its default rollback window is 180 s. The rule set is static: it does not know which
    interface the current session arrived on, because the stdio bridge's origin address does not
    reach the tool handlers (a session-aware rule is future work).
  • History and restore (2.4). Confirming a change keeps the config as it was before, per config,
    newest history_keep (default 5, 0 off, at most 20), under /etc/openwrt-mcp/history/ with
    0600 files. uci_get history=list and history=diff:<id> read them; uci_apply restore=<id>
    puts one back through the same snapshot, checks, rollback timer and probes. uci import writes
    the file at once (measured), so a restore replaces the file as pkg_config_resolve does, and
    runs the checkers on the installed file before anything reloads; its dry run shows the settings
    diff only. A rolled-back change leaves no entry. wg_new_client and wg_remove_client, which
    commit without a rollback, record the file just before they commit.
  • Polling instead of sleeping (2.5). service_control reads the state every 500 ms until it
    has been the same for three reads, or wait seconds (default 10, max 60) pass, and says which
    happened. It also notes a final state that contradicts the action (a service still running after
    stop).
  • Write-path parity (2.6). add_list skips an element already in the list: libuci appends a
    duplicate (measured). del_list of a missing element already succeeds as a no-op (measured), and
    a test pins it. set_list now refuses an empty element like add_list and del_list did, and
    one table test runs every option-writing op against the same hostile names and values.

Security

  • A probe has the router ping or resolve a name for the caller, which a grant on uci_apply never
    allowed, so each probe target is a policy scope of its own, probe.<kind>.<target>. Targets
    that could read as an option are refused. A restore replaces a whole config, so its scope is
    <config>, which <config>.* does not cover.
  • History entries hold whole config files, secrets included. They get the snapshots' protection
    (0700 directory, 0600 files, state-path guard), are masked when shown, and are in
    sysupgrade backups because keep.d keeps the whole state directory.

Changed

  • uci_apply's changes is optional in the schema (a restore has none).
  • wg_new_client and wg_remove_client take the calling client's name, for the history entry.

Not done

  • Out of scope: the wg_* tools still commit with no snapshot or rollback. History in RAM for
    low-flash boards (ROADMAP open question) is not built.

Install on the router (it picks the archive for the architecture and checks SHA256SUMS):

wget -O /tmp/install-router.sh https://github.com/stanislav-testhub/openwrt-mcp/releases/latest/download/install-router.sh
sh /tmp/install-router.sh

or from a PC with ./install.sh install --release. Verify where a file was built with
gh attestation verify <file> --repo stanislav-testhub/openwrt-mcp.

On your PC (Windows, macOS), openwrt-mcp connect --client <name> --host <router> makes the
SSH key and sets up your MCP client; unpack the windows_* or darwin_* archive for it.