feat(control): a remote control port, opened in addition to the local channel - #187
Merged
Merged
Conversation
… 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
force-pushed
the
feat/remote-control-listener
branch
from
September 24, 2026 17:11
0383897 to
5299477
Compare
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
config.remote_port_zero; the gateway's port →config.remote_port_is_gateway; a bad entry →config.bad_remote_allow_from.twcore initwrites the section withenabled: falseand a random port from 20000–32000 (never the gateway port).tw_config::remote::{ensure_section, enable, disable}and their*_fileversions make minimal YAML edits.Runtime
tw_control::servealso runsremote::follow, which re-reads the section on everyConfigReloaded: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.gw.listen.*codes, the same ones the gateway uses).After accept, before the handshake:
allow_fromis closed without a byte. Loopback is not automatically allowed: this machine has the socket.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_refusedGET /diagnostics→control.remote.diagnostics_refusedConfigManager(PUT/PATCH/rollback/resource edits) that changeslisten.control→control.remote.control_section_lockedThe check is in
ConfigManager::write, so every write path is covered. Local connections are unaffected.API (CONTROL_API_VERSION 20)
Statusgains:reachable/gateway_reachable:all→ every non-loopback interface address (IPv4 preferred);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. Usegateway_reachableto 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 disablesetsenabled: falseand keeps the port and the allow list.twcore control-keystill 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 initmentionstwcore remote enable.Tests
crates/tw-control/tests/remote.rsruns over real TCP:/statusreports the listener;allow_fromcloses an open connection from the source it drops, while a change that still allows the source leaves the connection open;all→127.0.0.1→alllive. 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 --workspaceandscripts/smoke.shpass locally.Docs: the configuration manual now checks
listen.control.remoteagainstRemoteListen(regenerated withUPDATE_CONFIG_DOCS=1), anddocs/server.md/server.zh-CN.mdfollowtwcore remote enable/disable/showand thecontrol-keystdout/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