Skip to content

[server] Guard hark serve, install it, retire the Python server - #17

Merged
drycode merged 4 commits into
mainfrom
dy/swift-server-guards
Aug 3, 2026
Merged

[server] Guard hark serve, install it, retire the Python server#17
drycode merged 4 commits into
mainfrom
dy/swift-server-guards

Conversation

@drycode

@drycode drycode commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

hark serve has been the running service on my Studio since partway through this branch, serving both machines against the same whisper backend. This makes it the shipped one and deletes src/hark/.

First: it was a faithful port, and I checked

Run side by side against the same whisper-server, same key, same audio:

swift python
/health {"status":"ok"} identical
valid WAV {"text":"Testing the Swift server, 123."} identical transcript
no key / wrong key 401 identical detail
wrong Content-Type 415 identical detail
empty body 400 identical detail
silence {"text":""} identical — RMS gate intact

The error strings match verbatim, so the client's error mapping works unchanged.

Then: three things it dropped

server.bind did nothing. NWListener(using: .tcp, on: port) accepts on every interface regardless of config, and the log printed the requested host — so the claim was false rather than merely absent:

log:    hark listening on 127.0.0.1:8914
lsof:   TCP *:8914 (LISTEN)
laptop: 200

A single-machine install was reachable from the whole network while saying it was on loopback.

No wildcard guard. The Python server has refused 0.0.0.0 since #6 — hark's response is pasted into whatever has focus, so an endpoint on every interface lets anyone who can route here choose what gets typed. That's a remote keystroke injector, not a data leak. Porting the server without it silently undid that fix.

No body cap. The body is parsed before routing, so before the key is checked — an unauthenticated request could make the server buffer whatever Content-Length it claimed. Now 16 MB → 413.

How the bind is enforced, and why not the obvious way

requiredLocalEndpoint restricts the listener but is stricter than a BSD bind(): a connection from the server's own machine to its own tailnet address is delivered locally, doesn't match the utun endpoint, and never completes. Measured — the Studio timed out reaching its own server while the laptop got 200. That breaks single-machine-on-a-tailnet, the common case.

So the address is enforced per connection, where the security question actually lives: refuse to serve anything that didn't arrive at the configured address. An attacker gets a handshake and nothing else. Loopback is always accepted — a client on this machine is the same trust boundary whichever address it dials.

Verified with bind = 127.0.0.1: loopback 200, another machine 000 with refused a connection logged.

The installer

install-server.sh builds swift/ into ~/Applications/Hark.app and points launchd at hark serve — the same bundle install-client.sh installs, so a single-machine setup gets one copy playing both roles. Plists are rendered in shell, so the server needs no Python at all. The shared secret is created by the server on first start (KeyFile.ensure).

Two things that run surfaced:

  • The install smoke test piped the binary into grep. hark with no arguments prints usage and exits 2 — correct — and under pipefail that fails the pipeline whatever grep says, so the check condemned a working binary.
  • --doctor probed the configured bind address and reported the service down while it was serving the laptop fine. It probes loopback now.

The deletion, and what was ported first

Gone: src/hark/, its six pytest modules, launchd/*.template, FastAPI/uvicorn/httpx, and the wheel packaging. pyproject.toml is now a test harness — nothing imports hark, and the suite drives the installers as subprocesses.

test_launchd_config_sync.py was the drift guard and its invariants were ported, not dropped: both plists render, no placeholder survives, ports match config, whisper never leaves loopback, the plist points at the installed bundle not the build tree, no WorkingDirectory, and a wildcard bind is refused before anything is written.

Two deliberate differences, each recorded as a test:

  • The server's plist carries no address, because hark serve reads config.toml directly. uvicorn needed --host baked in — precisely the two-copies-of-one-fact the drift guard existed to police.
  • bind = "" falls back to loopback rather than being refused. In Python that value reached a socket API where empty spells the wildcard; here it never does, and defaulting to the safe end beats refusing.

Verification

Live on my Studio: hark serve under launchd, loopback 200, laptop 200 over the tailnet, a real utterance transcribed end to end, and --doctor 6/6. The running service was confirmed unaffected before the deletion was committed.

66 pytest, 53 SwiftPM, shellcheck.

Note the client config on the server machine should be http://127.0.0.1:<port> — loopback needs no allowPlaintext and no ATS exception. Remote clients keep the private address.

drycode added 4 commits August 3, 2026 17:38
Three findings from running `hark serve` against the same whisper
backend as the Python server it replaces. The transcription path is a
faithful port — identical transcripts, identical status codes, identical
`detail` strings, and the silence gate intact — so these are the gaps.

server.bind DID NOTHING. NWListener(using: .tcp, on: port) listens on
every interface regardless of config, and the log printed the requested
host, so the claim was false rather than merely absent. Measured with
bind = "127.0.0.1":

  log:    hark listening on 127.0.0.1:8914
  lsof:   TCP *:8914 (LISTEN)
  laptop: 200

A single-machine install was reachable from the whole network while
saying it was on loopback. Now binds via requiredLocalEndpoint, and the
log reports the host it actually bound. Verified both ways: loopback is
refused from another machine, and a tailnet bind still serves it.

(NWListener takes requiredLocalEndpoint OR `on:`, not both — supplying
both is EINVAL at creation, which is how the first attempt failed.)

NO WILDCARD GUARD. The Python server has refused 0.0.0.0 since #6 —
hark's response is pasted into whatever has focus, so an endpoint on
every interface lets anyone who can route here choose what gets typed.
That is a remote keystroke injector, not a data leak. Porting the server
without the guard silently undid the fix; the vocabulary and the message
now mirror src/hark/plists.py.

NO BODY CAP. The body is parsed before routing, so before the key is
checked: an unauthenticated request could make the server buffer
whatever Content-Length it claimed. Capped at 16 MB — about 8 minutes of
16 kHz mono s16, far past any hold-to-talk utterance — and answered 413
rather than 400, since the request was understood and refused on size.

Solves: hark #2 — reopens and re-fixes #6 for the Swift server
Tests: 53 SwiftPM (5 new); verified live — wildcard refused, loopback unreachable from the laptop, tailnet bind still 200, 20 MB body 413, normal utterance unchanged
requiredLocalEndpoint restricts the listener, but more than a BSD bind()
does: a connection from the server's OWN machine to its own tailnet
address is delivered locally, its path does not match the utun endpoint,
and the handshake never completes. Measured — the Studio timed out
reaching its own server while the laptop got 200. That breaks the
single-machine-on-a-tailnet setup, which is the common one.

So the address is enforced where the security question actually lives:
refuse to SERVE a connection that did not arrive at the configured
address. An attacker gets a handshake and nothing else. Loopback is
always accepted — a client on this machine is the same trust boundary
whether it dials 127.0.0.1 or the machine's own address.

Verified with bind = 127.0.0.1: loopback 200, another machine on the
tailnet 000 with "refused a connection" logged.

KNOWN DIFFERENCE FROM THE PYTHON SERVER, and the reason this is not yet
the default: with bind set to a tailnet address, the server's own
machine still cannot reach it at that address — the connection fails
below this code, at the socket layer, where Python's IPv4 bind succeeds
and Network.framework's IPv6 wildcard does not. Use 127.0.0.1 in
client.json on the machine running the server. That is the better
configuration anyway: loopback needs no allowPlaintext and no ATS
exception.

Solves: hark #2 — the bind guard, without breaking local access
Tests: 53 SwiftPM; live checks for both the allow and the refuse path
install-server.sh now builds swift/ into ~/Applications/Hark.app and
points launchd at `hark serve`. One signed bundle, two roles — the same
artifact install-client.sh installs, so a single-machine setup ends up
with one copy playing both parts.

The plists are rendered here in shell rather than by `python -m
hark.plists`, so the server no longer needs a Python venv at all. The
wildcard-bind check is not re-implemented: `hark serve` refuses 0.0.0.0
at startup with an explanation, which is the enforcement point that
matters. Whisper's plist is rendered the same way and is otherwise
unchanged — whisper.cpp is still the ASR engine.

The shared secret is now created by the server on first start
(KeyFile.ensure) instead of by shelling into the venv, keeping one
implementation of how it is generated and persisted.

Two things this run surfaced:

The install smoke test piped the binary into grep. `hark` with no
arguments prints usage and exits 2 — correct — and under `set -o
pipefail` that fails the pipeline whatever grep says, so the check
condemned a working binary. Captured into a variable instead.

--doctor probed the CONFIGURED BIND address and reported the service as
down while it was serving the other machine perfectly. It now probes
loopback: the server accepts loopback by design, and on a tailnet bind
the server's own machine cannot reach itself at that address.

src/hark/ is deliberately still present. It is no longer what runs, and
deleting it is a separate step once this has been in use for a few days.

Verified: hark serve running under launchd, Studio 200 over loopback,
laptop 200 over the tailnet, and a real utterance transcribed end to end.

Solves: hark #2 — retiring the Python server, step 2 of 3
Tests: 123 pytest (installer doctor retargeted from venv to bundle), 53 SwiftPM, shellcheck
`hark serve` has been the running service since the previous commit,
against the same whisper backend, serving both machines. src/hark/ is
no longer what runs, so it goes.

Removed: src/hark/ (app, config, sanitize, whisper, audio, plists), the
six pytest modules that exercised it, launchd/*.plist.template, the
FastAPI/uvicorn/httpx dependencies, and the wheel packaging. pyproject
is now a test harness rather than a package: nothing imports hark, and
the suite drives install-server.sh and install-client.sh as
subprocesses, which is what a user actually runs.

WHAT WAS PORTED FIRST, rather than dropped with it:

test_launchd_config_sync.py was the drift guard — the plists are what
launchd runs, and a wrong one shows up only as a restart loop and a
spawn error in a log nobody is watching. Its invariants now apply to the
shell renderer: both plists render, no placeholder survives, ports match
config, whisper never leaves loopback, the plist points at the installed
bundle rather than the build tree, no WorkingDirectory, and a wildcard
bind is refused before anything is written.

Rendering moved into render_plists() above the source guard so the tests
can exercise it — the first attempt defined it below and every test got
"command not found".

TWO DELIBERATE DIFFERENCES, both recorded as tests:

The server's plist now carries no address at all, because `hark serve`
reads config.toml directly. uvicorn needed --host baked in, which is
precisely the two-copies-of-one-fact the drift guard existed to police.

`bind = ""` falls back to loopback instead of being refused. In Python
that value went straight to a socket API where empty spells the
wildcard; here it never reaches one, and defaulting to the safe end
beats refusing.

Verified before committing: the running service is untouched by the
deletion and still answers 200.

Solves: hark #2 — retiring the Python server, step 3 of 3
Tests: 66 pytest (27 for the server installer, drift guard included), 53 SwiftPM, shellcheck
@drycode
drycode merged commit bb88017 into main Aug 3, 2026
4 checks passed
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