Skip to content

feat(control): a remote control port, opened in addition to the local channel - #187

Merged
fylorn merged 3 commits into
mainfrom
feat/remote-control-listener
Sep 24, 2026
Merged

fylorn merged 3 commits into
mainfrom
feat/remote-control-listener

Conversation

@fylorn

@fylorn fylorn commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Phase P4 of the remote-core plan: a remote control port, listen.control.remote, for a desktop app on another machine. It is opened in addition to the local channel (unix socket, or loopback port on Windows) and uses the same key and Noise handshake as #184.

Configuration

listen:
  control:
    key: "…"
    remote:
      enabled: true
      bind: all                 # loopback / all / interface name / address, like the gateway; defaults to all
      port: 23483               # no default; picked at random when the section is written
      allow_from: [192.168.1.0/24]   # omitted = private ranges; [] = nobody
  • Validation: port 0 → config.remote_port_zero; the gateway's port → config.remote_port_is_gateway; a bad entry → config.bad_remote_allow_from.
  • twcore init writes the section with enabled: false and a random port from 20000–32000 (never the gateway port). tw_config::remote::{ensure_section, enable, disable} and their *_file versions make minimal YAML edits.

Runtime

  • tw_control::serve also runs remote::follow, which re-reads the section on every ConfigReloaded:

    • Enable, disable, bind and port changes apply live. A new address is bound before the old one is dropped, except when the new bind collides on the same port (all ↔ an interface address, EADDRINUSE on Linux). In that case the old listener is closed first and the new one bound; if that still fails, the old address is bound again and the reason is reported.
    • A bind failure keeps the old listener and records the reason (gw.listen.* codes, the same ones the gateway uses).
    • Closing or moving the port closes the connections made through it.
    • Nothing here can take down the local channel or the gateway.
  • After accept, before the handshake:

    • A source outside allow_from is closed without a byte. Loopback is not automatically allowed: this machine has the socket.
    • A source with 5 failed handshakes in 60 s is ignored for 60 s. A version mismatch does not count.
    • At most 32 remote connections are open at once.
    • Allow-list changes apply at once: every reload makes each open remote connection re-check allow_from, and a source that is no longer allowed is closed immediately, the same way a rotated key closes connections made with the old one.
  • Remote connections run HTTP/1.1 only, inside a task-local marker (tw_control::remote::is_remote()). That lets the core refuse, with 403:

    • POST /shutdown → control.remote.shutdown_refused
    • GET /diagnostics → control.remote.diagnostics_refused
    • any config write through ConfigManager (PUT/PATCH/rollback/resource edits) that changes listen.control → control.remote.control_section_locked

    The check is in ConfigManager::write, so every write path is covered. Local connections are unaffected.

API (CONTROL_API_VERSION 20)

Status gains:

pub remote_control: RemoteControlView,   // { enabled, addr: Option<String>, error: Option<Msg>, allow_from, reachable: Vec<String> }
pub gateway_reachable: Vec<String>,      // "ip:port" other machines can use for the gateway; empty when it only listens on loopback

reachable / gateway_reachable:

  • bound to one address → that address;
  • bound to all → every non-loopback interface address (IPv4 preferred);
  • bound to loopback → empty.

How the app should pick the gateway address in remote mode: use the host it dialled for the control port together with the gateway port from gateway_addr, because that host is already known to reach the server. Use gateway_reachable to check it and as a fallback. The core cannot see NAT, port forwarding or DNS names.

CLI

  • twcore remote [show] prints whether the remote control port is open, where to connect, and which sources are allowed.
  • twcore remote enable [--bind B] [--port N] [--allow CIDR]... writes the section if it is missing (random port) and turns it on; a running core opens the port within a second. twcore remote disable sets enabled: false and keeps the port and the allow list.
  • twcore control-key still prints only the key on stdout. The remote port and addresses go to stderr, so the user can copy everything while $(twcore control-key) still returns just the key.
  • twcore init mentions twcore remote enable.

Tests

