Skip to content

feat(control): one control key and a Noise handshake on every control connection - #184

Merged
fylorn merged 2 commits into
mainfrom
feat/control-key-noise
Sep 24, 2026
Merged

fylorn merged 2 commits into
mainfrom
feat/control-key-noise

Conversation

@fylorn

@fylorn fylorn commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

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_off path.

Protocol

Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s (the snow crate, only X25519 / ChaChaPoly / BLAKE2s enabled). The PSK is the 32-byte listen.control.key from config.yaml; prologue ThinkWatch control channel 1.

app  → core   [u16 BE len][e, encrypted ClientHello { proto, app }]
core → app    [u16 BE len][e, ee, encrypted ServerHello { proto, core, accept }]
              or a single plaintext 0xFF (REJECT) and close — message 1 did not decrypt (wrong key)
then          [u16 BE len][ciphertext]…   ≤ 65535 bytes incl. the 16-byte tag; larger writes are split
  • proto is CONTROL_API_VERSION (bumped to 18). On a mismatch core answers accept: false and closes; the client gets VersionMismatch { ours, theirs, peer_version } with core's CalVer before any HTTP.
  • Handshake timeout 5 s on both sides. Plain HTTP (e.g. curl) gets the reject byte.
  • After the handshake SecureStream<S> is a plain AsyncRead + AsyncWrite; hyper HTTP/1.1 and the SSE event stream run over it unchanged.
  • The server reads the key per handshake from the config in effect. When a reload changes the key, connections made with the previous key are closed.

What changed

  • New crate 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).
  • tw-api: control::ControlKey (64-hex parse/format, redacted Debug), KEY_PATH, KEY_HEX_LEN, KEY_MASK, CONFIG_FILE, config_file(). Removed TOKEN_FILE, TOKEN_ENV, token_file.
  • tw-config: listen.control.key (ControlListen, with a marked spot for remote), validated — missing → config.control_key_missing, not 64 hex → config.control_key_invalid. generate_initial() carries a fresh key; generate_control_key(). New control_key module: 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::rollback puts the current key back.
  • twcore: serve writes the key before the control plane listens (new config, or one added field in an existing config). New twcore control-key [--rotate] and twcore call [-X METHOD] [-d JSON] [--out FILE] <path>, which replaces curl for scripts and debugging. check validates the way serve will (a missing key is noted, not an error). init uses the same generator.
  • tw-control: token.rs is gone; serve(state, at) takes no token; gate.rs does the handshake in hand_off for every transport. GET /config masks 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.sh and the Windows CI step use twcore call; they also check that plain HTTP is refused, a wrong key is refused, only the key lines are added to an old config, /config is masked, and --rotate takes effect.

Message codes: new config.control_key_missing, config.control_key_invalid, control.control_key_locked; removed control.unauthorized.

What the desktop app must change (P2)

  • Depend on tw-link (same tag as tw-api). Remove token.rs, the TW_CONTROL_TOKEN env on spawn, and every Authorization: Bearer header.
  • Local key: tw_link::read_key(&tw_api::control::config_file(&tw_api::data::dir())). KeyReadError::Io (NotFound) / Missing means 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 returned SecureStream. call::<E>() and the event stream are otherwise unchanged.
  • Map LinkError to UI states. After WrongKey or a dropped connection, re-read the key once (rotation closes old connections).
  • GET /config text now carries KEY_MASK where the key is; send it back unchanged. Add translations for the three new codes and drop control.unauthorized.
  • CONTROL_API_VERSION is 18; regenerate the TS types.

For P4 (remote listener)

  • Put remote in ControlListen (marked in tw-config/src/lib.rs); the key stays the same field.
  • Accept on the remote port and hand the stream to the same hand_off(stream, app, gate) in tw-control/src/lib.rs. allow_from rejection happens before it (close, no bytes; the client sees Closed); the 5 s handshake timeout is already in Acceptor.
  • Remote restrictions (/shutdown, listen.control writes, diagnostics) need to know which transport a request came from; hand_off is the place to add that per-connection marker.

Checks

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

🤖 Generated with Claude Code

fylorn and others added 2 commits September 24, 2026 23:32
… 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
fylorn merged commit 1fae554 into main Sep 24, 2026
4 checks passed
@fylorn
fylorn deleted the feat/control-key-noise branch September 24, 2026 15:51
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>
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>
@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