[server] Guard hark serve, install it, retire the Python server - #17
Merged
Conversation
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
This was referenced Aug 3, 2026
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.
hark servehas 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 deletessrc/hark/.First: it was a faithful port, and I checked
Run side by side against the same whisper-server, same key, same audio:
/health{"status":"ok"}{"text":"Testing the Swift server, 123."}detaildetaildetail{"text":""}The error strings match verbatim, so the client's error mapping works unchanged.
Then: three things it dropped
server.binddid 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: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.0since #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-Lengthit claimed. Now 16 MB →413.How the bind is enforced, and why not the obvious way
requiredLocalEndpointrestricts the listener but is stricter than a BSDbind(): 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 withrefused a connectionlogged.The installer
install-server.shbuildsswift/into~/Applications/Hark.appand points launchd athark serve— the same bundleinstall-client.shinstalls, 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:
grep.harkwith no arguments prints usage and exits 2 — correct — and underpipefailthat fails the pipeline whatever grep says, so the check condemned a working binary.--doctorprobed 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.tomlis now a test harness — nothing importshark, and the suite drives the installers as subprocesses.test_launchd_config_sync.pywas 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, noWorkingDirectory, and a wildcard bind is refused before anything is written.Two deliberate differences, each recorded as a test:
hark servereadsconfig.tomldirectly.uvicornneeded--hostbaked 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 serveunder launchd, loopback 200, laptop 200 over the tailnet, a real utterance transcribed end to end, and--doctor6/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 noallowPlaintextand no ATS exception. Remote clients keep the private address.