Skip to content

refactor!: render torrc one way and preserve onion identities; 0.4.9.13:1 - #39

Draft
dr-bonez wants to merge 4 commits into
masterfrom
redesign/one-way-torrc
Draft

dr-bonez wants to merge 4 commits into
masterfrom
redesign/one-way-torrc

Conversation

@dr-bonez

@dr-bonez dr-bonez commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Closes #38, which records the decisions this implements and why. Answers #36 (decision 4 there).

What changes

  • torrc is rendered one way. Onion state moves to store.json on the startos volume. torrc is written from it below a marker line; everything above the marker is the user's and is preserved with trailing newlines normalized. A forward target is resolved from the live binding when the file is rendered and is never stored, so there is nothing to reconcile and no stale target left to inherit a port another service later claims. The reconcile pass, parked entries and the annotation parser are gone. The old two-way model is frozen at startos/versions/legacy/torrc.ts, read only by the historical migration and the current layout migration.
  • Relay and bridge mode are removed. The release notes explain why, in all five locales. A relay's identity under keys/ is left in place.
  • Nothing deletes a key or a mapping automatically. torrc and the exported URLs hold only what resolves right now. An address whose package was uninstalled, or whose host or port was retired, becomes unused and comes back if its target does, so restore order needs no special case. Delete Onion Addresses becomes Delete Unused Onion Addresses: every unused address, all selected by default, and the only thing that destroys a key. If a selected address has come into use by the time it runs, it deletes nothing and fails naming it. The interface-page delete detaches and keeps the key; Add Onion Service offers unused addresses from any host of the same package for explicit reuse; an address still in use stays on its existing host.
  • Tor's DataDirectory moves to data/, so Reset Tor Connection deletes that directory instead of walking an allow-list. The migration carries state and the caches over, so the update does not re-select anyone's entry nodes.
  • Automatic Recovery is a new action; the watchdog stays on by default, and off is fail closed. Its text states the trade-off for the user and nothing of the mechanism, which is in the README.
  • Backups cover both volumes. README and instructions.md rewritten; AGENTS.md cut from ten bullets to three.

down: IMPOSSIBLE: an older release would read the rendered torrc as its database and find no annotations in it.

Earlier implementation verification (historical)

x86_64 VM, StartOS 0.4.0.2, Tor updated from 0.4.9.11:5, so the whole migration chain ran. A VM snapshot was taken first.

  • The migration imported all three onions. Every .onion hostname is identical to before, and so is the guard set. The old file is set aside as torrc.legacy.
  • Two of those onions still targeted the *.startos container hostnames. Both now resolve from the live binding; qbittorrent's is 10.0.3.1:59231, its assigned port rather than the 8080 it advertises, which is Onion service entries are never reconciled against the binding they were derived from #17's drift.
  • The qbittorrent onion answered HTTP 200 over the real Tor network, from a Tor client on another machine.
  • All three URLs are exported with delete-onion-service as their remove action.
  • Automatic Recovery flips the action's name, description and confirmation.
  • A line added above the marker was picked up at once, Tor opened the extra SocksPort beside 9050, and the line survived a full container rebuild.
  • Reset Tor Connection deleted data/ at the next start (57 guards to 20), left the onions untouched, and health returned to success.
  • Delete Unused Onion Addresses lists nothing while all three addresses are in use.
  • Offline, against the package's Tor 0.4.9.12: the renderer is a fixed point on its own output, keeps a user section and discards an edit below the marker, and tor --verify-config accepts the result; the last DataDirectory wins while SocksPort and ControlSocket are additive; guards live only in state.

tsc, Prettier, ncc and make x86 pass.

Noticed, not caused by this

On that box the start-os/admin onion, an SSL one, fails its TLS handshake with unrecognized name, while the same onion and port with no SNI return HTTP 200. That is a StartOS regression on master, not this package: the vhost lookup became exact and plugin-exported hostnames were never given an entry. Fixed in Start9Labs/start-technologies#4044. Plaintext onions are unaffected.

Current-head follow-up — 0.4.9.13:1

Rebased on current master; this now targets 0.4.9.13:1.

  • Rendering and URL export share an enabled-binding/bridge-leg resolver. A URL is exported only for a leg Tor actually forwards. The watched projections exclude the plugin's own URL exports.
  • Add Onion Service includes entirely unused addresses from any host of the same package. Moving one is explicit, checks eligibility again at execution, and retains the original key directory through keyId; disabled bindings count as in use. No automatic cross-host migration is introduced.
  • Legacy import keeps valid keys even when the package/host is absent and recognizes already imported identities when retrying after an interrupted handoff. Lookup errors abort reuse/deletion rather than treating an address as unused.
  • The version migration retires or-multi, freeing its bindings. Custom domains assigned to that host must be reattached; release notes explain this in each locale.
  • Converted dependency declarations and generated entry-point plumbing to SDK 3. README/instructions match the behavior, including explicit service startup and newline normalization.
  • Pin the installed Alpine Tor package to the manifest's upstream version. A cached build otherwise shipped Tor 0.4.9.12 under a 0.4.9.13 package version. torVersion supplies both the build argument and the package version's upstream component; UPDATING.md documents the exact APK selector.

Verification for this follow-up

Using a locally packed SDK 3.0.0 from monorepo 3454c4f6f (no SDK/core-TS changes between that commit and d55cf47cf):

  • Typecheck, Prettier, ncc, and 7 regression tests pass.
  • make x86, make arm, and make riscv pass with start-cli 2.1.0. The pinned x86 image reports Tor 0.4.9.13.
  • On StartOS alpha 6c16910: fresh Tor/Bitcoin installs start successfully; new onion creation agrees with rendered config and URL export; detaching an RPC onion and explicitly reusing it on Peer retains its hostname and key hash, exports public port 8333, survives a Tor restart, and deletes the original key directory when subsequently deleted as unused.
  • Upgrading released Tor 0.4.9.13:0 while stopped imports the existing mapping, preserves its hostname/key hash, retains torrc.legacy, and retires a relay host configured while stopped.

The VM tests above preceded the final APK pin (the cached daemon there was 0.4.9.12); the final pinned image was checked with Docker, not re-exercised on that VM. Full restore/install-order permutations and the complete 0.3.5 migration path were not rerun in this follow-up.

Release prerequisites / outstanding runtime check

Remain draft. npm still publishes SDK 2.0.9. The committed SDK pin/lockfile remain unchanged; this branch's SDK-3 calls do not compile against that pin. Publish SDK 3, refresh the normal dependency pin/lockfile, then rerun ordinary clean-install CI. No local tarball dependency is committed.

A later running, same-version sideload remained updating after the client's 240-second timeout (Tor's process stayed alive). This is not established as a package, SDK, or alpha-runtime fault; stopped upgrade and restart tests passed. Hot-update behavior remains an explicit verification gap, not a green check.

🤖 Generated with Claude Code

@helix-a

helix-a commented Sep 22, 2026

Copy link
Copy Markdown
Member

Tested at 2bbbbdf (x86_64 build) together with Start9Labs/bitcoin-core-startos#309, which retires the peer port the 0.3.5 bitcoind left behind. Both runs started from a real StartOS 0.3.5.1 install with Bitcoin 29.3.1 configured and run. Each was updated over the air to 0.4.0.1 (legacy registry) and then to 0.4.0.2 a30d84d (beta registry). The OS migration carried all four onions over with their hostnames.

I drove the actions through package.action.get-input / run over startd's local RPC, passing the same urlPluginMetadata prefill the interface page sends. That covers two items this PR lists as not verified: Add Onion Service reusing an unused address, and Delete Unused listing a genuinely unused one.

Run A: tor 0.4.9.11:5 → this PR, with bitcoind already on the released native 31.1:17.

  • The migration imported bitcoind/peer/0 as {externalPort 8333, internalPort 8333}. Binding 8333 was enabled: false, so the address was left out of torrc and counted as in use. Delete Unused listed nothing, and Add Onion Service on Peer (58333) offered only Create new address.
  • After bitcoind 31.1:18 retired 8333, Delete Unused listed 44llhvb2…gwad.onion — bitcoind/peer, and Add Onion Service offered it.
  • Reuse stored {externalPort 8333, internalPort 58333} and shed the 8333 mapping. torrc got HiddenServicePort 8333 10.0.3.1:64916, which is 58333's assigned port. The URL was exported on the Peer interface with removeAction: delete-onion-service. bitcoind picked it up as externalip.
  • A Bitcoin P2P version handshake to 44llhvb2…gwad.onion:8333 through Tor's SOCKS port returned /Satoshi:31.1.0/. It did again after restarting both services, and the mapping survived.

Run B: released tor 0.4.9.12:5 parks it first, then this PR.

  • bitcoind went from 29.3.1:0 straight to 31.1:18 while the released Tor was running. The released Tor logged Parked onion services …: bitcoind/peer:8333 and kept the key.
  • This PR's migration imported the parked entry as unused, and Add Onion Service offered it. Reuse rendered HiddenServicePort 8333 10.0.3.1:8333, and the handshake to hbtyauwm…a6qd.onion:8333 returned /Satoshi:31.1.0/. Here 58333 had got external 8333, because the retirement freed it before the first native bind.

The untouched RPC and admin onions answered through Tor in both runs.

One behavior to be aware of, which I don't think is a defect in this PR: the first connection to the reattached address, about a minute after it was rendered, failed with resolve failed. Fetch status: No more HSDir available to query. Tor then answered from that cached failure for several minutes, and the next attempts after it expired connected at once. The same happened once right after restarting Tor. It is Tor's client-side HSDir-miss caching racing the new descriptor's upload, not a rendering problem.

Not exercised: submitting Delete Unused, submitting Delete Onion Service, a fresh install, a restore, and the aarch64/riscv64 builds.

@helix-a

helix-a commented Sep 23, 2026

Copy link
Copy Markdown
Member

Pushed b4add8a: access: 'public' on Add Onion Service and Delete Onion Service. A service calling either one may act only on its own hosts. The input-spec function and run both call requireOwner(caller, packageId), which throws when a non-null caller differs from the target's packageId. A null caller means the user.

This branch won't typecheck yet. It still pins start-sdk 2.0.9, which has neither access nor caller (start-technologies#4045, unreleased 3.0.0). With a 3.0.0 SDK built from master it typechecks and packs. It needs the 3.0 pin once that SDK publishes.