crates/tw-control/tests/remote.rs runs over real TCP:

  • an allowed source gets in and /status reports the listener;
  • a denied source is closed without a byte, and an allow-list change takes effect without rebinding;
  • 5 wrong keys bench the source, while the local channel keeps working;
  • the three remote-only 403s, and the same actions succeed over the local channel;
  • live disable (the port closes and remote connections drop), port change, a taken port (the old listener is kept and the error reported), and a failure at start;
  • narrowing allow_from closes an open connection from the source it drops, while a change that still allows the source leaves the connection open;
  • moving the same port all → 127.0.0.1 → all live. macOS allows the overlapping bind outright, so the close-then-bind path runs on the Linux job.

Unit tests cover the allow list, throttling and reachable addresses, plus the tw-config section edits.

cargo fmt, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace and scripts/smoke.sh pass locally.

Docs: the configuration manual now checks listen.control.remote against RemoteListen (regenerated with UPDATE_CONFIG_DOCS=1), and docs/server.md / server.zh-CN.md follow twcore remote enable/disable/show and the control-key stdout/stderr output.

New message codes: config.remote_port_zero, config.remote_port_is_gateway, config.bad_remote_allow_from, control.remote.shutdown_refused, control.remote.diagnostics_refused, control.remote.control_section_locked.

Not included: an event when the remote listener changes. Clients read it from /status, which the app already re-reads on reconnect.

🤖 Generated with Claude Code

fylorn and others added 3 commits September 25, 2026 01:03
… channel

listen.control.remote { enabled, bind, port, allow_from } opens a TCP port for
a desktop app on another machine. It is separate from the unix socket /
Windows loopback channel and uses the same key and Noise handshake.

- The port has no default: twcore init writes the section closed with a random
  port (20000-32000, never the gateway's), and `twcore remote enable|disable`
  opens and closes it with minimal YAML edits. Validation rejects port 0, the
  gateway's port, and malformed allow_from entries (new config.* codes).
- The listener follows config reloads: enable, disable, bind and port changes
  apply live (a new address is bound before the old one is dropped; a failure
  keeps the old one and is reported). Closing or moving the port closes the
  connections made through it. It never takes down the local channel or the
  gateway.
- Before the handshake: sources outside allow_from are closed without a byte
  (loopback is not waved through), a source with 5 failed handshakes in a
  minute is ignored for a minute, and at most 32 remote connections are open.
- Remote connections speak HTTP/1.1 only and carry a task-local marker; they
  get 403 for POST /shutdown, the diagnostic bundle, and any config write that
  changes listen.control (control.remote.* codes).
- Status gains remote_control { enabled, addr, error, allow_from, reachable }
  and gateway_reachable; CONTROL_API_VERSION is 19.
- twcore control-key also prints (to stderr) where remote control listens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…port moves between overlapping addresses live

- Every config reload now makes each open remote connection re-check
  allow_from; a source the list no longer lets in is closed at once, the
  same way a rotated key closes connections made with the old one.
- Moving the remote port between overlapping addresses on the same port
  (all <-> an interface address) no longer fails with EADDRINUSE: when the
  new bind collides on the same port, the old listener is closed first and
  the new one bound; if that still fails, the old address is bound again and
  the reason is reported. Different ports still bind the new one first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ide follows the CLI

- The manual now checks listen.control.remote against RemoteListen instead of
  declaring it pending: port is required (written at random, 20000-32000, by
  twcore init and twcore remote enable), bind defaults to all, allow_from to
  the private ranges, enabled to false. Regenerated both languages.
- docs/server.md and its Chinese version use `twcore remote enable/disable`,
  show what `twcore control-key` prints on stdout and stderr, and say what a
  remote connection cannot do, how throttling works, and that narrowing
  allow_from or rotating the key closes open connections.
- CONTROL_API_VERSION is 20 on top of #186's 19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn force-pushed the feat/remote-control-listener branch from 0383897 to 5299477 Compare September 24, 2026 17:11
@fylorn
fylorn merged commit f8c5a9a into main Sep 24, 2026
4 checks passed
@fylorn
fylorn deleted the feat/remote-control-listener branch September 24, 2026 17:20
@fylorn fylorn mentioned this pull request Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant