feat(control): one control key and a Noise handshake on every control connection - #184
Merged
Merged
Conversation
… connection Every control-plane connection (unix socket, Windows loopback port) now starts with a Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s handshake keyed by listen.control.key in config.yaml, before hyper serves HTTP over it. The bearer token (TW_CONTROL_TOKEN, control.token, the Authorization guard) is gone. - New crate tw-link (snow, tokio, serde, tw-api, tw-yaml): connect(), Acceptor (key read per handshake), SecureStream (u16 length + ciphertext frames), typed LinkError, read_key()/key_in_config() for the desktop app. Versions are exchanged in the handshake (CONTROL_API_VERSION, now 18); a mismatch is answered with accept=false, a wrong key with one plaintext reject byte. - tw-api::control::ControlKey (parse/format, redacted Debug), KEY_PATH, KEY_MASK, CONFIG_FILE. - tw-config: listen.control.key, validated (missing / not 64 hex is rejected); generate_initial() carries a fresh key; control_key module ensures, rotates, masks and unmasks the key with minimal YAML edits. History snapshots store the key masked; a rollback keeps the current key. - twcore serve writes the key before the control plane listens (only that field is added to an existing config); new `twcore control-key [--rotate]` and `twcore call` (curl cannot do the handshake); `check` tolerates a config serve will add the key to. - tw-control: GET /config masks the key; a control-plane write that changes or removes it is refused (control.control_key_locked); when the key changes, connections made with the old one are closed. - smoke.sh and the Windows CI step talk to the control plane through twcore call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…way defaults go Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fylorn
added a commit
that referenced
this pull request
Sep 24, 2026
The control key landed (#184), so `listen.control` is no longer pending: its table is checked against `ControlListen`, `listen` gains the `control` row, and the starting configuration shown in the manual has the key `init` and `serve` now write. `listen.control.remote` is still pending. A row that points at a pending section is left out of its parent's field comparison, so `remote` stays documented under `listen.control`; the moment `ControlListen` reads `remote`, the pending probe fails and asks for the real type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Sep 24, 2026
fylorn
added a commit
that referenced
this pull request
Sep 24, 2026
* feat(twcore): add `twcore upgrade`
A core running on its own (on a server) had no way to move to another
release short of downloading a file and copying it over the binary by
hand. `twcore upgrade` asks GitHub for the latest release, or the one
given with --version, downloads the build for this OS and architecture,
checks its .sha256, runs the new file once to see it reports the version
being installed, and renames it over the current executable. Every step
before the rename leaves the old binary untouched, and the rename is
atomic, so a failed or tampered download never leaves a broken file.
--version installs even an older release: a server has to match the
desktop app's version, which may be behind. --check only reports.
--restart restarts twcore.service when systemd is running it; without
it the command says how, since a restart cuts requests in flight.
The copy inside the desktop app is refused: the app updates it itself,
and the app and core have to come from one commit.
No new dependencies: reqwest and sha2 are already in the tree. Tests run
the whole flow against a local fake GitHub (checksum mismatch, a binary
reporting the wrong version, a missing pinned release), replace a
running binary under itself, and read release.yml to check the asset
names it publishes are the ones upgrade looks for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(release): Linux tarballs, a systemd unit and a one-line install script
Running core on a Linux server and managing it from the desktop app
needs a way to put it there. The release already published bare Linux
binaries (the desktop pipeline takes them); it now also publishes
twcore-<target>.tar.gz for x86_64 and aarch64, holding the binary with
its executable bit, the systemd unit and the licence, each with a
.sha256. The bare binaries and their names are unchanged.
packaging/systemd/twcore.service runs core as a dedicated user with its
data in /var/lib/thinkwatch (StateDirectory, 0700), reads ${VAR} values
from /etc/thinkwatch/env, restarts on failure, and is hardened so it can
write only its data directory (systemd-analyze security: 1.4). It keeps
AF_NETLINK so a `bind` naming an interface still resolves.
scripts/install.sh (POSIX sh) detects the architecture, downloads the
latest or a pinned release, verifies the SHA-256, checks the binary runs
here, installs it atomically into /usr/local/bin, creates the user, the
data directory and the environment file, installs the unit, and runs
`twcore init` when there is no configuration. It is safe to run again:
configuration, environment and data are never touched, and it never
starts or restarts the service itself.
docs/server.md (and zh-CN) walks through install, init, the three config
edits, systemd, `twcore control-key` and connecting the desktop app,
plus upgrading and uninstalling.
The unit and the script were run under systemd on Ubuntu 22.04
(aarch64): install, check, enable --now, live reload, reinstall while
running, and `twcore upgrade --restart`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: a configuration reference whose field tables are generated and checked
docs/config.md and docs/config.zh-CN.md describe every section and field
of config.yaml, how a change takes effect (the reload stages, the
rejected-config rule, history and rollback, the `twcore config` and
`check` commands) and the environment variables core reads.
The field tables and the built-in rule lists are rendered from
crates/tw-config/tests/manual/schema.rs, and the `manual` test fails when
the manual and the code disagree. The declaration is checked against the
code, not trusted:
- field names come from serde itself, through a probe deserializer that
records what a derived Deserialize asks for, so a field added to a
config type without a row in the manual fails with its name;
- a declared default is written into a minimal section and must parse to
the same thing as leaving the field out, and a required field must
fail without it;
- enum values are read from serde rather than copied;
- the YAML examples in the manuals are parsed as configuration.
`UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual` rewrites
the generated blocks and leaves the prose alone. CONTRIBUTING explains
the mechanism and lists the release assets.
listen.control.key and listen.control.remote are documented ahead of the
code (they land with the control key and remote access work). They are
declared as pending: rendered in the manual, and the test fails as soon
as the code reads those fields, so the declaration has to be switched to
the real types and their defaults get checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(config): compare the manual regardless of line endings
A Windows checkout can turn the manuals' line endings into CRLF, and the
generated blocks are rendered with LF.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(config): check listen.control against the code now that it exists
The control key landed (#184), so `listen.control` is no longer pending:
its table is checked against `ControlListen`, `listen` gains the
`control` row, and the starting configuration shown in the manual has
the key `init` and `serve` now write.
`listen.control.remote` is still pending. A row that points at a pending
section is left out of its parent's field comparison, so `remote` stays
documented under `listen.control`; the moment `ControlListen` reads
`remote`, the pending probe fails and asks for the real type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 P1 of the remote-core plan: one control key, and a Noise handshake in front of every control-plane connection. The remote TCP listener (P4) is not in this PR; the APIs are shaped so it drops into the same
hand_offpath.Protocol
Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s(thesnowcrate, only X25519 / ChaChaPoly / BLAKE2s enabled). The PSK is the 32-bytelisten.control.keyfromconfig.yaml; prologueThinkWatch control channel 1.protoisCONTROL_API_VERSION(bumped to 18). On a mismatch core answersaccept: falseand closes; the client getsVersionMismatch { ours, theirs, peer_version }with core's CalVer before any HTTP.SecureStream<S>is a plainAsyncRead + AsyncWrite; hyper HTTP/1.1 and the SSE event stream run over it unchanged.What changed
tw-link(deps: snow, tokio, serde/serde_json, thiserror, tw-api, tw-yaml):connect,connect_with_timeout,Acceptor,Accepted,SecureStream,LinkError(Unreachable / Closed / WrongKey / VersionMismatch / Timeout / Io),ClientHello/ServerHello,read_key(path)/key_in_config(text).control::ControlKey(64-hex parse/format, redacted Debug),KEY_PATH,KEY_HEX_LEN,KEY_MASK,CONFIG_FILE,config_file(). RemovedTOKEN_FILE,TOKEN_ENV,token_file.listen.control.key(ControlListen, with a marked spot forremote), validated — missing →config.control_key_missing, not 64 hex →config.control_key_invalid.generate_initial()carries a fresh key;generate_control_key(). Newcontrol_keymodule:ensure/ensure_file(adds only that field, minimal YAML edit),rotate/rotate_file,mask,unmask,raw_in. History snapshots store the key masked (the version is still hashed from the real text);history::rollbackputs the current key back.servewrites the key before the control plane listens (new config, or one added field in an existing config). Newtwcore control-key [--rotate]andtwcore call [-X METHOD] [-d JSON] [--out FILE] <path>, which replaces curl for scripts and debugging.checkvalidates the wayservewill (a missing key is noted, not an error).inituses the same generator.token.rsis gone;serve(state, at)takes no token;gate.rsdoes the handshake inhand_offfor every transport.GET /configmasks the key;PUT/PATCH/ rollback through the control plane keep it when the mask comes back and refuse any change or removal (403 control.control_key_locked). The diagnostic bundle and config-error excerpts mask it completely.scripts/smoke.shand the Windows CI step usetwcore call; they also check that plain HTTP is refused, a wrong key is refused, only the key lines are added to an old config,/configis masked, and--rotatetakes effect.Message codes: new
config.control_key_missing,config.control_key_invalid,control.control_key_locked; removedcontrol.unauthorized.What the desktop app must change (P2)
tw-link(same tag astw-api). Removetoken.rs, theTW_CONTROL_TOKENenv on spawn, and everyAuthorization: Bearerheader.tw_link::read_key(&tw_api::control::config_file(&tw_api::data::dir())).KeyReadError::Io(NotFound) /Missingmeans core is not ready yet; retry as for a missing socket.ControlClient::connect: after opening the unix socket or loopback TCP stream,tw_link::connect(stream, &key, app_version)and run hyper's HTTP/1.1 client on the returnedSecureStream.call::<E>()and the event stream are otherwise unchanged.LinkErrorto UI states. AfterWrongKeyor a dropped connection, re-read the key once (rotation closes old connections).GET /configtext now carriesKEY_MASKwhere the key is; send it back unchanged. Add translations for the three new codes and dropcontrol.unauthorized.CONTROL_API_VERSIONis 18; regenerate the TS types.For P4 (remote listener)
remoteinControlListen(marked intw-config/src/lib.rs); the key stays the same field.hand_off(stream, app, gate)intw-control/src/lib.rs.allow_fromrejection happens before it (close, no bytes; the client seesClosed); the 5 s handshake timeout is already inAcceptor./shutdown,listen.controlwrites, diagnostics) need to know which transport a request came from;hand_offis the place to add that per-connection marker.Checks
cargo fmt --all,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace, andscripts/smoke.shpass locally (macOS).🤖 Generated with Claude Code