End to end on a VM (0.3.5.1, then OTA, then a StartOS build from start-technologies#4069):

  • Bitcoin 31.1:18 (bitcoin-core-startos#309) retires the 0.3.5 peer port 8333, which leaves its peer .onion unused.
  • On init, Bitcoin runs this action for its own bitcoind/peer/0 address with caller: "bitcoind", and the form and the run share one event id.
  • Tor's store now maps bitcoind/peer/0 to {externalPort: 8333, internalPort: 58333}, and torrc has HiddenServicePort 8333 10.0.3.1:8333.
  • A P2P version handshake to fddsvz4q…75yd.onion:8333 through Tor's SOCKS port answered /Satoshi:31.1.0/.

Details: Start9Labs/start-technologies#4069 (comment)

@helix-a helix-a left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not ready to release at this head.

Correctness finding: exportUrls checks only that the internal binding is enabled, while renderTorrc also requires getBridgeAddress({…, ssl}) to resolve. If a service keeps its host/port but changes from a two-leg binding to TLS-only, an existing plaintext onion mapping is omitted from torrc but still exported on the interface. The export watcher also sees no change (its projection is still the same enabled-port list), so it can retain that dead URL indefinitely. The old reconciler handled a missing SSL mode; the rework removes that path. I exercised the two actual handlers with an enabled binding and a null matching bridge address: no HiddenServicePort is rendered, but one URL is exported. Make export eligibility use the same matching bridge-leg condition, watched with .const(), as rendering; keep the projection narrow enough not to react to the plugin's own exports.

Release blockers:

  • This branch conflicts with master, which now ships Tor 0.4.9.13:0; 0.4.9.12:7 is behind it. Rebase and choose the next revision on the newer upstream line.
  • With a clean npm ci, both npm run check and npm run build fail on the three caller reads. SDK 3.0.0 is still unpublished (npm latest is 2.0.9).
  • A pin bump alone no longer suffices. I built and packed SDK 3.0.0 from current monorepo master 3454c4f6f, installed it in a throwaway archive of this head, and found additional errors: removed sdk.setupDependencies, the new third buildManifest argument, and the removed manifest dependencies field. The package needs the current SDK-3 migration as well.
  • CI is skipped because the PR is draft. Marking it ready alone will not trigger this package's current workflow; it needs a subsequent synchronize event or an explicit build dispatch.

Fleet-scope correction: I originally treated historical host-id changes as migration defects and proposed cross-host migration across the fleet. That conclusion did not establish the reason for those changes and is withdrawn. A deliberately replaced or split host need not inherit the old host's addresses; LND's REST/gRPC split does not need migration. Automatic repair remains appropriate for a confirmed port-renumbering defect such as Bitcoin's peer/8333 → peer/58333. Allowing an entirely unused address to be explicitly reused on another host of the same package is a separate capability, not a reason to move addresses automatically or a release blocker for this PR.

Two independent cleanup/preservation points:

  • The renamed-host importer still does if (!host) continue and then consumes the handoff file. A missing historical host must not prevent preserving its key and an unused store entry; otherwise no later action can recover it from the store. This is inherited from the old importer, not newly introduced by the rework.
  • The removed relay's or-multi host is only disabled, not retired. Now that this package needs SDK 3 anyway, retire it explicitly in the migration rather than reserving its ports permanently. Disclose that retirement's effect on user-added domains in the release notes.

Checks in this review: clean installs, typecheck and ncc build on the committed SDK pin (both fail); current-SDK-3 consumer typecheck in a scratch tree (fails as above); Prettier on changed TypeScript (passes); mocked-handler render/export comparison. I did not repeat the earlier VM runs or complete fresh-install/restore/delete-action testing.

dr-bonez and others added 3 commits September 30, 2026 18:13
…utomatically; 0.4.9.12:6 → 0.4.9.12:7

torrc was generated from structured state and then parsed back as the
source of truth, and most of the fixes since #19 were patches on that
choice. The decisions this implements are recorded in #38.

- Onion state moves to store.json on the startos volume. torrc is rendered
  from it one way, below a marker; the section above the marker is the
  user's and is carried over byte for byte. A forward target is resolved
  from the live binding at render time and never stored, so there is
  nothing to reconcile and no stale target to inherit another service's
  port. The reconcile pass, parked entries and the annotation parser go;
  the old two-way model is frozen at versions/legacy/torrc.ts for the
  migrations that read old volumes.
- Relay and bridge mode are removed. The release notes say why.
- Nothing deletes a key or a mapping automatically. torrc and the exported
  URLs hold only what resolves right now, an address whose target is gone
  becomes unused and returns if the target does, and restore order needs
  no special case. Delete Onion Addresses becomes Delete Unused Onion
  Addresses, the only thing that destroys a key; the interface-page delete
  detaches and keeps it.
- Tor's DataDirectory moves to data/, so a reset deletes that directory
  instead of walking an allow-list. The migration carries state and the
  caches over so the update does not re-select entry nodes.
- The recovery watchdog stays on by default and gains an Automatic
  Recovery action; off is fail closed.
- Backups cover both volumes.

down is IMPOSSIBLE: an older release would read the rendered torrc as its
database and find no annotations in it.

Helix-Harness: pi
Helix-Model: openai-codex/gpt-6.1-sol
StartOS keeps a disabled binding's assigned port and bridge address, so
getBridgeAddress still resolves for it. torrc kept forwarding the port to
nothing, the URL stayed exported, and the address counted as in use only
by that accident.

- renderTorrc skips a port whose binding is disabled, watching the flag
  with its own .const(): toggling it leaves the bridge address unchanged,
  so the target's watch would not re-run the render.
- exportUrls exports enabled bindings only, so the URLs match what Tor
  serves.
- isServed counts a disabled binding as in use explicitly. Its service
  still holds the port and can enable it again, so Delete Unused Onion
  Addresses does not offer the address.

Verified on the dev VM (StartOS 0.4.0.2): tsc passes, the three existing
onions still render, and Delete Unused lists nothing. Not exercised: an
onion on a disabled binding, since the VM has none and store.json is not
reachable from any subcontainer.

Helix-Harness: pi
Helix-Model: openai-codex/gpt-6.1-sol
Add Onion Service and Delete Onion Service become access: 'public', so a
service can run them through effects.action.run, for instance to move an
address back onto a port it renumbered. requireOwner refuses any caller
other than the user (null) or the service whose host the address is on;
Add also checks in its input form, which otherwise lists that host's
unused addresses. Delete Unused Onion Addresses, the only action that
destroys a key, stays user-only.

Needs start-sdk 3.0.0 for the action's caller (start-technologies#4045).

Helix-Harness: pi
Helix-Model: openai-codex/gpt-6.1-sol
@helix-a
helix-a force-pushed the redesign/one-way-torrc branch from b4add8a to c7c4c5c Compare September 30, 2026 19:16
helix-a added a commit to Start9Labs/bitcoin-core-startos that referenced this pull request Sep 30, 2026
…31.1:18

The 0.3.5 package bound container port 8333 on the peer host. Every
0.4-native version binds 58333 there instead, and setupInterfaces
disables what it no longer declares rather than deleting it, so a server
migrated from 0.3.5 kept the 8333 record: enabled false, holding external
port 8333, with Tor still forwarding the peer .onion to it.

up() retires it with retirePort(8333). Under Start9Labs/tor-startos#39 the
address becomes unused, key kept, and Add Onion Service on the Peer
interface offers it back for 58333 with the same hostname. On a server
that never carried the binding, retirePort resolves false and nothing
happens.

Needs start-sdk 3.0.0 and StartOS 0.4.0.2, neither published to npm yet.

Helix-Harness: pi
Helix-Model: openai-codex/gpt-6.1-sol
Helix-Harness: pi
Helix-Model: openai-codex/gpt-6.1-sol
@helix-a
helix-a force-pushed the redesign/one-way-torrc branch from c7c4c5c to fe3c2dc Compare September 30, 2026 19:23
@helix-a helix-a changed the title refactor!: render torrc one way, drop the relay, never delete a key automatically; 0.4.9.12:6 → 0.4.9.12:7 refactor!: render torrc one way and preserve onion identities; 0.4.9.13:1 Sep 30, 2026
@helix-a

helix-a commented Sep 30, 2026

Copy link
Copy Markdown
Member

Pushed the rebase and fixes in fe3c2dc: shared render/export eligibility, unused-address dropdown and explicit same-package cross-host reuse with stable keys, fail-closed lookup errors, absent-host import preservation/retry safety, relay-host retirement, SDK-3 API conversion, and an exact Tor APK pin. README/instructions/UPDATING are synced. Seven regression tests and all three architecture packs pass with the local SDK-3 build; live create/detach/reuse/restart/delete and a stopped released-version upgrade passed. The description now separates current verification from the earlier test record. Still draft pending published SDK-3 pins/locks; a running same-version sideload stalled on the alpha VM and remains an explicit, unattributed runtime gap.

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.

Redesign: render torrc one way, drop the relay, clean up onions on explicit signals

2 participants