diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e1a7e4..256a408 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,17 +3,20 @@ name: Build on: workflow_dispatch: pull_request: - paths-ignore: ['*.md'] + types: [opened, synchronize, reopened, ready_for_review] branches: ['master'] + paths-ignore: ['*.md'] + +permissions: {} concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + group: package-build-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: build: - if: github.event.pull_request.draft == false + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + permissions: + contents: read uses: Start9Labs/start-technologies/.github/workflows/build.yml@master - # with: - # FREE_DISK_SPACE: true # No DEV_KEY — a PR build doesn't publish, so it doesn't need the signing key. diff --git a/.github/workflows/pr-retarget.yml b/.github/workflows/pr-retarget.yml new file mode 100644 index 0000000..388d957 --- /dev/null +++ b/.github/workflows/pr-retarget.yml @@ -0,0 +1,18 @@ +name: Retarget Build + +on: + pull_request: + types: [edited] + +permissions: {} + +concurrency: + group: package-build-${{ github.event.changes.base && github.event.pull_request.number || format('metadata-{0}', github.event.pull_request.number) }} + cancel-in-progress: true + +jobs: + build: + if: github.event.changes.base && github.event.pull_request.draft == false + permissions: + contents: read + uses: Start9Labs/start-technologies/.github/workflows/build.yml@master diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4680564..6dcef4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,6 @@ jobs: release: uses: Start9Labs/start-technologies/.github/workflows/release.yml@master with: - # FREE_DISK_SPACE: true RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }} secrets: diff --git a/.github/workflows/syncNext.yml b/.github/workflows/syncNext.yml index d4a7ea0..14a28b8 100644 --- a/.github/workflows/syncNext.yml +++ b/.github/workflows/syncNext.yml @@ -3,6 +3,14 @@ name: Sync next # Carries every change that lands on the base branch onto the paired `next` # iteration branch, so `next` never drifts behind what has already shipped. # `next` is created on the first run if the repo does not have one yet. +# +# List every base branch this package maintains. Most packages have one; the +# multi-branch packages list each major line or flavor (e.g. 28.x, 29.x). A +# package whose default branch is `main` must say `main` here — a workflow +# pointed at a branch the repo does not use never runs. +# +# No paths-ignore: a docs-only commit on the base still has to reach `next`, +# or the next merge back re-introduces the stale copy. on: push: branches: ['master'] diff --git a/.github/workflows/tagAndRelease.yml b/.github/workflows/tagAndRelease.yml index 9d9d4f8..f000851 100644 --- a/.github/workflows/tagAndRelease.yml +++ b/.github/workflows/tagAndRelease.yml @@ -14,7 +14,6 @@ jobs: uses: Start9Labs/start-technologies/.github/workflows/tagAndRelease.yml@master with: REFERENCE_REGISTRY: ${{ vars.REFERENCE_REGISTRY }} - # FREE_DISK_SPACE: true RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }} secrets: diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1432f56 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +"@start9labs/start-sdk/prettier.config.json" diff --git a/AGENTS.md b/AGENTS.md index f275da5..d6e6a0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,21 +18,23 @@ Freshly scaffolded? Work the guide page, not a file in this repo — read it, don't copy it in. Keep `README.md` (technical reference for an AI support or administering agent) and -`instructions.md` (end-user docs) in sync with your changes. +`instructions.md` (end-user docs) in sync with your changes. This file restates neither: +whoever changes the package has both, so it carries only what they don't — repo mechanics, +a change that looks right and is not, where the next thing gets added, a naming trap, a +build or test invocation particular to this repo. + +**Fix a defect you spot rather than reporting it** — you have the package open and the +context to be sure. File **a GitHub issue on this repo** only when the call isn't yours to +make: you can't pin the cause down, two defensible fixes exist, or it's too large to ride on +the work in hand. An open issue is a report, not a queue — implement one when you're asked +to or when it's labelled `Approved`, then close it with `Closes #`. -**Bugs and feature requests are GitHub issues on this repo** — file them as you find them. Don't record work in the repo instead: no `TODO.md`, no `NOTES.md`, no `PLAN.md`. What you verified, tried, and decided belongs in the commit message and the PR body. ## This repo -- **`socksHostId` and `socksPort` in `startos/utils/` are a published contract.** Sixteen packaging repos across both registries import them from `tor-startos/startos/utils`, and nothing in this repo references them — so renaming either, or moving them off that module path, breaks every dependent with no signal here. `utils/` resolves through its `index.ts`, which makes the directory name load-bearing too. -- **The wipe must happen in `main` before any daemon is constructed.** A running Tor holds its network state in memory and flushes it on shutdown, so deleting the files underneath it writes the same entry nodes straight back. That is why the wipe is queued to a file and applied at the next start. -- **`PRESERVE` is an allow-list on purpose.** A wipe that misses a cache file leaves the bad entry node in place — the exact failure being recovered from. Anything new the package persists on the `tor` volume must be added to it. -- **The watchdog's flags are files whose presence is the value — don't turn them into a file model.** The Reset Tor Connection action and the health check write them from different processes, and a `FileHelper.merge` is an unlocked read-modify-write: one writer loses, and a torn JSON file stops the service starting. -- **The watchdog wipes at most once per outage.** Past that the cause is not stale state, and retrying just restarts the service in a loop; the check reports the failure instead. -- **Any bootstrap-percentage movement resets the stall clock but not the attempt ladder.** Only a healthy reading resets the ladder — otherwise a Tor that crawls forward a percent at a time never escalates. -- **The `# @service` / `# @ssl` / `# @internalPort` comments in `torrc` are structural.** There is no round-trippable torrc format, so the parser reconstructs package id, host id, and upstream port from them. Stripping them loses that mapping. -- **Onion-service indexes are never reused after a deletion.** The index is a `HiddenServiceDir` path holding key material; reusing one would put a new service on a stale key directory. An entry with no ports is not written, which frees its index the same way — park a port with a null target rather than removing it. -- **Prune only on a confirmed-gone package — a missing host proves nothing.** `sdk.host.get` returning null means the host is gone _or not bound yet_: a batch restore writes every package's entry before any of them inits, and the host appears only when that package's own init binds it. So a null host with the package still present keeps its entry and keys, unexported, until the host watch fires; a thrown lookup means unknown and keeps them too. Read the package's status with `.once()`, never `.const()` — a status watch re-fires on every health tick of the target. And map the host to a boolean before `.const()` — subscribing to the whole host re-fires on the export phase's own writes and spins the pass indefinitely. -- **Reconcile onion targets ahead of `reloadTorrc`.** Both are init handlers and `setupInit` runs them in order, so the repair reaches Tor in the first pass rather than the next one. Every later repair reaches it through `reloadTorrc`'s own `torrc` watch, because a `.const()` retry re-runs only the handler that registered it. `getBridgeAddress` subscribes to the whole host like anything else; what keeps it from spinning on the export phase's writes is that it yields a single address string, which the watcher deduplicates. +- **`startos/utils/index.ts` (`socksHostId`, `socksPort`) and `startos/utils/reattach.ts` (`setupOnionReattachment`) are a published contract.** Other packaging repos import them by those paths, and nothing in this repo references them, so a rename or move breaks every dependent with no signal here. `reattach.ts` imports `@start9labs/start-sdk` alone: anything it imports from this repo lands in every consumer's bundle. +- **Anything the package persists on the `tor` volume goes outside `data/`.** That directory is Tor's `DataDirectory` and Reset Tor Connection deletes it whole; there is no allow-list to add a new file to. +- **`startos/versions/legacy/torrc.ts` is frozen.** It is the two-way `torrc` model earlier releases used, relay parsing included, and the historical and current layout migrations read old volumes through it. Nothing else may import it, and tidying it changes what those migrations see. +- **`requireOwner` is the only thing keeping one service off another's onion addresses.** Add Onion Service and Delete Onion Service are `access: 'public'`, so every installed service can run them; any new action or input form that touches an address by `urlPluginMetadata.packageId` calls it with the action's `caller`. diff --git a/Dockerfile b/Dockerfile index 02908a4..b957a23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ FROM alpine:3.23 -RUN apk add --no-cache tor && sed -i 's|^\(tor:.*\):/sbin/nologin$|\1:/bin/sh|' /etc/passwd +ARG TOR_VERSION +RUN apk add --no-cache "tor=${TOR_VERSION}-r0" && sed -i 's|^\(tor:.*\):/sbin/nologin$|\1:/bin/sh|' /etc/passwd USER tor ENTRYPOINT ["tor"] CMD ["-f", "/etc/tor/torrc"] diff --git a/README.md b/README.md index f4a2b1f..488dc62 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ > upstream documentation is accurate and fully applicable — see the > Documentation section of `instructions.md` for links. -[Tor](https://gitlab.torproject.org/tpo/core/tor/) is the anonymity network daemon. On StartOS it is infrastructure rather than an app: it gives every other service a SOCKS proxy for outbound traffic and `.onion` addresses for inbound, hands those addresses to StartOS through a plugin, and recovers itself when it wedges on a bad entry node. +[Tor](https://gitlab.torproject.org/tpo/core/tor/) is the anonymity network daemon. On StartOS it is infrastructure rather than an app: it gives every other service a SOCKS proxy for outbound traffic and `.onion` addresses for inbound, and hands those addresses to StartOS through a plugin. - **Upstream repo:** - **Wrapper repo:** @@ -35,7 +35,7 @@ ## Image and Container Runtime -A minimal Alpine build around the distribution's `tor` package — no upstream image exists to use. +A minimal Alpine build around the distribution's `tor` package — no upstream image exists to use. The manifest's `torVersion` supplies an exact APK version to the Docker build; `startos/versions/current.ts` repeats it as the package version's upstream component. | Property | Value | | ------------- | --------------------------------------- | @@ -48,187 +48,167 @@ A minimal Alpine build around the distribution's `tor` package — no upstream i | `tor-sub` | The `tor` daemon — `start-cli package attach tor` lands in it | | `chown-tmp` | Temporary; re-owns hidden-service directories after a config change | -**The daemon is pointed at a torrc on the volume, not the image's `/etc/tor/torrc`**, because the package generates that file and Tor has to read the generated one. - One oneshot, `chown`, runs first: Tor runs as the `tor` user and refuses a data directory that is not mode 700 and owned by it, while StartOS creates volumes root-owned. -riscv64 is unusual in the fleet and deliberate here: Tor is infrastructure other packages depend on, so it should be available wherever StartOS runs. +riscv64 is deliberate: Tor is infrastructure other packages depend on, so it should be available wherever StartOS runs. ## Volume and Data Layout -Two volumes, and only one of them enters the container. +| Volume | Mount Point | Purpose | +| --------- | -------------- | ----------------------------------------------------------- | +| `tor` | `/var/lib/tor` | `torrc`, the onion keys, Tor's own data, the control socket | +| `startos` | — (host side) | `store.json`; a one-time onion import file. Never mounted | -| Volume | Mount Point | Purpose | -| --------- | -------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `tor` | `/var/lib/tor` | `torrc`, the hidden-service keys, the relay identity, Tor's network caches, the control socket, and the watchdog's state | -| `startos` | — (host side) | A one-time onion-address import file; never mounted into a container | +Inside the `tor` volume: -**`hidden_services/` is the irreplaceable part.** Each subdirectory holds an ed25519 secret key, and that key _is_ the `.onion` address — lose it and the address is gone for good, with no way to regenerate it. +| Path | What it is | +| -------------------------------- | ------------------------------------------------------------------------------------------------- | +| `torrc` | Tor's config. The top is the user's; the rest is generated — see [File Models](#file-models) | +| `hidden_services/` | One directory per `.onion` address, `//hs_/`. **Irreplaceable** | +| `data/` | Tor's `DataDirectory`: the `state` file that pins its entry nodes, and its caches. **Disposable** | +| `control.sock` | Tor's control socket, which the health check and the reload use | +| `.wipe-requested`, `.auto-wiped` | The watchdog's flags. Present means set | +| `keys/` | A relay's identity, on a server that ran one under an earlier release. Unused, and left in place | +| `torrc.legacy` | The `torrc` an earlier release wrote, set aside during migration. A record only | -Everything else under `/var/lib/tor` divides into things the package generates (`torrc`), the relay's long-term identity under `keys/`, and Tor's cached view of the network. That last group is what the recovery path deletes. +**Each `hidden_services/` key _is_ its `.onion` address** — lose it and the address is gone for good. Everything Tor can rebuild lives under `data/`, which is what makes a reset a matter of deleting that one directory. ## File Models -One model, and it is not a config format any parser handles. - -| File | Volume | Format | Modelled | Written by | -| ------- | ------ | ---------- | ---------------------------------------------------- | ----------------------------- | -| `torrc` | `tor` | Tor config | Yes — `FileHelper` with custom serializer and parser | Every init, and three actions | +| File | Volume | Format | Modelled | Written by | +| ------------ | --------- | ------ | ----------------------------- | --------------------------------- | +| `store.json` | `startos` | JSON | Yes — `FileHelper.json` | The actions, the URL plugin, init | +| `torrc` | `tor` | Text | No — written, never read back | `init/renderTorrc` | -**`torrc` is generated wholesale from structured data, then parsed back out of the same file.** There is no round-trippable torrc format, so the serializer embeds `# @service`, `# @ssl`, and `# @internalPort` comment annotations, and the parser is a state machine that reconstructs the structure from them. Two consequences worth knowing: +**`store.json` is the source of truth.** It holds `automaticRecovery`, and `onions`: a record keyed `//`, each entry carrying its `ports` (`externalPort`, `internalPort`, `ssl`). A moved address also carries `keyId`, the original key directory's identity; moving the mapping leaves the key files in place. It does not hold a forward target, and nothing in it is ever removed automatically. -- **A hand edit does not survive.** The next write regenerates the file from the parsed structure, and anything the parser does not understand is dropped. -- **Those comments are load-bearing.** Stripping them loses the package id, host id, and upstream port behind each onion service. -- **The relay's advertised address and port are derived, not configured.** An init handler re-asserts them from the OR binding on every init and whenever the binding's Public addresses or assigned port change: an `Address` line when exactly one gateway has the Public IPv4 address enabled, an `ORPort NoListen` / `ORPort NoAdvertise` pair when StartOS assigned a different external port, and `IPv4Only` on the ORPort while no public IPv6 address is enabled. **Configure Relay** never sets them, and changing the OR port drops them until the handler derives them again. +**`torrc` is rendered from it, one way.** A line beginning `# ===== Everything below this line is generated` splits the file: -The file always carries the SOCKS port, the data directory, and the control socket. `SocksPort 0.0.0.0:9050` binds every interface of the _container_, which has only loopback and the LXC bridge, so it is not a LAN exposure. Tor cannot know that and warns on every start, `You specified a public address '0.0.0.0:9050' for SocksPort`; the warning is expected. Beyond that it holds the onion services — keyed by package, host, and an index that is **never reused after a deletion**, because the index is a directory path containing key material — and the relay settings when a relay is enabled. A port whose interface has no bridge-reachable address is written as a commented-out `HiddenServicePort`, and the `HiddenServiceDir` line is commented out too once every port of the entry is, so Tor neither forwards nor publishes the address while the entry and its key stay on record; the annotations above them are what let it come back when the binding returns. +- **Above the marker is the user's.** Its contents are preserved, with trailing newlines normalized, and Tor honors it. A file with no marker is treated as all user section. +- **Below the marker is generated** and replaced on every render: `SocksPort`, `DataDirectory`, `ControlSocket`, then a `HiddenServiceDir` block per address. To change it, change the store — through a service's interface page or Tor's actions. +- Tor takes the **last** value of a single-valued option, so the generated `DataDirectory` wins over one written above the marker. List options such as `SocksPort` are additive, so a user can add a listener but not displace the package's. -The watchdog's state — whether a wipe is queued, and whether it has already wiped during the current outage — is two flag files beside the torrc, `.wipe-requested` and `.auto-wiped`. Present means set; there is nothing inside them to parse, and creating or removing one is atomic, so the health check and the Reset Tor Connection action can never tear or overwrite each other's write. +**An onion's forward target is resolved when the file is rendered**, from the live binding, and the render re-runs whenever a target changes. A port with no bridge address is left out, and an address with no port left to forward to is not written at all. Nothing stale is ever kept to point at a port another service might later hold. ## Dependencies -None, and by design. Tor sits underneath other services rather than beside them — sixteen packages across both registries import its host id and port to reach the SOCKS proxy. +None, and by design. Tor sits underneath other services rather than beside them. Dependents import `socksHostId` and `socksPort` from `tor-startos/startos/utils` to reach the proxy. ## Network Access and Interfaces -The SOCKS proxy is a binding with **no exported interface**, and that is deliberate: an unexported binding never reaches the LAN, so it lands only on loopback and the LXC bridge. Dependents get a stable bridge address and nothing to watch. +The SOCKS proxy is a binding with **no exported interface**: an unexported binding lands only on loopback and the LXC bridge, never the LAN. `SocksPort 0.0.0.0:9050` binds every interface of the _container_, which has only those two, so Tor's start-up warning about a public address is expected. | Binding | Host | Port | Exported? | | ------------ | ------- | ---- | ----------------------------- | | SOCKS5 proxy | `socks` | 9050 | No — bridge and loopback only | -| Interface | Id | Type | Port | Present when | -| ----------------- | ---- | ---- | ---------------------- | ------------------ | -| Tor Relay OR Port | `or` | p2p | The configured OR port | A relay is enabled | - -**The relay port is the exception to everything else here**: it is exported precisely so it can be reached from the public internet, which is what running a relay means. Its binding is `secure: { ssl: false }` — the OR protocol carries its own TLS, so StartOS treats it like any self-securing p2p port: LAN and `.local` addresses serve as soon as the interface exists, while each gateway's **Public** address is offered but stays off until the user enables it. - -Public reachability needs the inbound path and the advertised address to agree. Enabling the Public address on a gateway opens the inbound path there: StartTunnel publishes the port automatically, and on a home connection StartOS asks the router for the forward (a router that refuses needs a manual one). The package keeps the advertised side in step with that — see [File Models](#file-models): with the Public IPv4 address enabled on exactly one gateway, `torrc` pins `Address` to it, and when another service already held the OR port's external slot, the relay advertises the port StartOS actually assigned. With Public enabled on more than one gateway nothing is pinned, and Tor advertises the address the directory authorities see on its **outbound** connections, which then has to be one of those gateways. A persistent `has not managed to confirm reachability for its ORPort(s)` warning, which the Relay Reachability health check surfaces in the UI, means the inbound path is missing or is on a gateway the relay does not advertise; it is not a bootstrap problem, and **Reset Tor Connection** will not fix it. The Public enable is stored against the exact address and port, so changing the OR port or the gateway's public IP turns it off until it is re-enabled. - -**The package gives the relay no IPv6 address.** A bare `ORPort` is an IPv6 ORPort as well, and Tor cannot see the server's addresses from inside its container, so it would log `Unable to find IPv6 address for ORPort` once an hour; while no public IPv6 address is enabled on the interface the package writes `IPv4Only` and the notice never appears. With a public IPv6 address enabled the ORPort is left dual-stack and the address is left to Tor's own discovery, so unless Tor finds one the notice returns and the relay publishes over IPv4 alone — it is a notice, not a fault. The package deliberately pins no IPv6 address: Tor omits an IPv6 address it discovered itself when that address fails the self-test, but refuses to publish any descriptor while a _configured_ one fails it, which would take a working IPv4 relay off the network. +The package exports no interface of its own. ### The URL plugin -Tor registers itself as StartOS's `url-v0` plugin provider, which is how `.onion` addresses reach the rest of the system. On every init it exports the current set of onion URLs back to the packages they belong to, and in the same pass it prunes entries whose target package no longer exists — deleting the key material with them, since the address can never be reattached to anything. An entry whose host is missing while its package is still installed is kept and not exported: a batch restore writes every package's entry before any of them inits, and a host exists only once its package's own init binds it, which is what triggers the export. An entry the plugin cannot export — its host missing, or its binding gone from a host that still exists — is parked rather than served, and no interface page shows it; **Delete Onion Addresses** is the one place it can still be removed. +Tor registers as StartOS's `url-v0` plugin provider. On every init, and whenever the store or a watched host changes, it exports each address to the interface it serves. + +**A key is never deleted automatically, and neither is a mapping.** What gets cleaned up is what Tor listens on: `torrc` and the exported URLs only ever hold ports whose binding is enabled and resolves right now. -That pruning only fires on a package StartOS confirms is gone. A lookup that throws leaves the entry and its keys alone, because "I could not resolve this" is not "this no longer exists." +| What happened | What Tor does | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The service's ports moved | Follows them — the target is resolved at render time | +| The binding was **disabled** | Stops serving and exporting it. The address is not unused: the port stays reserved to the service, so the address returns when the binding is enabled again, and Delete Unused Onion Addresses does not offer it | +| The port or host was **retired** | Stops serving and exporting it. The address becomes unused | +| The package was **uninstalled** | Stops serving and exporting it. The address becomes unused, and comes back by itself if the package is installed again | +| A restore has not reached it yet | Nothing to do — the address is served as soon as its host is bound, in whatever order packages are restored | +| A lookup threw | Skips it for this pass | -**An onion's forward target is re-derived on every start.** The `HiddenServicePort` target stored in `torrc` is the external port the binding held when the entry was written, and a binding's ports move — a package gaining `addSsl`, an OS upgrade reassigning them — after which Tor forwards to a port nothing owns and the address is refused at the SOCKS layer. An init handler resolves each entry's bridge address afresh and rewrites the ones that have drifted, which repairs the address without touching its key. It is a `.const()` watcher, so it also fires the moment a binding moves rather than waiting for the next start. An entry whose interface stopped serving the mode it was created with follows the mode it does serve instead — a plaintext onion on a binding that has become TLS-only is re-pointed at the TLS address and re-annotated — so the address keeps answering on the port it advertises. An entry with no bridge-reachable address at all is parked: its directives are commented out, so the address stops being served and published rather than forwarding to a port some other service may now hold, and it resumes unchanged when the binding is back. +**An unused address** is one none of whose ports belongs to a binding its service still holds: no port is on a disabled binding, and none resolves to a bridge address. It is not written to `torrc` and shows on no interface page. It leaves two ways: Add Onion Service offers it for any interface of the same package, even on another host, which is how an address survives a service renumbering a port; and Delete Unused Onion Addresses destroys its key. ## Installation and First-Run Flow -Nothing to configure and nothing to reveal. Install writes a torrc, starts Tor, and the SOCKS proxy is available to other services as soon as the bootstrap completes. There is no task, no account, and no credential. +Nothing to configure. Install seeds `store.json` and renders a `torrc`. Start the service; the SOCKS proxy is available once the bootstrap completes. There is no task, no account, and no credential. -The first start takes longer than later ones — Tor downloads a consensus and builds its first circuits, which is what the bootstrap percentage in the health check is reporting. +**You do not add onion services by hand.** They arrive through the URL plugin when a service is given a Tor address on its interface page. -**You do not add onion services by hand.** They arrive through the URL plugin when another service asks StartOS for a Tor address, which is why the add and per-address delete actions are hidden. +**An install carrying onion addresses from StartOS 0.3.5 imports them once.** If `onion-migration.json` is present on the `startos` volume, init derives each address from its key, writes the key material into place, and renames the file. Keys that are not properly clamped are skipped. Valid keys are kept even when their package or host is absent, with an empty port list; retrying an interrupted import recognizes already imported addresses. -**An install carrying onion addresses from an older StartOS imports them once.** If a migration file is present, init derives each address from its key, writes the key material into place, and renames the file so it never runs twice. Keys that are not properly clamped are skipped rather than imported broken. +**Updating from a release that used `torrc` as its database** runs a migration that reads the onions out of the old `torrc` into `store.json`, sets that file aside as `torrc.legacy`, and moves Tor's state and caches under `data/` so the update does not re-select the server's entry nodes. Relay settings are dropped and the old relay host is retired, freeing its bindings. The downgrade is prohibited. ## Actions -Five actions: two hidden ones the plugin drives, and three for you. - ### Add Onion Service / Delete Onion Service (hidden) -Not user-facing. These are the plugin's table actions — StartOS invokes them when a service is given or loses a Tor address, and they are what write and remove the key material. +The plugin's table actions — StartOS invokes them from an interface page. -- **Deleting is permanent.** The secret key is removed with the entry, so the `.onion` address can never be recovered or reassigned. +- **Add** attaches an address to the interface's binding: a new one, optionally from a supplied key, or an existing address of the same package. An address in use stays on its host; an entirely unused one can be explicitly moved to another host within that package, retaining its hostname and original key directory. Attaching to an existing address sheds any mapping of its whose binding is gone. +- **Delete** detaches: it removes that port's mapping and nothing else. The key stays, and an address left with no port becomes unused. +- **Services call them too.** Both are `access: 'public'`, so any installed service can run them through `effects.action.run` to manage its own addresses — for instance, to move an address back onto a port it renumbered. Each checks the action's `caller` against `urlPluginMetadata.packageId` and refuses a service acting on another's host; the user, whose `caller` is `null`, may act on any. Add runs the check in its input form too, so a service cannot list another's unused addresses either. The input is the one the interface page sends: `urlPluginMetadata` names the package, host, interface and internal port, and `address.selection` is `new` or one of the address ids the form offers. -### Delete Onion Addresses +### Delete Unused Onion Addresses -Lists every `.onion` address in `torrc` with the package and host it belongs to, marks the ones no longer attached to an interface, and deletes the selected entries with their key material. +The only thing in this package that destroys a key. It lists every unused address with its package and host, all selected by default, and deletes the selected ones with their keys. -- **When to run it:** an address has to go and no interface page shows it. A service whose interface or port changed leaves its entry parked — unserved, key kept — until that port is back or this action removes it. -- **What it changes:** removes the entries from `torrc` and deletes their `hidden_services/` directories. Tor reloads when `torrc` changes. +- **What counts as unused:** no port of the address is on a disabled binding or resolves to a bridge address. A disabled binding is not served, but its address is kept for when the service enables it again. +- **What it changes:** deletes the `hidden_services/` directory and the store entry of each selected address. It checks again at run time: if any selected address has come into use since the form opened, it deletes nothing and fails naming them. A binding lookup error also aborts reuse or deletion; it is not evidence that an address is unused. - **Repeat safety:** deleting is permanent — the key is the address. - **Availability: any status.** -### Configure Relay - -Turns this node into a Tor relay or bridge, and sets its nickname, contact info, OR port, and bandwidth limits. - -- **What it changes:** the `relay` section of `torrc`, and through it the presence of the OR interface. -- **Cost:** seconds, then a config reload — except that changing the OR port of a relay that is already on **restarts Tor**, interrupting every onion service for the time Tor takes to bootstrap. Tor tests an ORPort only when it starts as a relay or its address changes; reloaded onto a new port it keeps the old port's verdict, and would publish, and the Relay Reachability check would report as reachable, a port nothing has tested. -- **Repeat safety:** idempotent; the form is pre-filled. -- **Enabling a relay is not the whole job.** It creates the OR interface; the port reaches the internet only once its **Public** address is enabled — see Interfaces above. A relay whose reachability was never confirmed contributes nothing and publishes no descriptor. The Relay Reachability health check shows whether it has been confirmed. -- **A relay contributes your bandwidth to the network.** The relay is configured to never act as an exit. -- **Bandwidth rate and burst are in KB/s, and burst must be at least the rate.** Tor rejects a relay below 75 KB/s or a burst below the rate, so the form enforces both. The `torrc` lines may carry either `KBytes` or `MBytes`; both read back in KB/s. -- **The relay identity is separate from your onion addresses.** It lives under `keys/` and survives the recovery wipe, so a relay keeps its fingerprint and its accumulated reputation. -- **The relay shares the Tor process with the onion services.** Tor advises against that combination and logs `Tor is currently configured as a relay and a hidden service` whenever both are configured. A `HiddenServiceDir` is written only for an address whose interface has a reachable port, so a server meant to be a relay or bridge only clears the warning by removing its `.onion` addresses — the StartOS UI's included; a parked address does not count, and **Delete Onion Addresses** removes it for good. - ### Reset Tor Connection -Clears Tor's cached view of the network and restarts it, so it picks new entry nodes. +Queues a wipe of `data/` and restarts. The deletion happens at the next start, before any daemon exists, because a running Tor holds that state in memory and writes it back on shutdown. -- **When to run it:** Tor is stuck bootstrapping, or keeps dropping circuits. It is the manual form of what the watchdog does automatically. -- **What it changes:** it queues a wipe; the deletion happens at the next start, before any daemon exists. -- **Cost:** Tor is offline for a few minutes while it re-bootstraps. -- **Repeat safety:** safe to re-run. -- **Your `.onion` addresses are not affected**, nor is the relay identity — the wipe is an allow-list that preserves the torrc, the hidden-service keys, the relay keys, the control socket, and the watchdog state, and deletes everything else. +- **When to run it:** Tor is stuck bootstrapping, or keeps dropping circuits. +- **Cost:** Tor is offline for a few minutes, and it selects new entry nodes — see [the trade-off](#the-trade-off-behind-automatic-recovery). +- **Not affected:** the onion keys, `torrc`, and `store.json`, none of which are under `data/`. - **Availability: only while the service is running.** -## Tasks +### Turn Off / Turn On Automatic Recovery -None. This package raises no tasks, so the service is never held on a prompt and its ordinary controls are always available. +One action whose name, description and confirmation follow the current setting. It flips `automaticRecovery` in `store.json`. Off is **fail closed**: the watchdog never acts, and a stuck Tor stays offline until the user runs Reset Tor Connection. -## Health Checks +## Tasks -Two checks. `tor` is also the recovery mechanism; `relay` only reports. +None. -| Check | Displayed | Method | -| ------- | -------------------- | ----------------------------------------------------------------------- | -| `tor` | "Tor SOCKS Proxy" | Tor's own control socket — bootstrap phase, circuit state, and dormancy | -| `relay` | "Relay Reachability" | Tor's own control socket — the result of the OR port self-test | +## Health Checks -The `tor` check reads Tor's real state rather than probing a port, so the message says what Tor is actually doing: a bootstrap percentage with Tor's own summary line while starting, and a distinct message for "bootstrapped but cannot build circuits". **Dormant counts as healthy** — Tor drops circuits when nothing has asked for one in a long time and wakes on the next request. +| Check | Displayed | Method | +| ----- | ----------------- | ----------------------------------------------------------------------- | +| `tor` | "Tor SOCKS Proxy" | Tor's own control socket — bootstrap phase, circuit state, and dormancy | -**It polls once a second while unhealthy instead of the default thirty**, because Tor bootstraps in seconds and a thirty-second poll left the UI showing 0% long after Tor had finished. +The check reads Tor's real state: a bootstrap percentage with Tor's summary line while starting, and a distinct failure for "bootstrapped but cannot build circuits". **Dormant counts as healthy.** It polls once a second while unhealthy instead of the default thirty. ### The watchdog -Tor pins an entry node and keeps retrying it — deliberately, to resist guard-discovery attacks — so an entry node that goes bad leaves Tor wedged, and a plain restart does not help because the choice is on disk. The health check escalates instead: - -1. **Five minutes unhealthy** with no movement in the bootstrap percentage, and it drops the pinned guards and open circuits over the control socket. Any change in the percentage restarts that clock, so a slow start is not mistaken for a wedge. -2. **Twice more**, ten and then twenty minutes apart. -3. **Then it wipes** the cached network state and restarts, so Tor re-selects from scratch. The wipe is queued and applied at the next start, because a running Tor holds this state in memory and would write the same entry nodes straight back. -4. **It wipes at most once per outage.** If Tor is still broken afterwards the cause is not stale state — most likely the server has no working internet — and the check says so and stops rather than restarting in a loop. +With Automatic Recovery on, sustained unhealthiness escalates: -A healthy reading resets the whole ladder. +1. **Five minutes** with no movement in the bootstrap percentage: drop the pinned entry nodes and open circuits over the control socket (`DROPGUARDS`, `DROPTIMEOUTS`, `NEWNYM`). +2. **Once more**, ten minutes later. +3. **Twenty minutes after that**, queue a wipe of `data/` and restart. +4. **It wipes at most once per outage.** If Tor is still broken afterwards the cause is not stale state — most likely the server has no working internet — and the check says so and stops. -### Relay reachability +A healthy reading resets the ladder. With Automatic Recovery off, none of this runs. -Disabled while relay mode is off. With relay mode on it reads, every 30 seconds, what the `tor` check's latest probe fetched — the two share one control connection, because Tor logs a notice for every connection it accepts — namely `status/reachability-succeeded/or` (Tor's own test of the OR port from outside, the same test that gates publishing the relay descriptor), `status/accepted-server-descriptor`, and `address/v6`. +### The trade-off behind Automatic Recovery -- **Success:** Tor has confirmed the OR port is reachable from the internet, and a directory authority has accepted the relay's descriptor. Both are required because Tor's reachability flag reads true until it has built a descriptor at all. -- **Loading** for the first 20 minutes of unreachable readings, which is as long as Tor itself waits before warning — or 45 minutes when Tor knows an IPv6 address, because Tor keeps reporting the OR port unreachable until one of its 20-minute checks drops an unreachable auto-discovered IPv6 address and publishes over IPv4 alone. The clock starts at the first unreachable reading, so turning relay mode on in a running Tor gets the same allowance as a restart, and it starts over whenever the OR port or the advertised address changes, because Tor then tests from scratch. -- **Failure, at once,** while no public address is enabled on the OR Port interface: nothing can reach the relay, whatever Tor reports. This is checked against the binding because Tor never revisits a test it has passed until its address changes, so its own verdict stays true after the Public address is turned off. -- **Failure** after the allowance otherwise: the inbound path is missing, or it is on a gateway the relay does not advertise — see [Network Access and Interfaces](#network-access-and-interfaces). Restarting Tor or running **Reset Tor Connection** does not change it. +Tor pins a small set of entry nodes and keeps them for months. That is deliberate: every fresh selection is another chance of picking an entry node run by an adversary, and the entry node is the one relay that sees this server's IP address. Tor's control specification says of `DROPGUARDS`, "Do not invoke this command lightly; it can increase vulnerability to tracking attacks over time." -The check only reads state; it never restarts the service. +The watchdog cannot tell a bad entry node from a dead link, so anyone able to interrupt this server's connection for five minutes forces a fresh selection, and can repeat that. For most servers staying reachable matters more, which is why the setting defaults to on. The user-facing text states the trade-off and nothing of this mechanism, on purpose. ## Backups and Restore -Only the `tor` volume is copied — `sdk.Backups.ofVolumes('tor')`. +Both volumes are backed up — `sdk.Backups.ofVolumes('tor', 'startos')`. The store maps each key directory to the interface it serves, so the two travel together. -- **Included:** the hidden-service keys, the relay identity, `torrc`, and Tor's caches. -- **This backup contains the private keys behind every `.onion` address on the server.** Anyone holding it can impersonate those addresses. Treat it accordingly. -- **Not included:** the `startos` volume, which only ever holds a one-time import file. -- **Restore:** the addresses come back, because the keys do. Onion entries whose target service is not installed on the restored server are pruned on the first start, and their keys deleted with them. A service restored in the same batch counts as installed from the moment the restore begins, whichever of the two comes up first; one you mean to restore later does not — restore Tor alongside the services that own its addresses, or after them, never before. +- **This backup contains the private keys behind every `.onion` address on the server.** Anyone holding it can impersonate those addresses. +- **Restore order does not matter.** Nothing is pruned, so Tor can be restored before, with, or after the services that own its addresses, and each address is served as soon as its host is bound. One whose service never comes back stays unused until Delete Unused Onion Addresses removes it. +- A backup taken before the store-based layout holds only the `tor` volume. StartOS restores the package version saved with that backup; updating it then runs the layout migration. ## Limitations and Differences -1. **`torrc` is generated and hand edits do not survive.** The annotation comments in it are structural, not documentation. -2. **Onion services are not added by hand.** They come from other services through the URL plugin; the actions that create them are hidden. -3. **Deleting an onion service is irreversible** — the key is the address. -4. **Onion entries whose target package is gone are pruned automatically**, key material included. A host missing from a package that is still installed keeps its entry, unexported, until the host is back or **Delete Onion Addresses** removes it. -5. **An onion whose interface has no bridge-reachable address is parked, not repaired** — there is nothing to point it at, so it stops being served and published until the interface is back, or until **Delete Onion Addresses** removes it. -6. **The SOCKS proxy is not exported** and is reachable only over loopback and the LXC bridge, never the LAN. -7. **The relay never acts as an exit.** -8. **The `tor` health check can restart the service on its own.** That is the watchdog working as intended, not a fault. -9. **The relay and the onion services run in one Tor process**, which Tor warns about whenever both are configured. A relay-only server is one with no served `.onion` addresses; a parked one does not count, and **Delete Onion Addresses** lists every one, served or not. -10. **With the Public address enabled on more than one gateway, the relay's address is not pinned.** Tor advertises the address its outbound traffic comes from, and that has to be one of those gateways. -11. **The package never gives the relay an IPv6 address**, and `torrc` hand edits do not survive. The relay advertises IPv6 only if a public IPv6 address is enabled and Tor discovers it on its own. +1. **Everything below the marker in `torrc` is generated**, and hand edits there do not survive. Edits above it do. +2. **Onion services are not added by hand.** They come from other services through the URL plugin. +3. **Deleting an address is irreversible** — the key is the address. +4. **A key is only ever deleted by hand.** Uninstalling a service, or retiring its host or port, leaves its addresses unused rather than deleting them, and installing the service again brings them back. +5. **The SOCKS proxy is not exported** and is reachable only over loopback and the LXC bridge. +6. **The `tor` health check can restart the service on its own** while Automatic Recovery is on. +7. **Run relays and bridges separately from onion hosting.** A relay's IP address is publicly listed, and a server that is both a listed relay and the host of `.onion` addresses can have the two linked by load and timing measurements, whether or not they share a process. A relay's identity under `keys/` is left in place. The migration retires the old `or-multi` host; custom domains assigned to it must be reattached to a current service interface. --- @@ -246,30 +226,32 @@ subcontainers: - chown-tmp # temporary; re-owns hidden-service dirs after a config change volumes: tor: /var/lib/tor - startos: host side (one-time onion-address import) + startos: host side, never mounted (store.json; one-time onion import) file_models: - - /var/lib/tor/torrc # custom serializer/parser; annotation comments are structural + - store.json # startos volume; source of truth: onions + automaticRecovery +generated_files: + - /var/lib/tor/torrc # user section above the marker is kept; the rest is rendered from store.json +disposable: + - /var/lib/tor/data # Tor's DataDirectory; Reset Tor Connection deletes it +irreplaceable: + - /var/lib/tor/hidden_services # one key directory per .onion address flag_files: # present = set - /var/lib/tor/.wipe-requested - /var/lib/tor/.auto-wiped startos_managed_env_vars: [] dependencies: [] -interfaces: - or: { type: p2p, port: 9001 } # only while a relay is enabled; port is configurable +interfaces: [] actions: - - add-onion-service # hidden; driven by the url-v0 plugin - - delete-onion-service # hidden; driven by the url-v0 plugin - - delete-onion-addresses - - configure-relay + - add-onion-service # hidden; url-v0 plugin; public, a service may call it for its own hosts + - delete-onion-service # hidden; url-v0 plugin; public, a service may call it for its own hosts + - delete-unused-addresses # the only action that destroys a key - reset-connection # only-running + - automatic-recovery # toggles store.json automaticRecovery; off = fail closed tasks: [] health_checks: - tor # displayed "Tor SOCKS Proxy"; also the self-recovery watchdog - - relay # displayed "Relay Reachability"; disabled unless relay mode is on ``` > **For dependent packages:** the SOCKS proxy is an unexported binding on host > `socks`, port 9050. Import `socksHostId` and `socksPort` from -> `tor-startos/startos/utils` rather than hardcoding either — sixteen packaging -> repos already do, and nothing in this repo references them, so a rename here -> breaks all of them silently. +> `tor-startos/startos/utils` rather than hardcoding either. diff --git a/UPDATING.md b/UPDATING.md index 7d930a2..430b36c 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -1,6 +1,6 @@ # Updating the upstream version -Tor is not pinned in this repo — it is installed via `apk add tor` from the Alpine base image, so the shipped Tor version is whatever the `Dockerfile`'s Alpine tag currently carries. Bumping Tor therefore means either (a) Alpine has published a new `tor` package within the current Alpine release and a rebuild will pick it up, or (b) we need to move to a newer Alpine base image to reach a newer Tor. +The upstream version is `torVersion` in `startos/manifest/index.ts`. It supplies the Docker build's `TOR_VERSION` argument, and the Dockerfile installs that exact Alpine package version, including the `-r0` packaging revision. `startos/versions/current.ts` repeats it as the upstream component of a quoted `version` literal, which release CI reads, so the two must match. ## Determining the upstream version @@ -13,30 +13,22 @@ curl -fsSL 'https://gitlab.torproject.org/api/v4/projects/tpo%2Fcore%2Ftor/repos | jq -r '.[].name' | grep -E '^tor-[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 ``` -Note the **four** version components — Tor versions are `0.4.9.11`, not `0.4.9`. A three-component regex matches nothing and the command silently prints an empty line. The filter also drops the `-alpha` / `-rc` / `-alpha-dev` tags, which are interleaved with the stable ones in the tag list; only a bare four-component tag is a stable release. +Tor versions have four components. The filter selects stable tags and excludes prereleases. -No version is pinned in this repo — there is no Tor tag, ARG, or `TOR_VERSION` variable to read. Use this only to know what the newest stable Tor is so you can compare against what Alpine ships (next section). +### Tor as packaged by Alpine -### Tor as packaged by Alpine (what actually ships) - -Tor is pulled in by `RUN apk add --no-cache tor` against the Alpine tag in the `Dockerfile`. **`tor` lives in Alpine's `community` repository, not `main`.** To see which Tor version that resolves to for the currently pinned Alpine release, read the authoritative package index (`APKINDEX`) for that release: +Tor lives in Alpine's `community` repository. Check the package index for each supported architecture: ``` ALPINE_TAG=$(grep -oP '(?<=^FROM alpine:)[0-9.]+' Dockerfile) -curl -fsSL "https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_TAG}/community/x86_64/APKINDEX.tar.gz" \ - | tar -xzO APKINDEX | grep -A1 '^P:tor$' +for ARCH in x86_64 aarch64 riscv64; do + echo "$ARCH" + curl -fsSL "https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_TAG}/community/${ARCH}/APKINDEX.tar.gz" \ + | tar -xzO APKINDEX | grep -A1 '^P:tor$' +done ``` -That prints `P:tor` / `V:-r` — the exact version a fresh build will install. Equivalently, ask `apk` itself inside the base image: - -``` -docker run --rm alpine:${ALPINE_TAG} sh -c 'apk update -q && apk search -e tor' -``` - -The pin lives implicitly in the Alpine base tag, not in a Tor-specific variable. - -> [!NOTE] -> The human-readable package browser lives at `https://pkgs.alpinelinux.org/package/v${ALPINE_TAG}/community/x86_64/tor` — note **`community`** in the path; the `main` path 404s, which is what made the old scrape in this doc fail. Don't scrape it either way: its HTML has since changed, so version-extracting `grep`s against it now come back empty rather than erroring. Use the `APKINDEX` or `apk` queries above. +The `V:` line gives the exact version and Alpine packaging revision. The desired upstream release must be available for all three architectures before bumping. If its revision differs from `-r0`, update the Dockerfile's exact package selector too. ### Alpine base image @@ -47,22 +39,11 @@ curl -fsSL 'https://hub.docker.com/v2/repositories/library/alpine/tags?page_size | jq -r '.results[].name' ``` -Pin lives in `Dockerfile` (`FROM alpine:`). +The base-image pin lives in `Dockerfile` (`FROM alpine:`). Move it when the desired Tor release requires a newer Alpine release. ## Applying the bump -There are two distinct bumps; do whichever applies. - -### Alpine has a newer Tor within the current Alpine release - -Nothing in this repo needs to change to pick up the new Tor — `apk add tor` will resolve to the new version on the next image build. Update `startos/versions/current.ts` (`version` + `releaseNotes`) and rebuild. - -### Need a newer Alpine to reach a newer Tor - -Edit `Dockerfile`: - -``` -FROM alpine: -``` - -Then update `startos/versions/current.ts` (`version` + `releaseNotes`) and rebuild. +1. Verify Alpine publishes the desired Tor package for every supported architecture. +2. Update `torVersion` in `startos/manifest/index.ts`, the version (upstream component and downstream revision) and release notes in `startos/versions/current.ts`, and the Alpine packaging revision in the Dockerfile if needed. +3. Build the packages. The explicit package selector and build argument make a version change invalidate the cached Tor-install layer. +4. Verify the binary with `start-cli package attach tor -n tor-sub -- tor --version`, as well as the package manifest version. diff --git a/instructions.md b/instructions.md index 2a18fa8..e920c2d 100644 --- a/instructions.md +++ b/instructions.md @@ -8,45 +8,62 @@ - A running Tor daemon with a **SOCKS5 proxy** on port 9050 that other StartOS services reach over the internal bridge to connect to `.onion` destinations. - The ability to **add a `.onion` address to any interface of any other installed service**, optionally with your own vanity key. -- Optional **relay or bridge mode** to contribute capacity to the Tor network. -- **Automatic recovery** when Tor gets stuck on a bad entry node, plus a **Reset Tor Connection** action to fix it yourself. +- **Automatic recovery** when Tor gets stuck connecting, which you can turn off, plus a **Reset Tor Connection** action to fix it yourself. ## Warnings - **Uninstalling Tor permanently deletes all onion service keys and `.onion` addresses.** Any service reachable through one of those addresses will lose it. Make a backup first if you want to keep your addresses. -- **Restore Tor together with the services that use its `.onion` addresses, or after them — never before.** Restored on its own, Tor treats an address whose service is not installed as abandoned and deletes its key. Selecting Tor and those services in the same restore is safe whichever order they come up in. +- **A `.onion` address outlives the service it belongs to.** Uninstalling a service leaves its addresses unused rather than deleting them, and installing it again brings them back. To get rid of one for good, use **Delete Unused Onion Addresses**. + +## Getting set up + +Install Tor, then **start the service**. Wait for the SOCKS Proxy health check to report that Tor is running. ## Using Tor ### Adding a .onion address to another service -Open the specific interface of the other service you want to expose over Tor. On that interface's page you'll find a **Tor** table; from there you can add or remove hidden services for that interface. When adding one you can supply a base64 ed25519 expanded private key for a vanity address, or leave that blank and StartOS will generate a fresh key. The `.onion` lives with the interface you attached it to — it appears and disappears with that interface. You can add an SSL or a non-SSL onion. The SSL toggle starts on for a service that is reachable only over SSL, and off for a web interface, since Tor already secures the connection and a certificate on a web interface's `.onion` only adds a browser warning. An interface that terminates its own TLS (SSL-only) can only take an SSL onion, since it has no plaintext endpoint to forward to. +Open the specific interface of the other service you want to expose over Tor. On that interface's page you'll find a **Tor** table; from there you can add or remove hidden services for that interface. When adding one you can supply a base64 ed25519 expanded private key for a vanity address, or leave that blank and StartOS will generate a fresh key. You can add an SSL or a non-SSL onion. The SSL toggle starts on for a service that is reachable only over SSL, and off for a web interface, since Tor already secures the connection and a certificate on a web interface's `.onion` only adds a browser warning. An interface that terminates its own TLS (SSL-only) can only take an SSL onion, since it has no plaintext endpoint to forward to. + +Your `.onion` addresses look after themselves once they exist. If a service's ports move, the address follows them without you doing anything, and the address itself never changes. + +If an update to a service removes the port an address was attached to, the address is kept. Open the interface you want it on, add a `.onion` address, and pick the existing address from the list instead of creating a new one. An entirely unused address can move to another host of the same service; an address still in use stays on its existing host. This is an explicit choice, not an automatic host migration. + +### Removing a .onion address -Your `.onion` addresses look after themselves once they exist. If a service changes the port or the encryption it is served on, Tor re-points the address the next time it starts, so it keeps answering without you doing anything and the address itself never changes. An address whose interface has no reachable port left stops answering until the port is back; it keeps its key, so it returns unchanged. +Removing an address from an interface's **Tor** table detaches it; its key is kept, so you can attach the same address again later. -### Removing a .onion address that no longer shows anywhere +To delete addresses for good, open Tor's **Actions** menu and run **Delete Unused Onion Addresses**. It lists every `.onion` address that no interface is using — ones you detached, and ones whose service was uninstalled or no longer has the port — with all of them selected. Untick any you want to keep. Deleting is permanent — the key goes with the address. If StartOS cannot check whether an address is in use, the action fails without deleting any keys; retry once that check is available. -An address stays with the interface it was attached to. If that service changed its interface or port, or was reinstalled differently, the address drops off every interface page and stops answering, but its key stays until you delete it. Open Tor's **Actions** menu and run **Delete Onion Addresses**: it lists every `.onion` address the server hosts, marks the ones no longer attached to an interface, and deletes the ones you pick. Deleting is permanent — the key goes with the address. +### Restoring from a backup + +Restore Tor and your other services in any order. Each address starts working again as soon as its service is back. ### Tor is stuck connecting -Tor enters the network through a small set of **entry nodes**, and it sticks with the ones it picked on purpose — hopping between entry points would make you easier to track. The downside is that if one of them goes bad, Tor keeps retrying it anyway: it stalls partway through connecting, or it connects but nothing loads. **Restarting Tor does not fix this**, because the entry node it picked is saved to disk and chosen again on the next start. +Sometimes Tor cannot connect, or connects but nothing loads, and **restarting it does not help**. -Tor now fixes this on its own. If it can't connect for a few minutes it switches entry nodes, and if that isn't enough it clears its saved network data and restarts so it starts fresh. Tor's health status tells you where it is while this happens. +With **Automatic Recovery** on, which is the default, Tor repairs this by itself within the hour. Tor's health status tells you where it is while this happens. -To fix it yourself without waiting, open Tor's **Actions** menu and run **Reset Tor Connection**. Tor clears the network data it has saved and restarts, then reconnects with new entry nodes — give it a few minutes before it's usable again. +To fix it yourself without waiting, open Tor's **Actions** menu and run **Reset Tor Connection**. Tor restarts and reconnects — give it a few minutes before it's usable again. Use it when Tor is stuck, not routinely. Your `.onion` addresses are never affected, by either route. If Tor still can't connect after a reset, its health status will say so, and the problem is almost certainly your server's internet connection rather than Tor itself. -### Running a relay or bridge +### Choosing between staying reachable and staying hidden + +**Automatic Recovery** is a trade-off, and the action that turns it on and off explains it each time you use it. + +- **On (default):** your services stay reachable without your attention. The cost: someone able to repeatedly interrupt this server's internet connection, such as an internet provider, could use those repairs to eventually work out that your `.onion` addresses are hosted here. +- **Off (fail closed):** that is no longer possible. The cost: if Tor gets stuck, it stays offline, along with everything that depends on it, until you run **Reset Tor Connection**. + +Leave it on unless hiding this server's location matters more to you than staying reachable. + +### Adding your own Tor options -1. Open Tor's **Actions** menu and run **Configure Relay**. -2. Toggle **Enabled**, then set a nickname, contact info, OR port, and bandwidth rate / burst in KB/s (Tor needs at least 75 KB/s, and the burst must be at least the rate). For a bridge, enable **Bridge Mode**. -3. Save. The OR port shows up under **Interfaces** as **Tor Relay OR Port** once relay mode is on. -4. Open the **Tor Relay OR Port** interface and enable the **Public** address on the connection you want your relay to use. Behind StartTunnel that is all. On a home connection StartOS asks your router to open the port; if your router doesn't allow that, forward the OR port to your server yourself. +Tor's configuration file, `torrc`, has two parts. The top part is yours: options you add there are kept and Tor uses them. Everything below the marked line is written by StartOS and is replaced whenever your addresses change. -Tor's **Relay Reachability** health status shows when the Tor network can reach your relay. The first test takes up to 20 minutes; once it passes, the relay appears in [Relay Search](https://metrics.torproject.org/rs.html) within a few hours. If it says the relay isn't reachable, the port isn't reaching your server: check that the Public address is still enabled and, on a home connection, your router. Keep the Public address enabled on just one connection — with more than one, your relay announces whichever of them Tor's outgoing traffic uses. If you change the OR port later, or your public IP changes, enable the Public address again. Changing the OR port while the relay is on restarts Tor so that it tests the new port, which briefly interrupts your `.onion` addresses. +### Relays and bridges -Tor advises against running a relay and `.onion` addresses in the same Tor process, and logs a warning whenever both are configured. If this server is meant to be a relay or bridge only, remove the `.onion` addresses from your services — the StartOS UI's included, under **System** — and the warning goes away. **Delete Onion Addresses** in Tor's **Actions** menu shows every address Tor holds, including any that no longer appears on an interface page. +Run a Tor relay or bridge separately from the server hosting your `.onion` addresses. A relay's IP address is public, and a server that both runs a relay and hosts `.onion` addresses can have the two linked. If you previously assigned custom domains to Tor's relay host, reattach them to a current service interface after updating; the update retires that host. diff --git a/package-lock.json b/package-lock.json index f7f5190..049cdf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,92 +7,14 @@ "name": "tor-startos", "dependencies": { "@noble/curves": "*", - "@start9labs/start-sdk": "2.0.9", + "@start9labs/start-sdk": "3.0.1", "rfc4648": "^1.5.4" - }, - "devDependencies": { - "@types/node": "^22.19.7", - "@vercel/ncc": "^0.38.4", - "prettier": "^3.8.1", - "typescript": "^6.0.3" - } - }, - "node_modules/@iarna/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", - "license": "ISC" - }, - "node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "2.2.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, - "node_modules/@start9labs/start-sdk": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-2.0.9.tgz", - "integrity": "sha512-HuSYrS10Bb+f6OyAMlCzd5Qyr+rIauiI0dF8MVh5Ny+eU0gF8/DeFAXlHFgL+9wkSv4UAP5q5yOzlBpXzsNbWQ==", - "bundleDependencies": [ - "@start9labs/start-core", - "eslint", - "typescript-eslint" - ], - "license": "MIT", - "dependencies": { - "@iarna/toml": "^3.0.0", - "@noble/curves": "^1.9.7", - "@noble/hashes": "^1.8.0", - "@start9labs/start-core": "file:./node_modules/@start9labs/start-core", - "@types/ini": "^4.1.1", - "deep-equality-data-structures": "^2.0.0", - "eslint": "^9.39.4", - "fast-xml-parser": "~5.7.0", - "ini": "^5.0.0", - "isomorphic-fetch": "^3.0.0", - "mime": "^4.1.0", - "typescript-eslint": "^8.61.0", - "yaml": "^2.8.3", - "zod": "4.4.3", - "zod-deep-partial": "^1.2.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "inBundle": true, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -107,9 +29,10 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { "version": "3.4.3", - "inBundle": true, + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -118,17 +41,19 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/regexpp": { + "node_modules/@eslint-community/regexpp": { "version": "4.12.2", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array": { + "node_modules/@eslint/config-array": { "version": "0.21.2", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", @@ -139,29 +64,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-helpers": { + "node_modules/@eslint/config-helpers": { "version": "0.4.2", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0" @@ -170,9 +76,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/core": { + "node_modules/@eslint/core": { "version": "0.17.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -181,9 +88,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "inBundle": true, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", "license": "MIT", "dependencies": { "ajv": "^6.14.0", @@ -192,7 +100,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -203,55 +111,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "inBundle": true, - "license": "Python-2.0" - }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.2.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "inBundle": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/js": { - "version": "9.39.4", - "inBundle": true, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -260,17 +123,19 @@ "url": "https://eslint.org/donate" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/object-schema": { + "node_modules/@eslint/object-schema": { "version": "2.1.7", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@eslint/plugin-kit": { + "node_modules/@eslint/plugin-kit": { "version": "0.4.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.17.0", @@ -280,9 +145,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@humanfs/core": { + "node_modules/@humanfs/core": { "version": "0.19.2", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "license": "Apache-2.0", "dependencies": { "@humanfs/types": "^0.15.0" @@ -291,9 +157,10 @@ "node": ">=18.18.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@humanfs/node": { + "node_modules/@humanfs/node": { "version": "0.16.8", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.2", @@ -304,17 +171,19 @@ "node": ">=18.18.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@humanfs/types": { + "node_modules/@humanfs/types": { "version": "0.15.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "license": "Apache-2.0", "engines": { "node": ">=18.18.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@humanwhocodes/module-importer": { + "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "license": "Apache-2.0", "engines": { "node": ">=12.22" @@ -324,9 +193,10 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@start9labs/start-sdk/node_modules/@humanwhocodes/retry": { + "node_modules/@humanwhocodes/retry": { "version": "0.4.3", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -336,6 +206,81 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-3.0.1.tgz", + "integrity": "sha512-cguBvoMMSnMZHz4AaUqbwC5MgRC9uymfdOiElcwiNFVnKvZ5hoE6cUEmumh/Gh4xKQUSPiDAJG7bQvRKxWRADQ==", + "bundleDependencies": [ + "@start9labs/start-core" + ], + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@start9labs/start-core": "file:./node_modules/@start9labs/start-core", + "@types/ini": "^4.1.1", + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "deep-equality-data-structures": "^2.0.0", + "eslint": "^9.39.4", + "fast-xml-parser": "~5.7.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "prettier": "3.8.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.61.0", + "yaml": "^2.8.3", + "zod": "4.4.3", + "zod-deep-partial": "^1.2.0" + } + }, "node_modules/@start9labs/start-sdk/node_modules/@noble/curves": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -363,26 +308,44 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@start9labs/start-sdk/node_modules/@types/estree": { + "node_modules/@types/estree": { "version": "1.0.9", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/@types/json-schema": { + "node_modules/@types/json-schema": { "version": "7.0.15", - "inBundle": true, + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/type-utils": "8.61.0", - "@typescript-eslint/utils": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -395,28 +358,30 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.0", + "@typescript-eslint/parser": "^8.71.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "inBundle": true, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "license": "MIT", "engines": { "node": ">= 4" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/parser": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3" }, "engines": { @@ -431,13 +396,14 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/project-service": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.0", - "@typescript-eslint/types": "^8.61.0", + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "engines": { @@ -451,13 +417,14 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -467,9 +434,10 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -482,14 +450,15 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/type-utils": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -505,9 +474,10 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/types": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -517,15 +487,16 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.0", - "@typescript-eslint/tsconfig-utils": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -543,31 +514,34 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { "version": "4.0.4", - "inBundle": true, + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", "engines": { "node": "18 || 20 || >=22" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "inBundle": true, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "inBundle": true, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -576,26 +550,16 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.3", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/utils": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0" + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -609,12 +573,13 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.0", - "inBundle": true, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/types": "8.71.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -625,9 +590,10 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "5.0.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "license": "Apache-2.0", "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -636,9 +602,19 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@start9labs/start-sdk/node_modules/acorn": { - "version": "8.16.0", - "inBundle": true, + "node_modules/@vercel/ncc": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -647,17 +623,19 @@ "node": ">=0.4.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/acorn-jsx": { + "node_modules/acorn-jsx": { "version": "5.3.2", - "inBundle": true, + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/ajv": { + "node_modules/ajv": { "version": "6.15.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -670,9 +648,10 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@start9labs/start-sdk/node_modules/ansi-styles": { + "node_modules/ansi-styles": { "version": "4.3.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -684,22 +663,53 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@start9labs/start-sdk/node_modules/balanced-match": { + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/balanced-match": { "version": "1.0.2", - "inBundle": true, + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/callsites": { + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { "version": "3.1.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/@start9labs/start-sdk/node_modules/chalk": { + "node_modules/chalk": { "version": "4.1.2", - "inBundle": true, + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -712,9 +722,10 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@start9labs/start-sdk/node_modules/color-convert": { + "node_modules/color-convert": { "version": "2.0.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -723,19 +734,22 @@ "node": ">=7.0.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/color-name": { + "node_modules/color-name": { "version": "1.1.4", - "inBundle": true, + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/concat-map": { + "node_modules/concat-map": { "version": "0.0.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/cross-spawn": { + "node_modules/cross-spawn": { "version": "7.0.6", - "inBundle": true, + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -746,9 +760,10 @@ "node": ">= 8" } }, - "node_modules/@start9labs/start-sdk/node_modules/debug": { + "node_modules/debug": { "version": "4.4.3", - "inBundle": true, + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -762,14 +777,38 @@ } } }, - "node_modules/@start9labs/start-sdk/node_modules/deep-is": { + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/deep-is": { "version": "0.1.4", - "inBundle": true, + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/eslint": { - "version": "9.39.4", - "inBundle": true, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", @@ -777,8 +816,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -825,9 +864,10 @@ } } }, - "node_modules/@start9labs/start-sdk/node_modules/eslint-scope": { + "node_modules/eslint-scope": { "version": "8.4.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -840,9 +880,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@start9labs/start-sdk/node_modules/eslint-visitor-keys": { + "node_modules/eslint-visitor-keys": { "version": "4.2.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -851,95 +892,11 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/espree": { - "version": "10.4.0", - "inBundle": true, - "license": "BSD-2-Clause", + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -952,9 +909,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@start9labs/start-sdk/node_modules/esquery": { + "node_modules/esquery": { "version": "1.7.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -963,9 +921,10 @@ "node": ">=0.10" } }, - "node_modules/@start9labs/start-sdk/node_modules/esrecurse": { + "node_modules/esrecurse": { "version": "4.3.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -974,40 +933,83 @@ "node": ">=4.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/estraverse": { + "node_modules/estraverse": { "version": "5.3.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/esutils": { + "node_modules/esutils": { "version": "2.0.3", - "inBundle": true, + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/fast-deep-equal": { + "node_modules/fast-deep-equal": { "version": "3.1.3", - "inBundle": true, + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/fast-json-stable-stringify": { + "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/fast-levenshtein": { + "node_modules/fast-levenshtein": { "version": "2.0.6", - "inBundle": true, + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/fdir": { + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { "version": "6.5.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1021,9 +1023,10 @@ } } }, - "node_modules/@start9labs/start-sdk/node_modules/file-entry-cache": { + "node_modules/file-entry-cache": { "version": "8.0.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" @@ -1032,9 +1035,26 @@ "node": ">=16.0.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/flat-cache": { + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { "version": "4.0.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "license": "MIT", "dependencies": { "flatted": "^3.2.9", @@ -1044,14 +1064,28 @@ "node": ">=16" } }, - "node_modules/@start9labs/start-sdk/node_modules/flatted": { - "version": "3.4.2", - "inBundle": true, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "license": "ISC" }, - "node_modules/@start9labs/start-sdk/node_modules/globals": { + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { "version": "14.0.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "license": "MIT", "engines": { "node": ">=18" @@ -1060,25 +1094,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@start9labs/start-sdk/node_modules/has-flag": { + "node_modules/has-flag": { "version": "4.0.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/@start9labs/start-sdk/node_modules/ignore": { + "node_modules/ignore": { "version": "5.3.2", - "inBundle": true, + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "license": "MIT", "engines": { "node": ">= 4" } }, - "node_modules/@start9labs/start-sdk/node_modules/import-fresh": { + "node_modules/import-fresh": { "version": "3.3.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -1091,33 +1128,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@start9labs/start-sdk/node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "inBundle": true, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">=0.8.19" } }, - "node_modules/@start9labs/start-sdk/node_modules/imurmurhash": { - "version": "0.1.4", - "inBundle": true, - "license": "MIT", + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", "engines": { - "node": ">=0.8.19" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/is-extglob": { + "node_modules/is-extglob": { "version": "2.1.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/is-glob": { + "node_modules/is-glob": { "version": "4.0.3", - "inBundle": true, + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -1126,37 +1167,75 @@ "node": ">=0.10.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/isexe": { + "node_modules/isexe": { "version": "2.0.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/@start9labs/start-sdk/node_modules/json-buffer": { + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { "version": "3.0.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/json-schema-traverse": { + "node_modules/json-schema-traverse": { "version": "0.4.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/json-stable-stringify-without-jsonify": { + "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/keyv": { + "node_modules/keyv": { "version": "4.5.4", - "inBundle": true, + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } }, - "node_modules/@start9labs/start-sdk/node_modules/levn": { + "node_modules/levn": { "version": "0.4.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", @@ -1166,24 +1245,99 @@ "node": ">= 0.8.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/lodash.merge": { + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { "version": "4.6.2", - "inBundle": true, + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/ms": { + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { "version": "2.1.3", - "inBundle": true, + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/natural-compare": { + "node_modules/natural-compare": { "version": "1.4.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/optionator": { + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { "version": "0.9.4", - "inBundle": true, + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "license": "MIT", "dependencies": { "deep-is": "^0.1.3", @@ -1197,9 +1351,10 @@ "node": ">= 0.8.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/p-limit": { + "node_modules/p-limit": { "version": "3.1.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -1211,9 +1366,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@start9labs/start-sdk/node_modules/parent-module": { + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { "version": "1.0.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -1222,25 +1393,43 @@ "node": ">=6" } }, - "node_modules/@start9labs/start-sdk/node_modules/path-exists": { + "node_modules/path-exists": { "version": "4.0.0", - "inBundle": true, + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/@start9labs/start-sdk/node_modules/path-key": { + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { "version": "3.1.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/@start9labs/start-sdk/node_modules/picomatch": { - "version": "4.0.4", - "inBundle": true, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -1249,230 +1438,103 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@start9labs/start-sdk/node_modules/prelude-ls": { + "node_modules/prelude-ls": { "version": "1.2.1", - "inBundle": true, + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "license": "MIT", "engines": { "node": ">= 0.8.0" } }, - "node_modules/@start9labs/start-sdk/node_modules/punycode": { - "version": "2.3.1", - "inBundle": true, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, "engines": { - "node": ">=6" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/shebang-command": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/shebang-regex": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/strip-json-comments": { - "version": "3.1.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/@start9labs/start-sdk/node_modules/supports-color": { - "version": "7.2.0", - "inBundle": true, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/@start9labs/start-sdk/node_modules/tinyglobby": { - "version": "0.2.17", - "inBundle": true, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=4" } }, - "node_modules/@start9labs/start-sdk/node_modules/ts-api-utils": { - "version": "2.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" }, - "node_modules/@start9labs/start-sdk/node_modules/type-check": { - "version": "0.4.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.8.0" + "node": ">=10" } }, - "node_modules/@start9labs/start-sdk/node_modules/typescript-eslint": { - "version": "8.61.0", - "inBundle": true, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.0", - "@typescript-eslint/parser": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/uri-js": { - "version": "4.4.1", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/@start9labs/start-sdk/node_modules/which": { - "version": "2.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">= 8" + "node": ">=8" } }, - "node_modules/@start9labs/start-sdk/node_modules/word-wrap": { - "version": "1.2.5", - "inBundle": true, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/@start9labs/start-sdk/node_modules/yocto-queue": { - "version": "0.1.0", - "inBundle": true, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@types/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.20.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", - "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@vercel/ncc": { - "version": "0.38.4", - "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", - "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", - "dev": true, - "license": "MIT", - "bin": { - "ncc": "dist/ncc/cli.js" - } - }, - "node_modules/anynum": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", - "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/deep-equality-data-structures": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", - "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", - "license": "MIT", - "dependencies": { - "object-hash": "^3.0.0" - } - }, - "node_modules/fast-xml-builder": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", - "integrity": "sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", "funding": [ { "type": "github", @@ -1481,136 +1543,67 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/ini": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", - "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" + "anynum": "^1.0.1" } }, - "node_modules/isomorphic-fetch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", - "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { - "node-fetch": "^2.6.1", - "whatwg-fetch": "^3.4.1" - } - }, - "node_modules/mime": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", - "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", - "funding": [ - "https://github.com/sponsors/broofa" - ], - "license": "MIT", - "bin": { - "mime": "bin/cli.js" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=16" + "node": ">=8" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { - "whatwg-url": "^5.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" + "node": ">=12.0.0" }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "engines": { - "node": ">= 6" + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/path-expression-matcher": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", - "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" }, - "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", - "dev": true, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, "engines": { - "node": ">=14" + "node": ">=18.12" }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/rfc4648": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", - "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", - "license": "MIT" - }, - "node_modules/strnum": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", - "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "license": "MIT", "dependencies": { - "anynum": "^1.0.1" + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1624,13 +1617,44 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -1653,10 +1677,34 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", "funding": [ { "type": "github", @@ -1669,9 +1717,9 @@ } }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -1683,6 +1731,18 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 35a1f44..1fde653 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,13 @@ { "name": "tor-startos", "scripts": { - "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", - "prettier": "prettier --write startos", - "check": "tsc --noEmit" + "test": "node --test test/*.test.cjs" }, "dependencies": { "@noble/curves": "*", - "@start9labs/start-sdk": "2.0.9", + "@start9labs/start-sdk": "3.0.1", "rfc4648": "^1.5.4" }, - "devDependencies": { - "@types/node": "^22.19.7", - "@vercel/ncc": "^0.38.4", - "prettier": "^3.8.1", - "typescript": "^6.0.3" - }, - "prettier": { - "trailingComma": "all", - "tabWidth": 2, - "semi": false, - "singleQuote": true - }, "overrides": { "@start9labs/start-sdk": "$@start9labs/start-sdk" } diff --git a/startos/actions/addOnionService.ts b/startos/actions/addOnionService.ts index 9127c23..3b0b50b 100644 --- a/startos/actions/addOnionService.ts +++ b/startos/actions/addOnionService.ts @@ -1,7 +1,16 @@ -import { hsDir, nextKey, torrc } from '../fileModels/torrc' +import { + hsDir, + nextIndex, + onionId, + parseOnionId, + present, + storeJson, + writeOnions, +} from '../fileModels/store.json' import { i18n } from '../i18n' import { sdk } from '../sdk' -import { bridgeHost, generateOnionFiles } from '../utils' +import { generateOnionFiles } from '../utils' +import { isServed, onionHostname, requireOwner } from '../utils/onions' const { InputSpec, Value, Variants } = sdk @@ -69,20 +78,21 @@ const inputSpec = InputSpec.of({ const { packageId, hostId, internalPort } = prefill?.urlPluginMetadata ?? {} - const config = await torrc.read().once() - const entries = - (packageId && hostId && config?.onionServices?.[packageId]?.[hostId]) || - {} + const onions = present(await storeJson.read((s) => s.onions).once()) // Which onion bindings this interface can serve, mirroring the execution // path: a plaintext primary unless the service terminates its own TLS, plus // an SSL binding when it's native-SSL or StartOS adds SSL. - const host = - packageId && hostId - ? await sdk.host.get(effects, { hostId, packageId }).once() - : null const binding = - internalPort != null ? host?.bindings[internalPort] : undefined + packageId && hostId && internalPort != null + ? await sdk.host + .get( + effects, + { hostId, packageId }, + (host) => host?.bindings[internalPort] ?? null, + ) + .once() + : null const nativeSsl = binding?.options.secure?.ssl === true const availNonSsl = !!binding?.enabled && !nativeSsl const availSsl = @@ -96,32 +106,26 @@ const inputSpec = InputSpec.of({ } > = {} - for (const [key, entry] of Object.entries(entries)) { - if (!entry || internalPort == null) continue + for (const [id, onion] of Object.entries(onions)) { + const owner = parseOnionId(id) + if (owner.packageId !== packageId) continue + const inUse = await isServed(effects, id, onion) + if (owner.hostId !== hostId && inUse) continue - const bindingPorts = Object.values(entry.ports).filter( - (p) => p?.internalPort === internalPort, + const served = onion.ports.filter( + (p) => p.internalPort === internalPort, ) - const hasNonSsl = bindingPorts.some((p) => p && !p.ssl) - const hasSsl = bindingPorts.some((p) => p?.ssl) - - // Skip an address that doesn't serve this binding at all, or one already - // attached to every binding the interface offers (non-SSL, plus SSL when - // available). - if (!hasNonSsl && !hasSsl) continue - if ((!availNonSsl || hasNonSsl) && (!availSsl || hasSsl)) continue + const hasNonSsl = served.some((p) => !p.ssl) + const hasSsl = served.some((p) => p.ssl) - let hostname = key - try { - const content = await sdk.volumes.tor.readFile( - `${hsDir(packageId!, hostId!, key)}/hostname`, - ) - hostname = content.toString().trim() - } catch { - // hostname file doesn't exist yet + // An address in use stays on its host. + if (inUse) { + if (!hasNonSsl && !hasSsl) continue + if ((!availNonSsl || hasNonSsl) && (!availSsl || hasSsl)) continue } - variants[key] = { - name: hostname, + + variants[id] = { + name: (await onionHostname(id, onion)) ?? id, spec: InputSpec.of({}), } } @@ -152,14 +156,16 @@ export const addOnionService = sdk.Action.withInput( allowedStatuses: 'any', group: null, visibility: 'hidden', + access: 'public', }), // input spec - async ({ effects, prefill }) => { + async ({ effects, prefill, caller }) => { const p = prefill as typeof inputSpec._PARTIAL let noSsl = false const meta = p?.urlPluginMetadata + requireOwner(caller, meta?.packageId) if (meta?.packageId && meta.hostId && meta.internalPort != null) { const internalPort = meta.internalPort noSsl = await sdk.host @@ -183,113 +189,118 @@ export const addOnionService = sdk.Action.withInput( async () => null, // execution - async ({ effects, input }) => { + async ({ effects, input, caller }) => { const { packageId, hostId, internalPort } = input.urlPluginMetadata + requireOwner(caller, packageId) const address = input.address as { selection: string value: { privateKey?: string | null } } - const host = await sdk.host.get(effects, { hostId, packageId }).once() - const binding = host?.bindings[internalPort] + const binding = await sdk.host + .get( + effects, + { hostId, packageId }, + (host) => host?.bindings[internalPort] ?? null, + ) + .once() + if (!binding?.enabled) { + throw new Error( + `Cannot create an onion service for "${packageId}": interface binding ${internalPort} is not exposed, so there is no reachable endpoint to forward to.`, + ) + } - // A binding that terminates its own TLS (native `secure.ssl`) is SSL-only: - // it has no plaintext endpoint, so the only honest onion is an SSL one. Such - // a binding shows no SSL toggle (the toggle is offered only for `addSsl` - // bindings, which expose both a plaintext and a StartOS-terminated SSL - // port), so `input.ssl` is absent — infer SSL from the binding itself. - const nativeSsl = binding?.options.secure?.ssl === true - const ssl = !!input.ssl || nativeSsl + // A binding that terminates its own TLS has no plaintext endpoint and shows + // no SSL toggle, so its onion is an SSL one whatever the input says. An + // `addSsl` binding offers both, on two different external ports. + const nativeSsl = binding.options.secure?.ssl === true + const ssl = nativeSsl || (!!input.ssl && !!binding.options.addSsl) + const port = { + externalPort: + !nativeSsl && ssl + ? binding.options.addSsl!.preferredExternalPort + : binding.options.preferredExternalPort, + internalPort, + ssl, + } - // Build the port entry. The target is always the interface's LXC-bridge - // `host:port` (the deprecated `.startos` container hostname is gone); - // the bridge exposes an http and an https variant, and we pick by `ssl`. - const newPorts: Record< - string, - { target: string; ssl: boolean; internalPort: number } - > = {} + const onions = present(await storeJson.read((s) => s.onions).once()) - if (ssl && nativeSsl && binding?.enabled) { - // The service speaks TLS on its own port, so Tor forwards raw TCP to the - // bridge's https address for it. - const addr = bridgeHost(host, internalPort, true) - if (addr) { - newPorts[String(binding.options.preferredExternalPort)] = { - target: `${addr.hostname}:${addr.port}`, - ssl: true, - internalPort, - } + if (address.selection !== 'new') { + const existing = onions[address.selection] + if (!existing) + throw new Error(i18n('This onion address is no longer available')) + const owner = parseOnionId(address.selection) + if (owner.packageId !== packageId) { + throw new Error(i18n('This onion address belongs to another service')) } - } else if (ssl && binding?.options.addSsl) { - // StartOS terminates TLS on the bridge's https port and forwards - // plaintext to the container; the onion targets that port. - const addr = bridgeHost(host, internalPort, true) - if (addr) { - newPorts[String(binding.options.addSsl.preferredExternalPort)] = { - target: `${addr.hostname}:${addr.port}`, - ssl: true, - internalPort, - } + const moving = owner.hostId !== hostId + if (moving && (await isServed(effects, address.selection, existing))) { + throw new Error( + i18n('Only an unused onion address can move to another host'), + ) } - } else { - if (binding?.enabled) { - const addr = bridgeHost(host, internalPort, false) - if (addr) { - newPorts[String(binding.options.preferredExternalPort)] = { - target: `${addr.hostname}:${addr.port}`, - ssl: false, - internalPort, - } - } - } else { + if ( + !moving && + existing.ports.some( + (p) => p.ssl === ssl && p.internalPort === internalPort, + ) + ) { throw new Error( - `Cannot create an onion service for "${packageId}": interface binding ${internalPort} is not exposed, so there is no reachable endpoint to forward to.`, + ssl + ? i18n( + 'This onion address already has an SSL binding for this port', + ) + : i18n( + 'This onion address already has a non-SSL binding for this port', + ), ) } - } - - const config = await torrc.read().once() - const onionServices = config?.onionServices || {} - if (!onionServices[packageId]) onionServices[packageId] = {} - if (!onionServices[packageId][hostId]) onionServices[packageId][hostId] = {} - - const services = onionServices[packageId][hostId] - - if (address.selection !== 'new') { - // Reuse existing address by key - const existing = services[address.selection] - if (existing) { - const duplicate = Object.values(existing.ports).some( - (p) => p?.ssl === ssl && p?.internalPort === internalPort, + // Attaching is the moment to shed mappings whose binding is gone. + const bound = await sdk.host + .get(effects, { hostId, packageId }, (host) => + Object.keys(host?.bindings ?? {}).map(Number), ) - if (duplicate) { - throw new Error( - ssl - ? i18n( - 'This onion address already has an SSL binding for this port', - ) - : i18n( - 'This onion address already has a non-SSL binding for this port', - ), - ) + .once() + if (moving) { + const id = onionId( + packageId, + hostId, + await nextIndex(onions, packageId, hostId), + ) + onions[id] = { + keyId: existing.keyId ?? address.selection, + ports: [port], } - services[address.selection] = { - ports: { ...existing.ports, ...newPorts }, + delete onions[address.selection] + } else { + onions[address.selection] = { + ...existing, + ports: [ + ...existing.ports.filter( + (p) => + bound.includes(p.internalPort) && + p.externalPort !== port.externalPort, + ), + port, + ], } } } else { - // Create new entry - const key = nextKey(services) - services[key] = { ports: newPorts } - - const dir = hsDir(packageId, hostId, key) + const id = onionId( + packageId, + hostId, + await nextIndex(onions, packageId, hostId), + ) const { secretKey, hostname } = generateOnionFiles( address.value.privateKey, ) + const dir = hsDir(id) await sdk.volumes.tor.writeFile(`${dir}/hs_ed25519_secret_key`, secretKey) await sdk.volumes.tor.writeFile(`${dir}/hostname`, hostname + '\n') + onions[id] = { ports: [port] } } - await torrc.merge(effects, { onionServices }) + await writeOnions(effects, onions) }, ) diff --git a/startos/actions/automaticRecovery.ts b/startos/actions/automaticRecovery.ts new file mode 100644 index 0000000..2e98e90 --- /dev/null +++ b/startos/actions/automaticRecovery.ts @@ -0,0 +1,56 @@ +import { storeJson } from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' + +const setting = storeJson.read((s) => s.automaticRecovery) + +export const automaticRecovery = sdk.Action.withoutInput( + // id + 'automatic-recovery', + + // metadata + async ({ effects }) => { + const on = (await setting.const(effects)) ?? true + return { + name: on + ? i18n('Turn Off Automatic Recovery') + : i18n('Turn On Automatic Recovery'), + description: on + ? i18n( + 'Automatic Recovery is on. When Tor loses its connection and cannot get it back, this service repairs it for you, so your services stay reachable without your attention.', + ) + : i18n( + 'Automatic Recovery is off, so Tor fails closed. If Tor gets stuck it stays offline, along with everything that depends on it, until you run Reset Tor Connection.', + ), + warning: on + ? i18n( + 'Turning this off makes Tor fail closed. If Tor gets stuck, it stays offline, along with everything that depends on it, until you run Reset Tor Connection. In exchange, no one can use automatic repairs to work out that your .onion addresses are hosted on this server. Turn it off only if hiding this server’s location matters more to you than staying reachable.', + ) + : i18n( + 'With this on, your services stay reachable without your attention. The cost: someone able to repeatedly interrupt this server’s internet connection, such as an internet provider, could use the automatic repairs to eventually work out that your .onion addresses are hosted here.', + ), + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + } + }, + + // execution + async ({ effects }) => { + const on = !((await setting.once()) ?? true) + await storeJson.merge(effects, { automaticRecovery: on }) + + return { + version: '1' as const, + title: on + ? i18n('Automatic Recovery Is On') + : i18n('Automatic Recovery Is Off'), + message: on + ? i18n('Tor will repair a stuck connection without you.') + : i18n( + 'Tor now fails closed: a stuck connection stays down until you run Reset Tor Connection.', + ), + result: null, + } + }, +) diff --git a/startos/actions/configureRelay.ts b/startos/actions/configureRelay.ts deleted file mode 100644 index 29f69db..0000000 --- a/startos/actions/configureRelay.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { utils } from '@start9labs/start-sdk' -import { torrc } from '../fileModels/torrc' -import { i18n } from '../i18n' -import { sdk } from '../sdk' - -const { InputSpec, Value } = sdk - -export const relayInputSpec = InputSpec.of({ - enabled: Value.toggle({ - name: i18n('Enabled'), - default: false, - }), - nickname: Value.text({ - name: i18n('Nickname'), - description: null, - required: false, - default: 'StartOSRelay', - placeholder: 'StartOSRelay', - patterns: [ - { - regex: '^[a-zA-Z0-9]{1,19}$', - description: 'Must be 1-19 alphanumeric characters', - }, - ], - masked: false, - inputmode: 'text', - minLength: 1, - maxLength: 19, - }), - contactInfo: Value.text({ - name: i18n('Contact Info'), - description: null, - required: false, - default: null, - placeholder: 'email@example.com', - patterns: [utils.Patterns.email], - masked: false, - inputmode: 'email', - minLength: null, - maxLength: null, - }), - bridge: Value.toggle({ - name: i18n('Bridge Mode'), - default: false, - }), - orPort: Value.number({ - name: i18n('OR Port'), - description: i18n( - 'Changing the OR port while the relay is on restarts Tor, so that it tests the new port.', - ), - required: false, - default: 9001, - min: 1, - max: 65535, - integer: true, - placeholder: null, - units: null, - }), - bandwidthRate: Value.number({ - name: i18n('Bandwidth Rate'), - description: i18n('Tor requires at least 75 KB/s for a relay.'), - required: true, - default: 1024, - min: 75, - max: null, - integer: true, - placeholder: null, - units: 'KB/s', - }), - bandwidthBurst: Value.number({ - name: i18n('Bandwidth Burst'), - description: i18n('Must be at least the Bandwidth Rate.'), - required: true, - default: 2048, - min: 75, - max: null, - integer: true, - placeholder: null, - units: 'KB/s', - }), -}) - -export const configureRelay = sdk.Action.withInput( - // id - 'configure-relay', - - // metadata - async () => ({ - name: i18n('Configure Relay'), - description: i18n('Configure Tor relay and bridge settings'), - warning: null, - allowedStatuses: 'any', - group: null, - visibility: 'enabled', - }), - - // input spec - relayInputSpec, - - // pre-fill from current config; InputSpec defaults fill any undefined fields - async ({ effects }) => { - return (await torrc.read((s) => s.relay).once()) ?? {} - }, - - // execution: merge relay input, converting nulls to undefined for zod .catch() defaults - async ({ effects, input }) => { - if (input.bandwidthBurst < input.bandwidthRate) { - throw new Error( - i18n('Bandwidth Burst must be at least the Bandwidth Rate.'), - ) - } - const before = await torrc.read((s) => s.relay).once() - await torrc.merge(effects, { - relay: { - enabled: input.enabled, - nickname: input.nickname ?? undefined, - contactInfo: input.contactInfo ?? undefined, - bridge: input.bridge, - orPort: input.orPort ?? undefined, - bandwidthRate: input.bandwidthRate, - bandwidthBurst: input.bandwidthBurst, - }, - }) - // Tor tests an ORPort only when it starts as a relay or its address - // changes. A reload onto a new port keeps the old port's verdict, so the - // relay would publish, and report as reachable, a port nobody has tested. - if ( - before?.enabled && - input.enabled && - (input.orPort ?? 9001) !== before.orPort - ) { - await sdk.restart(effects) - } - }, -) diff --git a/startos/actions/deleteOnionAddresses.ts b/startos/actions/deleteOnionAddresses.ts deleted file mode 100644 index 44af875..0000000 --- a/startos/actions/deleteOnionAddresses.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { rm } from 'fs/promises' -import { dropOnionService, hsDir, torrc } from '../fileModels/torrc' -import { i18n } from '../i18n' -import { sdk } from '../sdk' - -const { InputSpec, Value } = sdk - -const onionHostname = (packageId: string, hostId: string, index: string) => - sdk.volumes.tor - .readFile(`${hsDir(packageId, hostId, index)}/hostname`) - .then((content) => content.toString().trim()) - .catch(() => null) - -const inputSpec = InputSpec.of({ - addresses: Value.dynamicMultiselect(async () => { - const onionServices = - (await torrc.read((t) => t.onionServices).once()) ?? {} - const values: Record = {} - for (const [packageId, hosts] of Object.entries(onionServices)) { - for (const [hostId, services] of Object.entries(hosts ?? {})) { - for (const [index, svc] of Object.entries(services ?? {})) { - if (!svc) continue - const attached = Object.values(svc.ports).some( - (p) => p && p.target !== null, - ) - const hostname = - (await onionHostname(packageId, hostId, index)) ?? - i18n('address not generated yet') - values[`${packageId}/${hostId}/${index}`] = - `${hostname} — ${packageId}/${hostId}` + - (attached ? '' : ` (${i18n('no longer attached to an interface')})`) - } - } - } - - if (!Object.keys(values).length) - return { - name: i18n('Addresses'), - default: [], - values: { _none: i18n('This server hosts no .onion addresses') }, - disabled: ['_none'], - } - - return { - name: i18n('Addresses'), - default: [], - values, - minLength: 1, - } - }), -}) - -export const deleteOnionAddresses = sdk.Action.withInput( - // id - 'delete-onion-addresses', - - // metadata - async () => ({ - name: i18n('Delete Onion Addresses'), - description: i18n( - 'Delete .onion addresses this server hosts, including any no longer attached to an interface', - ), - warning: i18n( - 'Each address you delete is gone for good: its key is destroyed with it.', - ), - allowedStatuses: 'any', - group: null, - visibility: 'enabled', - }), - - // input spec - inputSpec, - - // pre-fill - async () => null, - - // execution - async ({ effects, input }) => { - const onionServices = structuredClone( - (await torrc.read((t) => t.onionServices).once()) ?? {}, - ) - const deleted: string[] = [] - for (const key of input.addresses) { - const [packageId, hostId, index] = key.split('/') - if (!onionServices[packageId]?.[hostId]?.[index]) continue - deleted.push((await onionHostname(packageId, hostId, index)) ?? key) - await rm(sdk.volumes.tor.subpath(hsDir(packageId, hostId, index)), { - recursive: true, - force: true, - }) - dropOnionService(onionServices, packageId, hostId, index) - } - if (!deleted.length) throw new Error(i18n('No addresses selected')) - - await torrc.merge(effects, { onionServices }) - - return { - version: '1' as const, - title: i18n('Onion Addresses Deleted'), - message: deleted.join('\n'), - result: null, - } - }, -) diff --git a/startos/actions/deleteOnionService.ts b/startos/actions/deleteOnionService.ts index 16efaf1..182941d 100644 --- a/startos/actions/deleteOnionService.ts +++ b/startos/actions/deleteOnionService.ts @@ -1,7 +1,12 @@ -import { rm } from 'fs/promises' -import { dropOnionService, hsDir, torrc } from '../fileModels/torrc' +import { + parseOnionId, + present, + storeJson, + writeOnions, +} from '../fileModels/store.json' import { i18n } from '../i18n' import { sdk } from '../sdk' +import { onionHostname, requireOwner } from '../utils/onions' const { InputSpec, Value } = sdk @@ -27,10 +32,13 @@ export const deleteOnionService = sdk.Action.withInput( async () => ({ name: i18n('Delete Onion Service'), description: i18n('Remove a Tor onion service'), - warning: i18n('Confirm you would like to delete this .onion address'), + warning: i18n( + 'This removes the .onion address from this interface. Its key is kept, so you can attach the address again, until you delete it with Delete Unused Onion Addresses.', + ), allowedStatuses: 'any', group: null, visibility: 'hidden', + access: 'public', }), // input spec @@ -40,49 +48,27 @@ export const deleteOnionService = sdk.Action.withInput( async () => null, // execution - async ({ effects, input }) => { + async ({ effects, input, caller }) => { const { packageId, hostId, hostname, port, ssl } = input.urlPluginMetadata + requireOwner(caller, packageId) - const config = await torrc.read().once() - const onionServices = structuredClone(config?.onionServices || {}) - const services = onionServices[packageId]?.[hostId] - if (!services) return + const onions = present(await storeJson.read((s) => s.onions).once()) - for (const [key, svc] of Object.entries(services)) { - if (!svc) continue - let onionHostname: string | undefined - try { - const content = await sdk.volumes.tor.readFile( - `${hsDir(packageId, hostId, key)}/hostname`, - ) - onionHostname = content.toString().trim() - } catch { - continue - } - - if (onionHostname !== hostname) continue - - // Found the matching entry — remove the specific port - // Use undefined (not delete) so merge() removes the key from the file - const portKey = port !== null ? String(port) : null - if (portKey && svc.ports[portKey]) { - const portInfo = svc.ports[portKey] - if ((portInfo.ssl || false) === ssl) { - ;(svc.ports as any)[portKey] = undefined - } - } + for (const [id, onion] of Object.entries(onions)) { + const owner = parseOnionId(id) + if (owner.packageId !== packageId || owner.hostId !== hostId) continue + if ((await onionHostname(id, onion)) !== hostname) continue - // If no ports remain, remove the entire entry and key material - if (Object.values(svc.ports).every((v) => v === undefined)) { - await rm(sdk.volumes.tor.subpath(hsDir(packageId, hostId, key)), { - recursive: true, - force: true, - }) - dropOnionService(onionServices, packageId, hostId, key) + // Detach only. The key stays until the user deletes it. + onions[id] = { + ...onion, + ports: onion.ports.filter( + (p) => !(p.externalPort === port && p.ssl === ssl), + ), } break } - await torrc.merge(effects, { onionServices }) + await writeOnions(effects, onions) }, ) diff --git a/startos/actions/deleteUnusedAddresses.ts b/startos/actions/deleteUnusedAddresses.ts new file mode 100644 index 0000000..4afd678 --- /dev/null +++ b/startos/actions/deleteUnusedAddresses.ts @@ -0,0 +1,114 @@ +import { rm } from 'fs/promises' +import { T } from '@start9labs/start-sdk' +import { + hsDir, + parseOnionId, + present, + storeJson, + writeOnions, +} from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { isServed, onionHostname } from '../utils/onions' + +const { InputSpec, Value } = sdk + +/** The addresses no interface is using: no port of theirs resolves. */ +async function unusedAddresses(effects: T.Effects) { + const onions = present(await storeJson.read((s) => s.onions).once()) + const unused: string[] = [] + for (const [id, onion] of Object.entries(onions)) { + if (!(await isServed(effects, id, onion))) unused.push(id) + } + return { onions, unused } +} + +const inputSpec = InputSpec.of({ + addresses: Value.dynamicMultiselect(async ({ effects }) => { + const { onions, unused } = await unusedAddresses(effects) + + if (!unused.length) + return { + name: i18n('Unused Addresses'), + default: [], + values: { _none: i18n('This server has no unused .onion addresses') }, + disabled: ['_none'], + } + + const values: Record = {} + for (const id of unused) { + const { packageId, hostId } = parseOnionId(id) + const hostname = + (await onionHostname(id, onions[id])) ?? + i18n('address not generated yet') + values[id] = `${hostname} — ${packageId}/${hostId}` + } + return { + name: i18n('Unused Addresses'), + default: unused, + values, + minLength: 1, + } + }), +}) + +export const deleteUnusedAddresses = sdk.Action.withInput( + // id + 'delete-unused-addresses', + + // metadata + async () => ({ + name: i18n('Delete Unused Onion Addresses'), + description: i18n( + 'Permanently delete the keys of .onion addresses that no interface is using', + ), + warning: i18n( + 'Each address you delete is gone for good: its key is destroyed with it.', + ), + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // input spec + inputSpec, + + // pre-fill + async () => null, + + // execution + async ({ effects, input }) => { + if (!input.addresses.length) throw new Error(i18n('No addresses selected')) + + // An address can come into use while the form is open. Delete nothing then. + const { onions, unused } = await unusedAddresses(effects) + const inUse = input.addresses.filter((id) => !unused.includes(id)) + if (inUse.length) { + const names = await Promise.all( + inUse.map(async (id) => (await onionHostname(id, onions[id])) ?? id), + ) + throw new Error( + `${i18n('Nothing was deleted, because these addresses are now in use:')} ${names.join(', ')}`, + ) + } + + const deleted: string[] = [] + for (const id of input.addresses) { + deleted.push((await onionHostname(id, onions[id])) ?? id) + await rm(sdk.volumes.tor.subpath(hsDir(id, onions[id])), { + recursive: true, + force: true, + }) + delete onions[id] + } + + await writeOnions(effects, onions) + + return { + version: '1' as const, + title: i18n('Onion Addresses Deleted'), + message: deleted.join('\n'), + result: null, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts index 36ce8b7..b93e573 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -1,13 +1,13 @@ import { sdk } from '../sdk' import { addOnionService } from './addOnionService' import { deleteOnionService } from './deleteOnionService' -import { deleteOnionAddresses } from './deleteOnionAddresses' -import { configureRelay } from './configureRelay' +import { deleteUnusedAddresses } from './deleteUnusedAddresses' import { resetConnection } from './resetConnection' +import { automaticRecovery } from './automaticRecovery' export const actions = sdk.Actions.of() .addAction(addOnionService) .addAction(deleteOnionService) - .addAction(deleteOnionAddresses) - .addAction(configureRelay) + .addAction(deleteUnusedAddresses) .addAction(resetConnection) + .addAction(automaticRecovery) diff --git a/startos/actions/resetConnection.ts b/startos/actions/resetConnection.ts index 51018d6..e423d3a 100644 --- a/startos/actions/resetConnection.ts +++ b/startos/actions/resetConnection.ts @@ -13,7 +13,7 @@ export const resetConnection = sdk.Action.withoutInput( 'Clear the network data Tor has saved and restart it, so it picks new entry nodes. Use this if Tor is stuck connecting or keeps dropping.', ), warning: i18n( - 'Tor will be offline for a few minutes while it reconnects. Your .onion addresses are not affected.', + 'Tor will be offline for a few minutes. Your .onion addresses are not affected. Each reset slightly raises the chance that someone watching for it can link your .onion addresses to this server, so use it when Tor is stuck, not routinely.', ), allowedStatuses: 'only-running', group: null, diff --git a/startos/backups.ts b/startos/backups.ts index d6df56b..e68c87f 100644 --- a/startos/backups.ts +++ b/startos/backups.ts @@ -1,5 +1,5 @@ import { sdk } from './sdk' export const { createBackup, restoreInit } = sdk.setupBackups( - async ({ effects }) => sdk.Backups.ofVolumes('tor'), + async ({ effects }) => sdk.Backups.ofVolumes('tor', 'startos'), ) diff --git a/startos/dependencies.ts b/startos/dependencies.ts index 7221c4b..b97fd7a 100644 --- a/startos/dependencies.ts +++ b/startos/dependencies.ts @@ -1,5 +1,3 @@ import { sdk } from './sdk' -export const setDependencies = sdk.setupDependencies( - async ({ effects }) => ({}), -) +export const dependencies = sdk.Dependencies.of() diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts new file mode 100644 index 0000000..10dcfb2 --- /dev/null +++ b/startos/fileModels/store.json.ts @@ -0,0 +1,103 @@ +import { access } from 'node:fs/promises' +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +const portShape = z.looseObject({ + /** The port the .onion address answers on. */ + externalPort: z.number(), + /** The binding it forwards to. The forward target itself is never stored. */ + internalPort: z.number(), + ssl: z.boolean(), +}) + +const onionShape = z.looseObject({ + keyId: z.string().optional(), + /** + * What the address forwards. Nothing here is ever removed automatically: a + * mapping whose binding is gone is simply not rendered, and resumes if the + * binding returns. + */ + ports: z.array(portShape).catch([]), +}) + +const shape = z.looseObject({ + /** Keyed by `onionId`. An entry that fails to parse is dropped alone. */ + onions: z + .record(z.string(), onionShape.optional().catch(undefined)) + .catch({}), + automaticRecovery: z.boolean().catch(true), +}) + +export type Onion = z.infer +export type OnionPort = z.infer +export type Onions = Record + +export const storeJson = FileHelper.json( + { base: sdk.volumes.startos, subpath: 'store.json' }, + shape, +) + +/** Named in the torrc marker. */ +export const STORE_LOCATION = "store.json on this package's startos volume" + +export const onionId = (packageId: string, hostId: string, index: string) => + `${packageId}/${hostId}/${index}` + +export function parseOnionId(id: string) { + const [packageId, hostId, index] = id.split('/') + return { packageId, hostId, index } +} + +/** The HiddenServiceDir, relative to the tor volume. */ +export function hsDir(id: string, onion?: Onion) { + const { packageId, hostId, index } = parseOnionId(onion?.keyId ?? id) + return `hidden_services/${packageId}/${hostId}/hs_${index}` +} + +/** The entries that parsed, without the `undefined` holes. */ +export function present(onions: z.infer['onions'] | null) { + return Object.fromEntries( + Object.entries(onions ?? {}).filter((e): e is [string, Onion] => !!e[1]), + ) +} + +/** Replaces the whole onion record: `merge` cannot drop one entry of it. */ +export async function writeOnions( + effects: Parameters[0], + onions: Onions, + options?: { allowWriteAfterConst?: boolean }, +) { + const store = await storeJson.read().once() + await storeJson.write( + effects, + { ...(store ?? { automaticRecovery: true }), onions }, + options, + ) +} + +/** + * The next index for a host's onions. An index names a key directory, so one + * that still exists on disk is skipped even when no entry claims it. + */ +export async function nextIndex( + onions: Onions, + packageId: string, + hostId: string, +): Promise { + const taken = Object.keys(onions) + .map(parseOnionId) + .filter((o) => o.packageId === packageId && o.hostId === hostId) + .map((o) => Number(o.index)) + .filter((n) => !isNaN(n)) + let index = taken.reduce((max, n) => Math.max(max, n + 1), 0) + while ( + await access( + sdk.volumes.tor.subpath(hsDir(onionId(packageId, hostId, `${index}`))), + ).then( + () => true, + () => false, + ) + ) + index += 1 + return `${index}` +} diff --git a/startos/fileModels/torrc.ts b/startos/fileModels/torrc.ts index 599fbee..c6c1be4 100644 --- a/startos/fileModels/torrc.ts +++ b/startos/fileModels/torrc.ts @@ -1,346 +1,11 @@ -import { FileHelper, z } from '@start9labs/start-sdk' +import { FileHelper } from '@start9labs/start-sdk' import { sdk } from '../sdk' -import { socksPort } from '../utils' - -const portInfoShape = z.object({ - target: z.string().nullable(), - ssl: z.boolean(), - internalPort: z.number(), -}) -type PortInfo = z.infer - -export const onionServiceEntryShape = z - .object({ - ports: z.record(z.string(), portInfoShape.optional().catch(undefined)), - }) - .catch({ ports: {} }) - -export const relayShape = z.object({ - enabled: z.boolean().catch(false), - nickname: z.string().min(1).optional().catch(undefined), - contactInfo: z.string().optional().catch(undefined), - bridge: z.boolean().catch(false), - orPort: z.number().catch(9001), - bandwidthRate: z.number().catch(1024), - bandwidthBurst: z.number().catch(2048), -}) - -const shape = z.object({ - onionServices: z - .record( - z.string(), - z - .record( - z.string(), - z - .record( - z.string(), - onionServiceEntryShape.optional().catch(undefined), - ) - .optional() - .catch(undefined), - ) - .optional() - .catch(undefined), - ) - .catch({}), - relay: relayShape.catch({ - enabled: false, - bridge: false, - orPort: 9001, - bandwidthRate: 1024, - bandwidthBurst: 2048, - }), - // Not user configuration: what the relay advertises, derived from the OR - // binding by init/advertiseRelay. `orPort` is the configured port it was - // derived for, so changing the OR port drops it until it is derived again. - advertise: z - .object({ - orPort: z.number().nullable().catch(null), - address: z.string().nullable().catch(null), - port: z.number().nullable().catch(null), - ipv4Only: z.boolean().catch(false), - }) - .catch({ orPort: null, address: null, port: null, ipv4Only: false }), -}) - -export type TorrcConfig = z.infer - -export function hsDir(packageId: string, hostId: string, index: string) { - return `hidden_services/${packageId}/${hostId}/hs_${index}` -} /** - * Marks an entry `undefined` in place, which `merge` drops from the file, and - * does the same to the host and package records it empties. + * The torrc as text. Nothing is parsed back out of it: `init/renderTorrc` + * writes it from the store, below the section the user owns. */ -export function dropOnionService( - onionServices: TorrcConfig['onionServices'], - packageId: string, - hostId: string, - index: string, -) { - const hosts = onionServices[packageId] - const services = hosts?.[hostId] - if (!hosts || !services) return - ;(services as any)[index] = undefined - if (Object.values(services).every((v) => v === undefined)) - (hosts as any)[hostId] = undefined - if (Object.values(hosts).every((v) => v === undefined)) - (onionServices as any)[packageId] = undefined -} - -/** - * Returns the next sequential numeric key (as a string) for a record. - * Gaps from deleted keys are intentionally NOT reused, since keys map to - * HiddenServiceDir paths containing cryptographic key material. - */ -export function nextKey(record: Record): string { - return String( - Object.keys(record) - .map(Number) - .filter((n) => !isNaN(n)) - .reduce((acc, x) => (x >= acc ? x + 1 : acc), 0), - ) -} - -/** - * Serializes structured config to a torrc file. - * Embeds `# @service`, `# @ssl`, and `# @internalPort` comment annotations so - * fromFile() can reconstruct the structured data (packageId, hostId, SSL - * status, upstream internal port) on read. - * A port with a null target is parked: its directive is written commented out, - * and so is the HiddenServiceDir once every port of the entry is. - */ -function toFile(config: TorrcConfig): string { - const lines: string[] = [ - `SocksPort 0.0.0.0:${socksPort}`, - 'DataDirectory /var/lib/tor', - 'ControlSocket /var/lib/tor/control.sock', - '', - ] - - const onionServices = config.onionServices || {} - for (const [packageId, hosts] of Object.entries(onionServices)) { - if (!hosts) continue - for (const [hostId, services] of Object.entries(hosts)) { - if (!services) continue - Object.entries(services).forEach(([index, svc]) => { - if (!svc) return - const ports = Object.entries(svc.ports).filter( - (e): e is [string, PortInfo] => !!e[1], - ) - if (ports.length === 0) return - const served = ports.some(([, p]) => p.target !== null) - lines.push(`# @service ${packageId} ${hostId}`) - lines.push( - `${served ? '' : '#'}HiddenServiceDir /var/lib/tor/${hsDir(packageId, hostId, index)}/`, - ) - for (const [externalPort, portInfo] of ports) { - if (portInfo.ssl) lines.push(`# @ssl ${portInfo.internalPort}`) - else lines.push(`# @internalPort ${portInfo.internalPort}`) - lines.push( - portInfo.target === null - ? `#HiddenServicePort ${externalPort}` - : `HiddenServicePort ${externalPort} ${portInfo.target}`, - ) - } - lines.push('') - }) - } - } - - const relay = config.relay - if (relay?.enabled) { - const advertise = - config.advertise?.orPort === relay.orPort ? config.advertise : null - // StartOS may assign the binding a different external port than the one - // Tor listens on: advertise the assigned port, listen on the configured one. - // A bare ORPort is an IPv6 ORPort too, and with no IPv6 address to publish - // Tor logs a notice about it every hour. - const family = advertise?.ipv4Only ? ' IPv4Only' : '' - if (advertise?.port && advertise.port !== relay.orPort) { - lines.push(`ORPort ${advertise.port} NoListen${family}`) - lines.push(`ORPort ${relay.orPort} NoAdvertise${family}`) - } else { - lines.push(`ORPort ${relay.orPort}${family}`) - } - if (advertise?.address) lines.push(`Address ${advertise.address}`) - if (relay.nickname) lines.push(`Nickname ${relay.nickname}`) - if (relay.contactInfo) lines.push(`ContactInfo ${relay.contactInfo}`) - if (relay.bridge) lines.push('BridgeRelay 1') - lines.push(`RelayBandwidthRate ${relay.bandwidthRate} KBytes`) - lines.push(`RelayBandwidthBurst ${relay.bandwidthBurst} KBytes`) - lines.push('ExitRelay 0') - lines.push('') - } - - return lines.join('\n') -} - -const kbytes = (n: string, unit: string) => - parseInt(n, 10) * (unit === 'M' ? 1024 : 1) - -/** - * Parses a torrc file back into structured config. - * Uses a state machine to group HiddenServiceDir/HiddenServicePort blocks, - * reading `# @service`, `# @ssl`, and `# @internalPort` annotations to - * recover metadata. - * Bandwidth values are stored in KBytes; a torrc written by an earlier - * release carries MBytes. - */ -function fromFile(raw: string): unknown { - const res: z.infer = { - onionServices: {}, - relay: { - enabled: false, - bridge: false, - orPort: 9001, - bandwidthRate: 1024, - bandwidthBurst: 2048, - }, - advertise: { orPort: null, address: null, port: null, ipv4Only: false }, - } - - const lines = raw.split('\n') - let currentPackageId: string | null = null - let currentHostId: string | null = null - let currentIndex: string | null = null - let currentPorts: Record = {} - let nextSslInternalPort: number | null = null - let nextInternalPort: number | null = null - let listens = false - - function flushCurrent() { - if ( - currentPackageId && - currentHostId && - currentIndex && - Object.keys(currentPorts).length > 0 - ) { - if (!res.onionServices[currentPackageId]) - res.onionServices[currentPackageId] = {} - if (!res.onionServices[currentPackageId]![currentHostId]) - res.onionServices[currentPackageId]![currentHostId] = {} - res.onionServices[currentPackageId]![currentHostId]![currentIndex] = { - ports: currentPorts, - } - } - currentPackageId = null - currentHostId = null - currentIndex = null - currentPorts = {} - nextSslInternalPort = null - nextInternalPort = null - } - - for (const line of lines) { - const trimmed = line.trim() - - const serviceMatch = trimmed.match(/^# @service (\S+) (\S+)$/) - if (serviceMatch) { - flushCurrent() - currentPackageId = serviceMatch[1] - currentHostId = serviceMatch[2] - continue - } - - const sslMatch = trimmed.match(/^# @ssl (\d+)$/) - if (sslMatch) { - nextSslInternalPort = parseInt(sslMatch[1], 10) - continue - } - - const internalPortMatch = trimmed.match(/^# @internalPort (\d+)$/) - if (internalPortMatch) { - nextInternalPort = parseInt(internalPortMatch[1], 10) - continue - } - - const hsDirMatch = trimmed.match( - /^#?\s*HiddenServiceDir\s.*\/hs_([^/]+)\/?$/, - ) - if (hsDirMatch) { - currentIndex = hsDirMatch[1] - continue - } - - const portMatch = trimmed.match( - /^(#?)\s*HiddenServicePort (\d+)(?:\s+(\S+))?/, - ) - if (portMatch && currentPackageId) { - const target = portMatch[1] || !portMatch[3] ? null : portMatch[3] - if (nextSslInternalPort !== null) { - currentPorts[portMatch[2]] = { - target, - ssl: true, - internalPort: nextSslInternalPort, - } - nextSslInternalPort = null - } else { - // Prefer the `# @internalPort` annotation; fall back to the target - // port for legacy entries written before the annotation existed - // (where it equals the lxcbr0 NAT port, not the upstream internal - // port, for SSL-wrapped/port-shifted bindings). - const internalPort = - nextInternalPort ?? - (target === null - ? NaN - : parseInt(target.slice(target.lastIndexOf(':') + 1), 10)) - currentPorts[portMatch[2]] = { target, ssl: false, internalPort } - nextInternalPort = null - } - continue - } - - let m - if ((m = trimmed.match(/^ORPort (\d+)(.*)/))) { - flushCurrent() - res.relay.enabled = true - const flags = m[2].split(/\s+/) - // A NoListen line carries the advertised port; the port Tor listens on - // is written without it. - if (flags.includes('NoListen')) res.advertise.port = parseInt(m[1], 10) - else { - res.relay.orPort = parseInt(m[1], 10) - listens = true - } - if (flags.includes('IPv4Only')) res.advertise.ipv4Only = true - } else if ((m = trimmed.match(/^Address (\S+)/))) { - res.advertise.address = m[1] - } else if ((m = trimmed.match(/^Nickname (.+)/))) { - res.relay.nickname = m[1] - } else if ((m = trimmed.match(/^ContactInfo (.+)/))) { - res.relay.contactInfo = m[1] - } else if (trimmed === 'BridgeRelay 1') { - res.relay.bridge = true - } else if ((m = trimmed.match(/^RelayBandwidthRate (\d+) ([KM])Bytes/))) { - res.relay.bandwidthRate = kbytes(m[1], m[2]) - } else if ((m = trimmed.match(/^RelayBandwidthBurst (\d+) ([KM])Bytes/))) { - res.relay.bandwidthBurst = kbytes(m[1], m[2]) - } - } - - flushCurrent() - // A NoListen line that lost its listening partner still names the relay's port. - if (res.advertise.port !== null && !listens) { - res.relay.orPort = res.advertise.port - res.advertise.port = null - } - if ( - res.advertise.address !== null || - res.advertise.port !== null || - res.advertise.ipv4Only - ) { - res.advertise.orPort = res.relay.orPort - } - - return res -} - -export const torrc = FileHelper.raw( - { base: sdk.volumes.tor, subpath: '/torrc' }, - toFile, - fromFile, - (data) => shape.parse(data), -) +export const torrcFile = FileHelper.string({ + base: sdk.volumes.tor, + subpath: 'torrc', +}) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index bc723bb..a7f8446 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -5,55 +5,48 @@ const dict = { 'Tor is not ready': 1, 'Private Key (optional)': 2, 'Base64-encoded ed25519 expanded private key for a vanity .onion address. Leave blank to auto-generate.': 3, - 'Configure Relay': 4, - 'Configure Tor relay and bridge settings': 5, - 'Tor SOCKS Proxy': 6, - Enabled: 7, - Nickname: 8, - 'Contact Info': 9, - 'Bridge Mode': 10, - 'OR Port': 11, - 'Bandwidth Rate': 12, - 'Bandwidth Burst': 13, - 'Tor Relay OR Port': 14, - 'Tor relay port for the Tor network': 15, - 'Add Onion Service': 16, - 'Add a Tor onion service for this URL': 17, - 'Delete Onion Service': 18, - 'Remove a Tor onion service': 19, - SSL: 20, - 'Serve this address with SSL': 21, - Address: 22, - 'Create new address': 23, - 'Confirm you would like to delete this .onion address': 24, - 'This onion address already has an SSL binding for this port': 25, - 'This onion address already has a non-SSL binding for this port': 26, - 'Tor is bootstrapped but cannot build circuits': 27, - 'Tor reset its connection but still cannot connect. Check the server’s internet connection.': 28, - 'Reset Tor Connection': 29, - 'Clear the network data Tor has saved and restart it, so it picks new entry nodes. Use this if Tor is stuck connecting or keeps dropping.': 30, - 'Tor will be offline for a few minutes while it reconnects. Your .onion addresses are not affected.': 31, - 'Reset Started': 32, - 'Tor is restarting and will reconnect with new entry nodes. This takes a few minutes.': 33, - 'Tor requires at least 75 KB/s for a relay.': 34, - 'Must be at least the Bandwidth Rate.': 35, - 'Bandwidth Burst must be at least the Bandwidth Rate.': 36, - 'Relay Reachability': 37, - 'Relay mode is off': 38, - 'Reachable from the internet': 39, - 'Testing whether the relay is reachable from the internet': 40, - 'Not reachable from the internet. Enable the Public address on the Tor Relay OR Port interface.': 41, - 'Tor could not reach the relay from the internet. Check that the OR port is forwarded to this server, and that the Public address is enabled on only one connection.': 42, - 'Changing the OR port while the relay is on restarts Tor, so that it tests the new port.': 43, - 'Delete Onion Addresses': 44, - 'Delete .onion addresses this server hosts, including any no longer attached to an interface': 45, - 'Each address you delete is gone for good: its key is destroyed with it.': 46, - Addresses: 47, - 'This server hosts no .onion addresses': 48, - 'no longer attached to an interface': 49, - 'address not generated yet': 50, - 'No addresses selected': 51, - 'Onion Addresses Deleted': 52, + 'Tor SOCKS Proxy': 4, + 'Add Onion Service': 5, + 'Add a Tor onion service for this URL': 6, + 'Delete Onion Service': 7, + 'Remove a Tor onion service': 8, + SSL: 9, + 'Serve this address with SSL': 10, + Address: 11, + 'Create new address': 12, + 'This onion address already has an SSL binding for this port': 13, + 'This onion address already has a non-SSL binding for this port': 14, + 'Tor is bootstrapped but cannot build circuits': 15, + 'Tor reset its connection but still cannot connect. Check the server’s internet connection.': 16, + 'Reset Tor Connection': 17, + 'Clear the network data Tor has saved and restart it, so it picks new entry nodes. Use this if Tor is stuck connecting or keeps dropping.': 18, + 'Reset Started': 19, + 'Tor is restarting and will reconnect with new entry nodes. This takes a few minutes.': 20, + 'Each address you delete is gone for good: its key is destroyed with it.': 21, + 'address not generated yet': 22, + 'No addresses selected': 23, + 'Onion Addresses Deleted': 24, + 'Turn Off Automatic Recovery': 25, + 'Turn On Automatic Recovery': 26, + 'Automatic Recovery is on. When Tor loses its connection and cannot get it back, this service repairs it for you, so your services stay reachable without your attention.': 27, + 'Automatic Recovery is off, so Tor fails closed. If Tor gets stuck it stays offline, along with everything that depends on it, until you run Reset Tor Connection.': 28, + 'Turning this off makes Tor fail closed. If Tor gets stuck, it stays offline, along with everything that depends on it, until you run Reset Tor Connection. In exchange, no one can use automatic repairs to work out that your .onion addresses are hosted on this server. Turn it off only if hiding this server’s location matters more to you than staying reachable.': 29, + 'With this on, your services stay reachable without your attention. The cost: someone able to repeatedly interrupt this server’s internet connection, such as an internet provider, could use the automatic repairs to eventually work out that your .onion addresses are hosted here.': 30, + 'Automatic Recovery Is On': 31, + 'Automatic Recovery Is Off': 32, + 'Tor will repair a stuck connection without you.': 33, + 'Tor now fails closed: a stuck connection stays down until you run Reset Tor Connection.': 34, + 'Tor will be offline for a few minutes. Your .onion addresses are not affected. Each reset slightly raises the chance that someone watching for it can link your .onion addresses to this server, so use it when Tor is stuck, not routinely.': 35, + 'Tor cannot build circuits. Automatic Recovery is off, so run Reset Tor Connection if this does not clear.': 36, + 'Unused Addresses': 37, + 'This server has no unused .onion addresses': 38, + 'Delete Unused Onion Addresses': 39, + 'Permanently delete the keys of .onion addresses that no interface is using': 40, + 'This removes the .onion address from this interface. Its key is kept, so you can attach the address again, until you delete it with Delete Unused Onion Addresses.': 41, + 'Nothing was deleted, because these addresses are now in use:': 42, + 'This onion address is no longer available': 43, + 'This onion address belongs to another service': 44, + 'Only an unused onion address can move to another host': 45, } as const export type I18nKey = keyof typeof dict diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index b27f1e2..0dca818 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -6,219 +6,191 @@ export default { 1: 'Tor no está listo', 2: 'Clave privada (opcional)', 3: 'Clave privada expandida ed25519 codificada en base64 para una dirección .onion personalizada. Déjelo en blanco para generar automáticamente.', - 4: 'Configurar relé', - 5: 'Configurar ajustes de relé y puente Tor', - 6: 'Proxy SOCKS de Tor', - 7: 'Habilitado', - 8: 'Apodo', - 9: 'Información de contacto', - 10: 'Modo puente', - 11: 'Puerto OR', - 12: 'Tasa de ancho de banda', - 13: 'Ráfaga de ancho de banda', - 14: 'Puerto OR del relé Tor', - 15: 'Puerto de relé Tor para la red Tor', - 16: 'Agregar servicio onion', - 17: 'Agregar un servicio onion de Tor para esta URL', - 18: 'Eliminar servicio onion', - 19: 'Eliminar un servicio onion de Tor', - 20: 'SSL', - 21: 'Servir esta dirección con SSL', - 22: 'Dirección', - 23: 'Crear nueva dirección', - 24: 'Confirme que desea eliminar esta dirección .onion', - 25: 'Esta dirección onion ya tiene un enlace SSL para este puerto', - 26: 'Esta dirección onion ya tiene un enlace no SSL para este puerto', - 27: 'Tor completó el arranque pero no puede construir circuitos', - 28: 'Tor restableció su conexión pero sigue sin poder conectarse. Compruebe la conexión a internet del servidor.', - 29: 'Restablecer la conexión de Tor', - 30: 'Borra los datos de red que Tor tiene guardados y lo reinicia, para que elija nuevos nodos de entrada. Use esto si Tor se queda atascado al conectar o se cae repetidamente.', - 31: 'Tor estará fuera de servicio unos minutos mientras se reconecta. Sus direcciones .onion no se ven afectadas.', - 32: 'Restablecimiento iniciado', - 33: 'Tor se está reiniciando y se reconectará con nuevos nodos de entrada. Esto tarda unos minutos.', - 34: 'Tor requiere al menos 75 KB/s para un relé.', - 35: 'Debe ser al menos igual a la tasa de ancho de banda.', - 36: 'La ráfaga de ancho de banda debe ser al menos igual a la tasa de ancho de banda.', - 37: 'Accesibilidad del relé', - 38: 'El modo relé está desactivado', - 39: 'Accesible desde internet', - 40: 'Comprobando si el relé es accesible desde internet', - 41: 'No es accesible desde internet. Habilite la dirección pública en la interfaz Puerto OR del relé Tor.', - 42: 'Tor no pudo alcanzar el relé desde internet. Compruebe que el puerto OR esté redirigido a este servidor y que la dirección pública esté habilitada en una sola conexión.', - 43: 'Cambiar el puerto OR con el relé activado reinicia Tor para que compruebe el nuevo puerto.', - 44: 'Eliminar direcciones onion', - 45: 'Eliminar direcciones .onion alojadas en este servidor, incluidas las que ya no están vinculadas a ninguna interfaz', - 46: 'Cada dirección que elimine desaparece para siempre: su clave se destruye con ella.', - 47: 'Direcciones', - 48: 'Este servidor no aloja ninguna dirección .onion', - 49: 'ya no está vinculada a ninguna interfaz', - 50: 'dirección aún no generada', - 51: 'No se seleccionó ninguna dirección', - 52: 'Direcciones onion eliminadas', + 4: 'Proxy SOCKS de Tor', + 5: 'Agregar servicio onion', + 6: 'Agregar un servicio onion de Tor para esta URL', + 7: 'Eliminar servicio onion', + 8: 'Eliminar un servicio onion de Tor', + 9: 'SSL', + 10: 'Servir esta dirección con SSL', + 11: 'Dirección', + 12: 'Crear nueva dirección', + 13: 'Esta dirección onion ya tiene un enlace SSL para este puerto', + 14: 'Esta dirección onion ya tiene un enlace no SSL para este puerto', + 15: 'Tor completó el arranque pero no puede construir circuitos', + 16: 'Tor restableció su conexión pero sigue sin poder conectarse. Compruebe la conexión a internet del servidor.', + 17: 'Restablecer la conexión de Tor', + 18: 'Borra los datos de red que Tor tiene guardados y lo reinicia, para que elija nuevos nodos de entrada. Use esto si Tor se queda atascado al conectar o se cae repetidamente.', + 19: 'Restablecimiento iniciado', + 20: 'Tor se está reiniciando y se reconectará con nuevos nodos de entrada. Esto tarda unos minutos.', + 21: 'Cada dirección que elimine desaparece para siempre: su clave se destruye con ella.', + 22: 'dirección aún no generada', + 23: 'No se seleccionó ninguna dirección', + 24: 'Direcciones onion eliminadas', + 25: 'Desactivar la recuperación automática', + 26: 'Activar la recuperación automática', + 27: 'La recuperación automática está activada. Cuando Tor pierde la conexión y no logra recuperarla, este servicio la repara por usted, de modo que sus servicios siguen accesibles sin que tenga que ocuparse.', + 28: 'La recuperación automática está desactivada, así que Tor falla en cerrado. Si Tor se queda atascado, permanece sin conexión, junto con todo lo que depende de él, hasta que ejecute Restablecer la conexión de Tor.', + 29: 'Al desactivarlo, Tor falla en cerrado. Si Tor se queda atascado, permanece sin conexión, junto con todo lo que depende de él, hasta que ejecute Restablecer la conexión de Tor. A cambio, nadie puede aprovechar las reparaciones automáticas para averiguar que sus direcciones .onion están alojadas en este servidor. Desactívelo solo si ocultar la ubicación de este servidor le importa más que seguir accesible.', + 30: 'Con esto activado, sus servicios siguen accesibles sin que tenga que ocuparse. El coste: alguien capaz de interrumpir repetidamente la conexión a internet de este servidor, como un proveedor de internet, podría aprovechar las reparaciones automáticas para acabar averiguando que sus direcciones .onion están alojadas aquí.', + 31: 'La recuperación automática está activada', + 32: 'La recuperación automática está desactivada', + 33: 'Tor reparará una conexión atascada sin su intervención.', + 34: 'Tor ahora falla en cerrado: una conexión atascada sigue caída hasta que ejecute Restablecer la conexión de Tor.', + 35: 'Tor estará sin conexión unos minutos. Sus direcciones .onion no se ven afectadas. Cada restablecimiento aumenta ligeramente la probabilidad de que alguien que lo esté esperando pueda vincular sus direcciones .onion con este servidor, así que úselo cuando Tor esté atascado, no de forma rutinaria.', + 36: 'Tor no puede construir circuitos. La recuperación automática está desactivada, así que ejecute Restablecer la conexión de Tor si esto no se resuelve.', + 37: 'Direcciones sin usar', + 38: 'Este servidor no tiene direcciones .onion sin usar', + 39: 'Eliminar direcciones onion sin usar', + 40: 'Elimina de forma permanente las claves de las direcciones .onion que ninguna interfaz está usando', + 41: 'Esto quita la dirección .onion de esta interfaz. Su clave se conserva, de modo que puede volver a asociar la dirección, hasta que la elimine con Eliminar direcciones onion sin usar.', + 42: 'No se eliminó nada, porque estas direcciones ahora están en uso:', + 43: 'Esta dirección onion ya no está disponible', + 44: 'Esta dirección onion pertenece a otro servicio', + 45: 'Solo una dirección onion sin usar puede trasladarse a otro host', }, de_DE: { 0: 'Tor läuft', 1: 'Tor ist nicht bereit', 2: 'Privater Schlüssel (optional)', 3: 'Base64-codierter erweiterter ed25519-Privatschlüssel für eine benutzerdefinierte .onion-Adresse. Leer lassen für automatische Generierung.', - 4: 'Relay konfigurieren', - 5: 'Tor-Relay- und Bridge-Einstellungen konfigurieren', - 6: 'Tor SOCKS-Proxy', - 7: 'Aktiviert', - 8: 'Spitzname', - 9: 'Kontaktinformationen', - 10: 'Bridge-Modus', - 11: 'OR-Port', - 12: 'Bandbreitenrate', - 13: 'Bandbreitenstoß', - 14: 'Tor-Relay-OR-Port', - 15: 'Tor-Relay-Port für das Tor-Netzwerk', - 16: 'Onion-Dienst hinzufügen', - 17: 'Einen Tor-Onion-Dienst für diese URL hinzufügen', - 18: 'Onion-Dienst löschen', - 19: 'Einen Tor-Onion-Dienst entfernen', - 20: 'SSL', - 21: 'Diese Adresse mit SSL bereitstellen', - 22: 'Adresse', - 23: 'Neue Adresse erstellen', - 24: 'Bestätigen Sie, dass Sie diese .onion-Adresse löschen möchten', - 25: 'Diese Onion-Adresse hat bereits eine SSL-Bindung für diesen Port', - 26: 'Diese Onion-Adresse hat bereits eine Nicht-SSL-Bindung für diesen Port', - 27: 'Tor ist gestartet, kann aber keine Kanäle aufbauen', - 28: 'Tor hat seine Verbindung zurückgesetzt, kann sich aber weiterhin nicht verbinden. Prüfen Sie die Internetverbindung des Servers.', - 29: 'Tor-Verbindung zurücksetzen', - 30: 'Löscht die von Tor gespeicherten Netzwerkdaten und startet es neu, damit es neue Eingangsknoten wählt. Nutzen Sie dies, wenn Tor beim Verbinden hängt oder die Verbindung ständig abbricht.', - 31: 'Tor ist einige Minuten offline, während es sich neu verbindet. Ihre .onion-Adressen sind nicht betroffen.', - 32: 'Zurücksetzen gestartet', - 33: 'Tor startet neu und verbindet sich mit neuen Eingangsknoten. Das dauert einige Minuten.', - 34: 'Tor benötigt für ein Relay mindestens 75 KB/s.', - 35: 'Muss mindestens der Bandbreitenrate entsprechen.', - 36: 'Der Bandbreitenstoß muss mindestens der Bandbreitenrate entsprechen.', - 37: 'Relay-Erreichbarkeit', - 38: 'Der Relay-Modus ist aus', - 39: 'Aus dem Internet erreichbar', - 40: 'Prüfe, ob das Relay aus dem Internet erreichbar ist', - 41: 'Aus dem Internet nicht erreichbar. Aktivieren Sie die öffentliche Adresse auf der Schnittstelle Tor-Relay-OR-Port.', - 42: 'Tor konnte das Relay aus dem Internet nicht erreichen. Prüfen Sie, ob der OR-Port an diesen Server weitergeleitet wird und die öffentliche Adresse nur auf einer Verbindung aktiviert ist.', - 43: 'Wird der OR-Port bei eingeschaltetem Relay geändert, startet Tor neu, damit es den neuen Port prüft.', - 44: 'Onion-Adressen löschen', - 45: 'Löscht .onion-Adressen, die dieser Server hostet, auch solche, die keiner Schnittstelle mehr zugeordnet sind', - 46: 'Jede gelöschte Adresse ist unwiderruflich verloren: ihr Schlüssel wird mit ihr vernichtet.', - 47: 'Adressen', - 48: 'Dieser Server hostet keine .onion-Adressen', - 49: 'keiner Schnittstelle mehr zugeordnet', - 50: 'Adresse noch nicht erzeugt', - 51: 'Keine Adressen ausgewählt', - 52: 'Onion-Adressen gelöscht', + 4: 'Tor SOCKS-Proxy', + 5: 'Onion-Dienst hinzufügen', + 6: 'Einen Tor-Onion-Dienst für diese URL hinzufügen', + 7: 'Onion-Dienst löschen', + 8: 'Einen Tor-Onion-Dienst entfernen', + 9: 'SSL', + 10: 'Diese Adresse mit SSL bereitstellen', + 11: 'Adresse', + 12: 'Neue Adresse erstellen', + 13: 'Diese Onion-Adresse hat bereits eine SSL-Bindung für diesen Port', + 14: 'Diese Onion-Adresse hat bereits eine Nicht-SSL-Bindung für diesen Port', + 15: 'Tor ist gestartet, kann aber keine Kanäle aufbauen', + 16: 'Tor hat seine Verbindung zurückgesetzt, kann sich aber weiterhin nicht verbinden. Prüfen Sie die Internetverbindung des Servers.', + 17: 'Tor-Verbindung zurücksetzen', + 18: 'Löscht die von Tor gespeicherten Netzwerkdaten und startet es neu, damit es neue Eingangsknoten wählt. Nutzen Sie dies, wenn Tor beim Verbinden hängt oder die Verbindung ständig abbricht.', + 19: 'Zurücksetzen gestartet', + 20: 'Tor startet neu und verbindet sich mit neuen Eingangsknoten. Das dauert einige Minuten.', + 21: 'Jede gelöschte Adresse ist unwiderruflich verloren: ihr Schlüssel wird mit ihr vernichtet.', + 22: 'Adresse noch nicht erzeugt', + 23: 'Keine Adressen ausgewählt', + 24: 'Onion-Adressen gelöscht', + 25: 'Automatische Wiederherstellung ausschalten', + 26: 'Automatische Wiederherstellung einschalten', + 27: 'Die automatische Wiederherstellung ist eingeschaltet. Verliert Tor seine Verbindung und bekommt sie nicht zurück, repariert dieser Dienst sie für Sie, sodass Ihre Dienste ohne Ihr Zutun erreichbar bleiben.', + 28: 'Die automatische Wiederherstellung ist ausgeschaltet, Tor verhält sich also fail-closed. Bleibt Tor hängen, bleibt es offline, zusammen mit allem, was davon abhängt, bis Sie „Tor-Verbindung zurücksetzen“ ausführen.', + 29: 'Ausgeschaltet verhält sich Tor fail-closed. Bleibt Tor hängen, bleibt es offline, zusammen mit allem, was davon abhängt, bis Sie „Tor-Verbindung zurücksetzen“ ausführen. Dafür kann niemand die automatischen Reparaturen nutzen, um herauszufinden, dass Ihre .onion-Adressen auf diesem Server gehostet werden. Schalten Sie es nur aus, wenn Ihnen das Verbergen des Standorts dieses Servers wichtiger ist als die Erreichbarkeit.', + 30: 'Eingeschaltet bleiben Ihre Dienste ohne Ihr Zutun erreichbar. Der Preis: Wer die Internetverbindung dieses Servers wiederholt unterbrechen kann, etwa ein Internetanbieter, könnte die automatischen Reparaturen nutzen, um irgendwann herauszufinden, dass Ihre .onion-Adressen hier gehostet werden.', + 31: 'Automatische Wiederherstellung ist eingeschaltet', + 32: 'Automatische Wiederherstellung ist ausgeschaltet', + 33: 'Tor repariert eine hängende Verbindung ohne Ihr Zutun.', + 34: 'Tor verhält sich jetzt fail-closed: Eine hängende Verbindung bleibt unterbrochen, bis Sie „Tor-Verbindung zurücksetzen“ ausführen.', + 35: 'Tor ist einige Minuten offline. Ihre .onion-Adressen sind nicht betroffen. Jedes Zurücksetzen erhöht geringfügig die Chance, dass jemand, der darauf wartet, Ihre .onion-Adressen mit diesem Server verknüpfen kann. Nutzen Sie es also, wenn Tor hängt, nicht routinemäßig.', + 36: 'Tor kann keine Kanäle aufbauen. Die automatische Wiederherstellung ist ausgeschaltet; führen Sie „Tor-Verbindung zurücksetzen“ aus, falls sich das nicht von selbst löst.', + 37: 'Ungenutzte Adressen', + 38: 'Dieser Server hat keine ungenutzten .onion-Adressen', + 39: 'Ungenutzte Onion-Adressen löschen', + 40: 'Löscht dauerhaft die Schlüssel von .onion-Adressen, die keine Schnittstelle verwendet', + 41: 'Dadurch wird die .onion-Adresse von dieser Schnittstelle entfernt. Ihr Schlüssel bleibt erhalten, sodass Sie die Adresse wieder zuordnen können, bis Sie ihn mit „Ungenutzte Onion-Adressen löschen“ löschen.', + 42: 'Es wurde nichts gelöscht, weil diese Adressen inzwischen verwendet werden:', + 43: 'Diese Onion-Adresse ist nicht mehr verfügbar', + 44: 'Diese Onion-Adresse gehört zu einem anderen Dienst', + 45: 'Nur eine ungenutzte Onion-Adresse kann auf einen anderen Host verschoben werden', }, pl_PL: { 0: 'Tor działa', 1: 'Tor nie jest gotowy', 2: 'Klucz prywatny (opcjonalny)', 3: 'Rozszerzony klucz prywatny ed25519 zakodowany w base64 dla niestandardowego adresu .onion. Pozostaw puste, aby wygenerować automatycznie.', - 4: 'Konfiguruj przekaźnik', - 5: 'Konfiguruj ustawienia przekaźnika i mostu Tor', - 6: 'Proxy SOCKS Tor', - 7: 'Włączony', - 8: 'Pseudonim', - 9: 'Informacje kontaktowe', - 10: 'Tryb mostu', - 11: 'Port OR', - 12: 'Szybkość przepustowości', - 13: 'Skok przepustowości', - 14: 'Port OR przekaźnika Tor', - 15: 'Port przekaźnika Tor dla sieci Tor', - 16: 'Dodaj usługę onion', - 17: 'Dodaj usługę onion Tor dla tego adresu URL', - 18: 'Usuń usługę onion', - 19: 'Usuń usługę onion Tor', - 20: 'SSL', - 21: 'Obsługuj ten adres z SSL', - 22: 'Adres', - 23: 'Utwórz nowy adres', - 24: 'Potwierdź, że chcesz usunąć ten adres .onion', - 25: 'Ten adres onion ma już powiązanie SSL dla tego portu', - 26: 'Ten adres onion ma już powiązanie bez SSL dla tego portu', - 27: 'Tor zakończył uruchamianie, ale nie może budować obwodów', - 28: 'Tor zresetował połączenie, ale nadal nie może się połączyć. Sprawdź połączenie internetowe serwera.', - 29: 'Zresetuj połączenie Tor', - 30: 'Czyści zapisane przez Tora dane sieci i uruchamia go ponownie, aby wybrał nowe węzły wejściowe. Użyj tego, jeśli Tor utknął przy łączeniu lub ciągle się rozłącza.', - 31: 'Tor będzie niedostępny przez kilka minut, gdy będzie się łączył ponownie. Twoje adresy .onion nie są naruszone.', - 32: 'Rozpoczęto resetowanie', - 33: 'Tor uruchamia się ponownie i połączy się z nowymi węzłami wejściowymi. Zajmie to kilka minut.', - 34: 'Tor wymaga co najmniej 75 KB/s dla przekaźnika.', - 35: 'Musi być co najmniej równa szybkości przepustowości.', - 36: 'Skok przepustowości musi być co najmniej równy szybkości przepustowości.', - 37: 'Osiągalność przekaźnika', - 38: 'Tryb przekaźnika jest wyłączony', - 39: 'Osiągalny z internetu', - 40: 'Sprawdzanie, czy przekaźnik jest osiągalny z internetu', - 41: 'Nieosiągalny z internetu. Włącz adres publiczny w interfejsie Port OR przekaźnika Tor.', - 42: 'Tor nie mógł połączyć się z przekaźnikiem z internetu. Sprawdź, czy port OR jest przekierowany na ten serwer i czy adres publiczny jest włączony tylko na jednym połączeniu.', - 43: 'Zmiana portu OR przy włączonym przekaźniku uruchamia Tora ponownie, aby sprawdził nowy port.', - 44: 'Usuń adresy onion', - 45: 'Usuń adresy .onion hostowane na tym serwerze, w tym te, które nie są już przypisane do żadnego interfejsu', - 46: 'Każdy usunięty adres znika bezpowrotnie: jego klucz zostaje zniszczony razem z nim.', - 47: 'Adresy', - 48: 'Ten serwer nie hostuje żadnych adresów .onion', - 49: 'nieprzypisany już do żadnego interfejsu', - 50: 'adres jeszcze nie wygenerowany', - 51: 'Nie wybrano żadnych adresów', - 52: 'Adresy onion usunięte', + 4: 'Proxy SOCKS Tor', + 5: 'Dodaj usługę onion', + 6: 'Dodaj usługę onion Tor dla tego adresu URL', + 7: 'Usuń usługę onion', + 8: 'Usuń usługę onion Tor', + 9: 'SSL', + 10: 'Obsługuj ten adres z SSL', + 11: 'Adres', + 12: 'Utwórz nowy adres', + 13: 'Ten adres onion ma już powiązanie SSL dla tego portu', + 14: 'Ten adres onion ma już powiązanie bez SSL dla tego portu', + 15: 'Tor zakończył uruchamianie, ale nie może budować obwodów', + 16: 'Tor zresetował połączenie, ale nadal nie może się połączyć. Sprawdź połączenie internetowe serwera.', + 17: 'Zresetuj połączenie Tor', + 18: 'Czyści zapisane przez Tora dane sieci i uruchamia go ponownie, aby wybrał nowe węzły wejściowe. Użyj tego, jeśli Tor utknął przy łączeniu lub ciągle się rozłącza.', + 19: 'Rozpoczęto resetowanie', + 20: 'Tor uruchamia się ponownie i połączy się z nowymi węzłami wejściowymi. Zajmie to kilka minut.', + 21: 'Każdy usunięty adres znika bezpowrotnie: jego klucz zostaje zniszczony razem z nim.', + 22: 'adres jeszcze nie wygenerowany', + 23: 'Nie wybrano żadnych adresów', + 24: 'Adresy onion usunięte', + 25: 'Wyłącz automatyczne odzyskiwanie', + 26: 'Włącz automatyczne odzyskiwanie', + 27: 'Automatyczne odzyskiwanie jest włączone. Gdy Tor traci połączenie i nie może go odzyskać, ta usługa naprawia je za Ciebie, dzięki czemu Twoje usługi pozostają dostępne bez Twojego udziału.', + 28: 'Automatyczne odzyskiwanie jest wyłączone, więc Tor działa w trybie fail-closed. Jeśli Tor utknie, pozostaje offline, razem ze wszystkim, co od niego zależy, dopóki nie uruchomisz akcji Zresetuj połączenie Tor.', + 29: 'Po wyłączeniu Tor działa w trybie fail-closed. Jeśli Tor utknie, pozostaje offline, razem ze wszystkim, co od niego zależy, dopóki nie uruchomisz akcji Zresetuj połączenie Tor. W zamian nikt nie może wykorzystać automatycznych napraw, by ustalić, że Twoje adresy .onion są hostowane na tym serwerze. Wyłącz to tylko wtedy, gdy ukrycie lokalizacji tego serwera jest dla Ciebie ważniejsze niż jego dostępność.', + 30: 'Gdy ta opcja jest włączona, Twoje usługi pozostają dostępne bez Twojego udziału. Koszt: ktoś, kto potrafi wielokrotnie przerywać połączenie internetowe tego serwera, na przykład dostawca internetu, mógłby wykorzystać automatyczne naprawy, by w końcu ustalić, że Twoje adresy .onion są hostowane tutaj.', + 31: 'Automatyczne odzyskiwanie jest włączone', + 32: 'Automatyczne odzyskiwanie jest wyłączone', + 33: 'Tor naprawi zablokowane połączenie bez Twojego udziału.', + 34: 'Tor działa teraz w trybie fail-closed: zablokowane połączenie pozostaje nieaktywne, dopóki nie uruchomisz akcji Zresetuj połączenie Tor.', + 35: 'Tor będzie offline przez kilka minut. Twoje adresy .onion nie są naruszane. Każdy reset nieznacznie zwiększa szansę, że ktoś, kto na to czeka, powiąże Twoje adresy .onion z tym serwerem, więc używaj go, gdy Tor utknie, a nie rutynowo.', + 36: 'Tor nie może budować obwodów. Automatyczne odzyskiwanie jest wyłączone, więc uruchom akcję Zresetuj połączenie Tor, jeśli to nie minie.', + 37: 'Nieużywane adresy', + 38: 'Ten serwer nie ma nieużywanych adresów .onion', + 39: 'Usuń nieużywane adresy onion', + 40: 'Trwale usuwa klucze adresów .onion, których nie używa żaden interfejs', + 41: 'To usuwa adres .onion z tego interfejsu. Jego klucz zostaje zachowany, więc możesz ponownie przypisać adres, dopóki nie usuniesz go akcją Usuń nieużywane adresy onion.', + 42: 'Nic nie zostało usunięte, ponieważ te adresy są teraz w użyciu:', + 43: 'Ten adres onion nie jest już dostępny', + 44: 'Ten adres onion należy do innej usługi', + 45: 'Tylko nieużywany adres onion można przenieść na inny host', }, fr_FR: { 0: 'Tor fonctionne', 1: "Tor n'est pas prêt", 2: 'Clé privée (optionnel)', 3: 'Clé privée étendue ed25519 encodée en base64 pour une adresse .onion personnalisée. Laissez vide pour générer automatiquement.', - 4: 'Configurer le relais', - 5: 'Configurer les paramètres de relais et pont Tor', - 6: 'Proxy SOCKS Tor', - 7: 'Activé', - 8: 'Pseudonyme', - 9: 'Informations de contact', - 10: 'Mode pont', - 11: 'Port OR', - 12: 'Débit de bande passante', - 13: 'Rafale de bande passante', - 14: 'Port OR du relais Tor', - 15: 'Port de relais Tor pour le réseau Tor', - 16: 'Ajouter un service onion', - 17: 'Ajouter un service onion Tor pour cette URL', - 18: 'Supprimer le service onion', - 19: 'Supprimer un service onion Tor', - 20: 'SSL', - 21: 'Servir cette adresse avec SSL', - 22: 'Adresse', - 23: 'Créer une nouvelle adresse', - 24: 'Confirmez que vous souhaitez supprimer cette adresse .onion', - 25: 'Cette adresse onion a déjà une liaison SSL pour ce port', - 26: 'Cette adresse onion a déjà une liaison non SSL pour ce port', - 27: 'Tor a terminé son amorçage mais ne peut pas construire de circuits', - 28: 'Tor a réinitialisé sa connexion mais ne parvient toujours pas à se connecter. Vérifiez la connexion internet du serveur.', - 29: 'Réinitialiser la connexion Tor', - 30: "Efface les données réseau enregistrées par Tor et le redémarre, afin qu'il choisisse de nouveaux nœuds d'entrée. À utiliser si Tor reste bloqué à la connexion ou se déconnecte sans cesse.", - 31: 'Tor sera hors ligne quelques minutes le temps de se reconnecter. Vos adresses .onion ne sont pas affectées.', - 32: 'Réinitialisation lancée', - 33: "Tor redémarre et se reconnectera avec de nouveaux nœuds d'entrée. Cela prend quelques minutes.", - 34: 'Tor exige au moins 75 Ko/s pour un relais.', - 35: 'Doit être au moins égal au débit de bande passante.', - 36: 'La rafale de bande passante doit être au moins égale au débit de bande passante.', - 37: 'Accessibilité du relais', - 38: 'Le mode relais est désactivé', - 39: 'Accessible depuis internet', - 40: "Vérification de l'accessibilité du relais depuis internet", - 41: "Inaccessible depuis internet. Activez l'adresse publique sur l'interface Port OR du relais Tor.", - 42: "Tor n'a pas pu joindre le relais depuis internet. Vérifiez que le port OR est redirigé vers ce serveur et que l'adresse publique n'est activée que sur une seule connexion.", - 43: 'Modifier le port OR alors que le relais est activé redémarre Tor afin qu’il teste le nouveau port.', - 44: 'Supprimer les adresses onion', - 45: 'Supprimer les adresses .onion hébergées sur ce serveur, y compris celles qui ne sont plus rattachées à aucune interface', - 46: 'Chaque adresse supprimée est perdue pour de bon : sa clé est détruite avec elle.', - 47: 'Adresses', - 48: "Ce serveur n'héberge aucune adresse .onion", - 49: 'plus rattachée à aucune interface', - 50: 'adresse pas encore générée', - 51: 'Aucune adresse sélectionnée', - 52: 'Adresses onion supprimées', + 4: 'Proxy SOCKS Tor', + 5: 'Ajouter un service onion', + 6: 'Ajouter un service onion Tor pour cette URL', + 7: 'Supprimer le service onion', + 8: 'Supprimer un service onion Tor', + 9: 'SSL', + 10: 'Servir cette adresse avec SSL', + 11: 'Adresse', + 12: 'Créer une nouvelle adresse', + 13: 'Cette adresse onion a déjà une liaison SSL pour ce port', + 14: 'Cette adresse onion a déjà une liaison non SSL pour ce port', + 15: 'Tor a terminé son amorçage mais ne peut pas construire de circuits', + 16: 'Tor a réinitialisé sa connexion mais ne parvient toujours pas à se connecter. Vérifiez la connexion internet du serveur.', + 17: 'Réinitialiser la connexion Tor', + 18: "Efface les données réseau enregistrées par Tor et le redémarre, afin qu'il choisisse de nouveaux nœuds d'entrée. À utiliser si Tor reste bloqué à la connexion ou se déconnecte sans cesse.", + 19: 'Réinitialisation lancée', + 20: "Tor redémarre et se reconnectera avec de nouveaux nœuds d'entrée. Cela prend quelques minutes.", + 21: 'Chaque adresse supprimée est perdue pour de bon : sa clé est détruite avec elle.', + 22: 'adresse pas encore générée', + 23: 'Aucune adresse sélectionnée', + 24: 'Adresses onion supprimées', + 25: 'Désactiver la récupération automatique', + 26: 'Activer la récupération automatique', + 27: 'La récupération automatique est activée. Lorsque Tor perd sa connexion et ne parvient pas à la rétablir, ce service la répare pour vous, de sorte que vos services restent joignables sans intervention de votre part.', + 28: "La récupération automatique est désactivée : Tor échoue donc en mode fermé. Si Tor se bloque, il reste hors ligne, avec tout ce qui en dépend, jusqu'à ce que vous lanciez Réinitialiser la connexion Tor.", + 29: "Une fois désactivé, Tor échoue en mode fermé. Si Tor se bloque, il reste hors ligne, avec tout ce qui en dépend, jusqu'à ce que vous lanciez Réinitialiser la connexion Tor. En contrepartie, personne ne peut se servir des réparations automatiques pour découvrir que vos adresses .onion sont hébergées sur ce serveur. Ne le désactivez que si cacher l'emplacement de ce serveur compte davantage pour vous que de rester joignable.", + 30: "Activé, vos services restent joignables sans intervention de votre part. Le coût : quelqu'un capable d'interrompre à répétition la connexion internet de ce serveur, un fournisseur d'accès par exemple, pourrait se servir des réparations automatiques pour finir par découvrir que vos adresses .onion sont hébergées ici.", + 31: 'La récupération automatique est activée', + 32: 'La récupération automatique est désactivée', + 33: 'Tor réparera une connexion bloquée sans vous.', + 34: "Tor échoue désormais en mode fermé : une connexion bloquée reste coupée jusqu'à ce que vous lanciez Réinitialiser la connexion Tor.", + 35: "Tor sera hors ligne quelques minutes. Vos adresses .onion ne sont pas affectées. Chaque réinitialisation augmente légèrement la probabilité que quelqu'un qui la guette puisse relier vos adresses .onion à ce serveur : utilisez-la quand Tor est bloqué, pas de façon routinière.", + 36: 'Tor ne peut pas construire de circuits. La récupération automatique est désactivée : lancez Réinitialiser la connexion Tor si cela ne se résout pas.', + 37: 'Adresses inutilisées', + 38: "Ce serveur n'a aucune adresse .onion inutilisée", + 39: 'Supprimer les adresses onion inutilisées', + 40: "Supprime définitivement les clés des adresses .onion qu'aucune interface n'utilise", + 41: "Ceci retire l'adresse .onion de cette interface. Sa clé est conservée : vous pouvez rattacher l'adresse, jusqu'à ce que vous la supprimiez avec Supprimer les adresses onion inutilisées.", + 42: "Rien n'a été supprimé, car ces adresses sont désormais utilisées :", + 43: "Cette adresse onion n'est plus disponible", + 44: 'Cette adresse onion appartient à un autre service', + 45: 'Seule une adresse onion inutilisée peut être déplacée vers un autre hôte', }, } satisfies Record diff --git a/startos/index.ts b/startos/index.ts index 7af589b..e882576 100644 --- a/startos/index.ts +++ b/startos/index.ts @@ -8,4 +8,5 @@ export { actions } from './actions' import { buildManifest } from '@start9labs/start-sdk' import { manifest as sdkManifest } from './manifest' import { versionGraph } from './versions' -export const manifest = buildManifest(versionGraph, sdkManifest) +import { dependencies } from './dependencies' +export const manifest = buildManifest(versionGraph, sdkManifest, dependencies) diff --git a/startos/init/advertiseRelay.ts b/startos/init/advertiseRelay.ts deleted file mode 100644 index 7bcd44c..0000000 --- a/startos/init/advertiseRelay.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { torrc } from '../fileModels/torrc' -import { sdk } from '../sdk' - -/** - * Keeps what the relay advertises in step with what StartOS exposes. - * - * Without an `Address` line Tor advertises whatever address the directory - * authorities see on its outbound connections, which need not be the gateway - * the user enabled the OR port's Public address on. And when another binding - * already held the configured port, StartOS assigns the OR binding a different - * external port that Tor would otherwise never learn. Both come off the - * `or-multi` binding, the way other p2p packages derive their announced - * addresses. - */ -export const advertiseRelay = sdk.setupOnInit(async (effects) => { - const relay = await torrc - .read((t) => ({ enabled: t.relay.enabled, orPort: t.relay.orPort })) - .const(effects) - if (!relay?.enabled) return - - const exposed = await sdk.host - .getOwn(effects, 'or-multi', (host) => { - const binding = host?.bindings[relay.orPort] - const iface = binding?.interfaces['or'] - if (!binding || !iface) return null - return { - assignedPort: binding.net.assignedPort, - publicIps: iface.addressInfo.public - .filter({ kind: 'ipv4' }) - .hostnames.map((h) => h.hostname), - publicIpv6: - iface.addressInfo.public.filter({ kind: 'ipv6' }).hostnames.length > - 0, - } - }) - .const() - if (!exposed) return - - // Tor takes one Address per address family. With the Public address enabled - // on more than one gateway there is no single right answer, so Tor keeps - // deciding for itself. - const publicIps = [...new Set(exposed.publicIps)] - const port = exposed.assignedPort - - await torrc.merge( - effects, - { - advertise: { - orPort: relay.orPort, - address: publicIps.length === 1 ? publicIps[0] : null, - port: port !== null && port !== relay.orPort ? port : null, - // Nothing reaches the relay over IPv6 until a public IPv6 address is - // enabled, so until then Tor gets no IPv6 ORPort to find an address for. - ipv4Only: !exposed.publicIpv6, - }, - }, - { allowWriteAfterConst: true }, - ) -}) diff --git a/startos/init/index.ts b/startos/init/index.ts index 55a2aff..ca9338c 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -1,14 +1,13 @@ import { actions } from '../actions' import { restoreInit } from '../backups' -import { setDependencies } from '../dependencies' +import { dependencies } from '../dependencies' import { setInterfaces } from '../interfaces' import { exportUrls, registerUrlPlugin } from '../plugin/url' import { sdk } from '../sdk' import { versionGraph } from '../versions' -import { advertiseRelay } from './advertiseRelay' import { migrateOnionAddresses } from './migrateOnionAddresses' -import { reconcileOnionTargets } from './reconcileOnionTargets' import { reloadTorrc } from './reloadTorrc' +import { renderTorrc } from './renderTorrc' import { seedFiles } from './seedFiles' export const init = sdk.setupInit( @@ -16,13 +15,12 @@ export const init = sdk.setupInit( versionGraph, seedFiles, setInterfaces, - advertiseRelay, - setDependencies, actions, + dependencies, registerUrlPlugin, migrateOnionAddresses, exportUrls, - reconcileOnionTargets, + renderTorrc, reloadTorrc, ) diff --git a/startos/init/migrateOnionAddresses.ts b/startos/init/migrateOnionAddresses.ts index 5cc837b..5ed5d8f 100644 --- a/startos/init/migrateOnionAddresses.ts +++ b/startos/init/migrateOnionAddresses.ts @@ -1,10 +1,20 @@ import { rename } from 'node:fs/promises' import { FileHelper, z } from '@start9labs/start-sdk' +import { + hsDir, + nextIndex, + onionId, + parseOnionId, + OnionPort, + present, + storeJson, + writeOnions, +} from '../fileModels/store.json' import { sdk } from '../sdk' -import { hsDir, nextKey, torrc } from '../fileModels/torrc' -import { bridgeHost, generateOnionFiles, isClamped } from '../utils' +import { generateOnionFiles, isClamped } from '../utils' +import { onionHostname } from '../utils/onions' -const migrationEntryShape = z.object({ +const migrationEntryShape = z.looseObject({ packageId: z.string(), hostId: z.string(), hostname: z.string(), @@ -13,9 +23,10 @@ const migrationEntryShape = z.object({ const migrationFile = FileHelper.json( { base: sdk.volumes.startos, subpath: 'onion-migration.json' }, - z.object({ addresses: z.array(migrationEntryShape) }), + z.looseObject({ addresses: z.array(migrationEntryShape) }), ) +/** Imports, once, the onion addresses a StartOS 0.3.5 server carried. */ export const migrateOnionAddresses = sdk.setupOnInit(async (effects) => { const migration = await migrationFile.read().once() if (!migration?.addresses?.length) return @@ -24,69 +35,61 @@ export const migrateOnionAddresses = sdk.setupOnInit(async (effects) => { `Found ${migration.addresses.length} onion address(es) to import`, ) - const config = await torrc.read().once() - const onionServices = structuredClone(config?.onionServices || {}) - - for (const entry of migration.addresses) { - const { packageId, hostId, key } = entry + const onions = present(await storeJson.read((s) => s.onions).once()) + for (const { packageId, hostId, key } of migration.addresses) { // Skip keys that aren't properly clamped if (!key) continue const keyBytes = Buffer.from(key, 'base64') if (keyBytes.length < 64 || !isClamped(keyBytes.subarray(0, 32))) continue + const { secretKey, hostname } = generateOnionFiles(key) + const existing = await Promise.all( + Object.entries(onions) + .filter(([id]) => { + const owner = parseOnionId(id) + return owner.packageId === packageId && owner.hostId === hostId + }) + .map(([id, onion]) => onionHostname(id, onion)), + ) + if (existing.includes(hostname)) continue + const host = await sdk.host.get(effects, { hostId, packageId }).once() - if (!host) continue // package/host not installed, skip - const ports: Record< - string, - { target: string; ssl: boolean; internalPort: number } - > = {} - for (const [internalPortStr, b] of Object.entries(host.bindings)) { + // Keyed by external port: an address answers on each port once. + const ports = new Map() + for (const [internalPortStr, b] of Object.entries(host?.bindings ?? {})) { if (!b.enabled) continue const internalPort = Number(internalPortStr) - - // Primary onion port. A native-SSL binding terminates its own TLS, so the - // onion forwards raw TCP to it (flagged ssl); everything else is - // plaintext. Both reach the target over the LXC bridge — the deprecated - // `.startos` container hostname is gone. - const nativeSsl = b.options.secure?.ssl === true - const primary = bridgeHost(host, internalPort, nativeSsl) - if (primary) { - ports[String(b.options.preferredExternalPort)] = { - target: `${primary.hostname}:${primary.port}`, - ssl: nativeSsl, - internalPort, - } - } - - // addSsl bindings also expose an OS-terminated SSL port over the bridge. + // A native-SSL binding terminates its own TLS and has no plaintext leg; + // an addSsl binding also offers an OS-terminated SSL port. + ports.set(b.options.preferredExternalPort, { + externalPort: b.options.preferredExternalPort, + internalPort, + ssl: b.options.secure?.ssl === true, + }) if (b.options.addSsl) { - const sslAddr = bridgeHost(host, internalPort, true) - if (sslAddr) { - ports[String(b.options.addSsl.preferredExternalPort)] = { - target: `${sslAddr.hostname}:${sslAddr.port}`, - ssl: true, - internalPort, - } - } + ports.set(b.options.addSsl.preferredExternalPort, { + externalPort: b.options.addSsl.preferredExternalPort, + internalPort, + ssl: true, + }) } } - if (!onionServices[packageId]) onionServices[packageId] = {} - if (!onionServices[packageId][hostId]) onionServices[packageId][hostId] = {} - - const entryKey = nextKey(onionServices[packageId][hostId]!) - onionServices[packageId][hostId]![entryKey] = { ports } - - const dir = hsDir(packageId, hostId, entryKey) - const { secretKey, hostname } = generateOnionFiles(key) + const id = onionId( + packageId, + hostId, + await nextIndex(onions, packageId, hostId), + ) + const dir = hsDir(id) await sdk.volumes.tor.writeFile(`${dir}/hs_ed25519_secret_key`, secretKey) await sdk.volumes.tor.writeFile(`${dir}/hostname`, hostname + '\n') + onions[id] = { ports: [...ports.values()] } console.info(`Imported onion address for ${packageId}/${hostId}`) } - await torrc.merge(effects, { onionServices }) + await writeOnions(effects, onions) await rename( migrationFile.path, diff --git a/startos/init/reconcileOnionTargets.ts b/startos/init/reconcileOnionTargets.ts deleted file mode 100644 index 4b4446a..0000000 --- a/startos/init/reconcileOnionTargets.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { torrc } from '../fileModels/torrc' -import { sdk } from '../sdk' - -/** - * Re-derive every onion service's forward target from the binding it was - * created against, rewriting the torrc wherever the two have drifted apart. - * - * An entry's `target` is a snapshot of the binding taken when the entry was - * written, and a binding's external ports move — a package gaining `addSsl`, an - * OS upgrade reassigning them. Tor then forwards to a port nothing owns and - * every connection to that .onion is refused at the SOCKS layer. - * - * Each target is a `getBridgeAddress` const watch, so this re-runs whenever an - * address it forwards to changes — never on the exported-URL writes - * `plugin/url.ts` makes against those same hosts, which touch only plugin - * addresses and leave the bridge ones alone. It is ordered ahead of - * `reloadTorrc` so a repair reaches Tor in the same pass that finds it. - * - * A port with no bridge-reachable address in either mode is parked — a null - * target, which the file writes commented out — until the binding returns. Key - * material is never touched, and a lookup that throws leaves its entry alone - * rather than taking the service down with it. - */ -export const reconcileOnionTargets = sdk.setupOnInit(async (effects) => { - const onionServices = await torrc.read((t) => t.onionServices).once() - if (!onionServices) return - - const next = structuredClone(onionServices) - const retargeted: string[] = [] - const parked: string[] = [] - const resumed: string[] = [] - - for (const [packageId, hosts] of Object.entries(next)) { - for (const [hostId, services] of Object.entries(hosts ?? {})) { - for (const svc of Object.values(services ?? {})) { - if (!svc) continue - for (const [externalPort, portInfo] of Object.entries(svc.ports)) { - if (!portInfo) continue - const where = `${packageId}/${hostId}:${externalPort}` - - const bridge = (ssl: boolean) => - sdk.host - .getBridgeAddress(effects, { - packageId, - hostId, - internalPort: portInfo.internalPort, - ssl, - }) - .const() - - let ssl = portInfo.ssl - let target: string | null - try { - target = await bridge(ssl) - if (target === null) { - // The binding stopped serving the mode this entry recorded, so - // follow the mode it does serve: the address keeps answering on - // the port it advertises, and the annotation stops lying about it. - ssl = !ssl - target = await bridge(ssl) - } - } catch (e) { - console.warn(`Skipping ${where}: ${String(e)}`) - continue - } - - if (target === null) ssl = portInfo.ssl - if (target === portInfo.target && ssl === portInfo.ssl) continue - - svc.ports[externalPort] = { ...portInfo, target, ssl } - const to = `${target}${ssl ? ' ssl' : ''}` - if (target === null) parked.push(where) - else if (portInfo.target === null) resumed.push(`${where} -> ${to}`) - else - retargeted.push( - `${where} ${portInfo.target}${portInfo.ssl ? ' ssl' : ''} -> ${to}`, - ) - } - } - } - } - - if (parked.length) { - console.warn( - `Parked onion services whose interface has no reachable port; they stop answering until it is back, or until Delete Onion Addresses removes them: ${parked.join(', ')}`, - ) - } - if (resumed.length) { - console.info(`Resumed onion services: ${resumed.join(', ')}`) - } - if (retargeted.length) { - console.info(`Retargeted onion services: ${retargeted.join(', ')}`) - } - if (parked.length || resumed.length || retargeted.length) { - await torrc.merge(effects, { onionServices: next }) - } -}) diff --git a/startos/init/reloadTorrc.ts b/startos/init/reloadTorrc.ts index acad12a..b197864 100644 --- a/startos/init/reloadTorrc.ts +++ b/startos/init/reloadTorrc.ts @@ -1,5 +1,5 @@ +import { torrcFile } from '../fileModels/torrc' import { reloadConfig } from '../utils/control' -import { torrc } from '../fileModels/torrc' import { sdk } from '../sdk' /** @@ -7,7 +7,7 @@ import { sdk } from '../sdk' * changes, avoiding a full daemon restart. */ export const reloadTorrc = sdk.setupOnInit(async (effects) => { - await torrc.read().const(effects) + await torrcFile.read().const(effects) // Fix ownership on hidden service dirs — files are written as root by // actions, but Tor requires them owned by the tor user with mode 700 @@ -26,7 +26,7 @@ export const reloadTorrc = sdk.setupOnInit(async (effects) => { [ 'sh', '-c', - 'chmod -R 700 /var/lib/tor/hidden_services && chown -R tor:tor /var/lib/tor/hidden_services', + 'mkdir -p /var/lib/tor/hidden_services && chmod -R 700 /var/lib/tor/hidden_services && chown -R tor:tor /var/lib/tor/hidden_services', ], { user: 'root' }, ) diff --git a/startos/init/renderTorrc.ts b/startos/init/renderTorrc.ts new file mode 100644 index 0000000..d70e187 --- /dev/null +++ b/startos/init/renderTorrc.ts @@ -0,0 +1,52 @@ +import { + hsDir, + parseOnionId, + present, + STORE_LOCATION, + storeJson, +} from '../fileModels/store.json' +import { torrcFile } from '../fileModels/torrc' +import { sdk } from '../sdk' +import { render, RenderedOnion, userSection } from '../torrc/render' +import { socksPort } from '../utils' +import { watchOnionTarget } from '../utils/onions' + +/** + * Renders the torrc from the store and the live bindings. A forward target is + * resolved here and never stored, so a binding whose port moves is followed, + * and a port that is disabled or has no bridge address is left out rather than + * pointed anywhere. Each target is a `.const()`, so this re-runs when one + * changes. + */ +export const renderTorrc = sdk.setupOnInit(async (effects) => { + const onions = present(await storeJson.read((s) => s.onions).const(effects)) + + const rendered: RenderedOnion[] = [] + for (const [id, onion] of Object.entries(onions)) { + const { packageId, hostId } = parseOnionId(id) + const ports: RenderedOnion['ports'] = [] + for (const port of onion.ports) { + const target = await watchOnionTarget(effects, id, port).catch((e) => { + console.warn(`Not serving ${id}:${port.externalPort}: ${String(e)}`) + return null + }) + if (target !== null) + ports.push({ externalPort: port.externalPort, target }) + } + rendered.push({ + dir: hsDir(id, onion), + label: `${packageId}/${hostId}`, + ports, + }) + } + + // Read once: the user's section is carried over, not reacted to here. + const current = await torrcFile.read().once() + const next = render(userSection(current), { + root: '/var/lib/tor', + source: STORE_LOCATION, + socksPort, + onions: rendered, + }) + if (next !== current) await torrcFile.write(effects, next) +}) diff --git a/startos/init/seedFiles.ts b/startos/init/seedFiles.ts index 0453c07..bf5e790 100644 --- a/startos/init/seedFiles.ts +++ b/startos/init/seedFiles.ts @@ -1,6 +1,6 @@ -import { torrc } from '../fileModels/torrc' +import { storeJson } from '../fileModels/store.json' import { sdk } from '../sdk' -export const seedFiles = sdk.setupOnInit(async (effects, kind) => { - await torrc.merge(effects, {}) +export const seedFiles = sdk.setupOnInit(async (effects) => { + await storeJson.merge(effects, {}) }) diff --git a/startos/interfaces.ts b/startos/interfaces.ts index 651d068..58770a5 100644 --- a/startos/interfaces.ts +++ b/startos/interfaces.ts @@ -1,6 +1,4 @@ -import { i18n } from './i18n' import { sdk } from './sdk' -import { torrc } from './fileModels/torrc' import { socksHostId, socksPort } from './utils' export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { @@ -14,34 +12,5 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { secure: { ssl: false }, }) - const relay = await torrc.read((s) => s.relay).const(effects) - - if (!relay?.enabled) return [] - - const orPort = relay.orPort ?? 9001 - - const orMulti = sdk.MultiHost.of(effects, 'or-multi') - const orOrigin = await orMulti.bindPort(orPort, { - protocol: null, - preferredExternalPort: orPort, - addSsl: null, - // The OR protocol is self-securing TLS. `secure: null` would treat it as - // plaintext and never offer the port a public address. - secure: { ssl: false }, - }) - - const orInterface = sdk.createInterface(effects, { - name: i18n('Tor Relay OR Port'), - id: 'or', - description: i18n('Tor relay port for the Tor network'), - type: 'p2p', - masked: false, - schemeOverride: null, - username: null, - path: '', - query: {}, - }) - - const receipt = await orOrigin.export([orInterface]) - return [receipt] + return [] }) diff --git a/startos/main.ts b/startos/main.ts index 753f8dc..cbd4730 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -1,6 +1,5 @@ import { i18n } from './i18n' import { applyPendingWipe, watchdog } from './utils/recovery' -import { relayReachability } from './utils/relay' import { sdk } from './sdk' export const main = sdk.setupMain(async ({ effects }) => { @@ -61,15 +60,5 @@ export const main = sdk.setupMain(async ({ effects }) => { }, requires: ['chown'], }) - // Always present, and disabled while relay mode is off, so turning relay - // mode on never has to restart Tor to add it. - .addHealthCheck('relay', { - ready: { - display: i18n('Relay Reachability'), - fn: relayReachability(effects), - trigger: sdk.trigger.cooldownTrigger(30_000), - }, - requires: ['tor'], - }) ) }) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts index 9dd68a8..65fa893 100644 --- a/startos/manifest/i18n.ts +++ b/startos/manifest/i18n.ts @@ -10,15 +10,15 @@ export default { }, long: { en_US: - 'Run onion services (.onion addresses) to make your installed apps accessible over the Tor network. Provides a SOCKS5 proxy for private browsing and can optionally operate as a Tor relay or bridge to support the network.', + 'Run onion services (.onion addresses) to make your installed apps accessible over the Tor network. Provides a SOCKS5 proxy your other services use to reach the Tor network.', es_ES: - 'Ejecute servicios onion (direcciones .onion) para hacer accesibles sus aplicaciones instaladas a través de la red Tor. Proporciona un proxy SOCKS5 para navegación privada y opcionalmente puede operar como un relé o puente Tor para apoyar la red.', + 'Ejecute servicios onion (direcciones .onion) para hacer accesibles sus aplicaciones instaladas a través de la red Tor. Proporciona un proxy SOCKS5 que sus otros servicios usan para acceder a la red Tor.', de_DE: - 'Betreiben Sie Onion-Dienste (.onion-Adressen), um Ihre installierten Apps über das Tor-Netzwerk zugänglich zu machen. Bietet einen SOCKS5-Proxy für privates Surfen und kann optional als Tor-Relay oder Bridge betrieben werden.', + 'Betreiben Sie Onion-Dienste (.onion-Adressen), um Ihre installierten Apps über das Tor-Netzwerk zugänglich zu machen. Bietet einen SOCKS5-Proxy, über den Ihre anderen Dienste das Tor-Netzwerk erreichen.', pl_PL: - 'Uruchom usługi onion (adresy .onion), aby udostępnić zainstalowane aplikacje przez sieć Tor. Zapewnia proxy SOCKS5 do prywatnego przeglądania i opcjonalnie może działać jako przekaźnik lub most Tor.', + 'Uruchom usługi onion (adresy .onion), aby udostępnić zainstalowane aplikacje przez sieć Tor. Zapewnia proxy SOCKS5, przez które Twoje pozostałe usługi łączą się z siecią Tor.', fr_FR: - 'Exécutez des services onion (adresses .onion) pour rendre vos applications installées accessibles via le réseau Tor. Fournit un proxy SOCKS5 pour la navigation privée et peut optionnellement fonctionner comme relais ou pont Tor.', + 'Exécutez des services onion (adresses .onion) pour rendre vos applications installées accessibles via le réseau Tor. Fournit un proxy SOCKS5 que vos autres services utilisent pour joindre le réseau Tor.', }, }, } diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index eafb1c9..1ed1cb7 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -1,6 +1,8 @@ import { setupManifest } from '@start9labs/start-sdk' import i18n from './i18n' +export const torVersion = '0.4.9.13' + export const manifest = setupManifest({ id: 'tor', title: 'Tor', @@ -13,10 +15,9 @@ export const manifest = setupManifest({ volumes: ['tor', 'startos'], images: { tor: { - source: { dockerBuild: {} }, + source: { dockerBuild: { buildArgs: { TOR_VERSION: torVersion } } }, arch: ['x86_64', 'aarch64', 'riscv64'], }, }, - dependencies: {}, plugins: ['url-v0'], }) diff --git a/startos/plugin/url.ts b/startos/plugin/url.ts index 38a0021..dd32108 100644 --- a/startos/plugin/url.ts +++ b/startos/plugin/url.ts @@ -1,146 +1,63 @@ import { FileHelper } from '@start9labs/start-sdk' -import { rm } from 'fs/promises' import { addOnionService } from '../actions/addOnionService' import { deleteOnionService } from '../actions/deleteOnionService' -import { hsDir, torrc } from '../fileModels/torrc' +import { + hsDir, + parseOnionId, + present, + storeJson, +} from '../fileModels/store.json' import { sdk } from '../sdk' +import { watchOnionTarget } from '../utils/onions' export const registerUrlPlugin = sdk.setupOnInit(async (effects) => sdk.plugin.url.register(effects, { tableAction: addOnionService }), ) +/** + * Exports every onion to the interface it serves. An address whose host or + * binding is absent or disabled is skipped, not removed: nothing here deletes a + * mapping or a key, so a service that is restored, reinstalled, re-bound or + * re-enabled later gets its address back, and a key goes only when the user + * deletes it. + */ export const exportUrls = sdk.plugin.url.setupExportedUrls( async ({ effects }) => { - const onionServices = - (await torrc.read((t) => t.onionServices).const(effects)) || {} + const onions = present(await storeJson.read((s) => s.onions).const(effects)) - // Phase 1: Remove onion service entries whose target package no longer exists - const cleaned = structuredClone(onionServices) - const removed: string[] = [] - const pending = new Set() + for (const [id, onion] of Object.entries(onions)) { + const { packageId, hostId } = parseOnionId(id) - for (const [packageId, hosts] of Object.entries(cleaned)) { - if (!hosts) continue + const hostname = await FileHelper.string({ + base: sdk.volumes.tor, + subpath: `${hsDir(id, onion)}/hostname`, + }) + .read() + .const(effects) + if (!hostname) continue - for (const [hostId, services] of Object.entries(hosts)) { - // A missing host is a package that was uninstalled — or one whose - // restore has not bound it yet: a batch restore writes every package's - // entry before any of them inits. Only the package's absence proves an - // uninstall. A thrown lookup (e.g. a legacy entry the OS can't resolve) - // keeps the entry and its key material. - // - // Map to existence before `.const()`: Phase 2's `exportUrl` mutates the - // target host (its exported-URL set), which this watch observes. - // Subscribing to the whole host would re-fire on our own writes and spin - // this pass indefinitely; the boolean only flips when a host actually - // appears or disappears. - let hostExists: boolean - try { - hostExists = await sdk.host - .get(effects, { hostId, packageId }, (host) => !!host) - .const() - } catch (e) { - console.warn( - `Skipping cleanup for ${packageId}/${hostId}: ${String(e)}`, - ) - continue - } - if (hostExists) continue // host still exists — keep the onion - - // Read once: a status watch would re-fire on every health tick of the - // target, and the host watch above already fires when the host arrives. - let installed: boolean - try { - installed = - (await sdk.getStatus(effects, { packageId }).once()) !== null - } catch (e) { - console.warn( - `Skipping cleanup for ${packageId}/${hostId}: ${String(e)}`, - ) - continue - } - if (installed) { - pending.add(`${packageId}/${hostId}`) - continue - } - - for (const index of Object.keys(services ?? {})) { - await rm(sdk.volumes.tor.subpath(hsDir(packageId, hostId, index)), { - recursive: true, - force: true, + for (const port of onion.ports) { + const target = await watchOnionTarget(effects, id, port).catch((e) => { + console.warn(`Not exporting ${id}: ${String(e)}`) + return null + }) + if (target === null) continue + await sdk.plugin.url + .exportUrl(effects, { + hostnameInfo: { + packageId, + hostId, + internalPort: port.internalPort, + ssl: port.ssl, + public: true, + hostname: hostname.trim(), + port: port.externalPort, + info: null, + }, + removeAction: deleteOnionService, + overflowActions: [], }) - } - // Set to undefined (not delete) so merge() removes the key from the file - ;(cleaned[packageId] as any)[hostId] = undefined - removed.push(`${packageId}/${hostId}`) - } - - if ( - Object.values(cleaned[packageId] || {}).every((v) => v === undefined) - ) { - ;(cleaned as any)[packageId] = undefined - } - } - - // Persist the cleaned config if we dropped any stale entries. We must NOT - // return early here: setupExportedUrls calls clearUrls({ except }) with the - // URLs exported during this run, so returning before Phase 2 would leave - // `except` empty and transiently wipe every package's onion from every host - // — firing host-info watchers and restarting every service that watches its - // own address. Instead we fall through and export the survivors in the same - // pass, so clearUrls only prunes the genuinely-stale entries. - if (removed.length) { - console.info(`Removed stale onion service entries: ${removed.join(', ')}`) - await torrc.merge( - effects, - { onionServices: cleaned }, - { allowWriteAfterConst: true }, - ) - } - - if (pending.size) { - console.info( - `Keeping onion services whose host is not bound yet: ${[...pending].join(', ')}`, - ) - } - - // Phase 2: Export URLs for all surviving entries whose host exists - for (const [packageId, hosts] of Object.entries(cleaned)) { - if (!hosts) continue - for (const [hostId, services] of Object.entries(hosts)) { - if (pending.has(`${packageId}/${hostId}`)) continue - for (const [i, svc] of Object.entries(services ?? {})) { - const hostnameFile = FileHelper.string({ - base: sdk.volumes.tor, - subpath: `${hsDir(packageId, hostId, i)}/hostname`, - }) - const hostname = await hostnameFile.read().const(effects) - if (!hostname) continue - - for (const [externalPort, portInfo] of Object.entries( - svc?.ports ?? {}, - )) { - if (!portInfo || portInfo.target === null) continue - await sdk.plugin.url - .exportUrl(effects, { - hostnameInfo: { - packageId, - hostId, - internalPort: portInfo.internalPort, - ssl: portInfo.ssl, - public: true, - hostname: hostname.trim(), - port: parseInt(externalPort, 10), - info: null, - }, - removeAction: deleteOnionService, - overflowActions: [], - }) - .catch((e) => { - console.error('Failed to export url', e) - }) - } - } + .catch((e) => console.error(`Failed to export ${id}`, e)) } } }, diff --git a/startos/torrc/render.ts b/startos/torrc/render.ts new file mode 100644 index 0000000..46d3d55 --- /dev/null +++ b/startos/torrc/render.ts @@ -0,0 +1,76 @@ +/** + * The first line of the marker. A torrc is split on the first line that starts + * with this, so the wording after it can change without orphaning a user's + * section. + */ +const MARKER_PREFIX = '# ===== Everything below this line is generated' + +/** An onion service whose forward targets are already resolved. */ +export type RenderedOnion = { + /** Path of the HiddenServiceDir, relative to the torrc's directory. */ + dir: string + /** `package/host`, written as a comment above the block. */ + label: string + ports: { externalPort: number; target: string }[] +} + +export type RenderInput = { + /** Absolute path, inside the container, of the directory holding the torrc. */ + root: string + /** Where the generated section comes from, named in the marker. */ + source: string + socksPort: number + onions: RenderedOnion[] +} + +const USER_SECTION_HEADER = [ + '# Tor configuration for this StartOS server.', + '#', + '# Lines you add in this top section are kept, and Tor honors them.', + '', +].join('\n') + +const marker = (source: string) => + [ + `${MARKER_PREFIX} from ${source}`, + '# and is rewritten whenever that file or a service port changes. Edits made', + '# below this line are lost. Add or remove a .onion address from the', + "# service's interface page in StartOS. Lines above this line are yours.", + ].join('\n') + +/** The part of a torrc above the marker, or the whole file if it has none. */ +export function userSection(raw: string | null): string { + if (raw === null) return USER_SECTION_HEADER + const lines = raw.split('\n') + const at = lines.findIndex((l) => l.startsWith(MARKER_PREFIX)) + return at === -1 ? raw : lines.slice(0, at).join('\n') +} + +export function hasMarker(raw: string): boolean { + return raw.split('\n').some((l) => l.startsWith(MARKER_PREFIX)) +} + +/** + * Renders a torrc: the user's section verbatim, then the marker, then the + * generated section. An onion with no port to forward to is left out. + */ +export function render(user: string, input: RenderInput): string { + const out = [ + user.replace(/\n*$/, ''), + '', + marker(input.source), + '', + `SocksPort 0.0.0.0:${input.socksPort}`, + `DataDirectory ${input.root}/data`, + `ControlSocket ${input.root}/control.sock`, + ] + for (const onion of input.onions) { + if (onion.ports.length === 0) continue + out.push('', `# ${onion.label}`) + out.push(`HiddenServiceDir ${input.root}/${onion.dir}/`) + for (const { externalPort, target } of onion.ports) { + out.push(`HiddenServicePort ${externalPort} ${target}`) + } + } + return out.join('\n') + '\n' +} diff --git a/startos/utils/control.ts b/startos/utils/control.ts index 331fa85..806caa6 100644 --- a/startos/utils/control.ts +++ b/startos/utils/control.ts @@ -38,23 +38,6 @@ export type TorStatus = { dormant: boolean } -export type RelayStatus = { - /** - * Tor's self-test found every ORPort in its current descriptor reachable. - * Vacuously true while Tor has no descriptor yet, so read it with `published`. - */ - reachable: boolean - /** A directory authority accepted the descriptor Tor last uploaded. */ - published: boolean - /** Tor knows an IPv6 address to publish, so an IPv6 ORPort is in play. */ - ipv6: boolean -} - -/** The watchdog probes every 30 seconds at its slowest; older than this is no reading. */ -const RELAY_STATUS_MAX_AGE_MS = 90_000 - -let relay: { at: number; status: RelayStatus } | null = null - /** * Everything the health check needs, in one round trip. Returns null when Tor * isn't answering its control socket at all. @@ -64,24 +47,8 @@ export async function probe(): Promise { 'GETINFO status/bootstrap-phase', 'GETINFO status/circuit-established', 'GETINFO dormant', - // For relayStatus(). Tor logs a notice for every control connection, so - // the relay check reads this reply instead of opening a second one. - 'GETINFO status/reachability-succeeded/or', - 'GETINFO status/accepted-server-descriptor', - 'GETINFO address/v6', ) - if (reply === null) { - relay = null - return null - } - relay = { - at: Date.now(), - status: { - reachable: /status\/reachability-succeeded\/or=1/.test(reply), - published: /status\/accepted-server-descriptor=1/.test(reply), - ipv6: /address\/v6=\S/.test(reply), - }, - } + if (reply === null) return null const phase = reply.match(/BOOTSTRAP PROGRESS=(\d+).*?SUMMARY="([^"]*)"/) const dormant = reply.match(/[- ]dormant=(\d+)/) @@ -107,16 +74,6 @@ export async function resetCircuits(): Promise { return (await send('DROPGUARDS', 'DROPTIMEOUTS', 'SIGNAL NEWNYM')) !== null } -/** - * What Tor's self-test says about the relay's OR port, the test that gates - * publishing the relay descriptor, as of the watchdog's latest `probe()`. Null - * when Tor didn't answer that probe, or there hasn't been one lately. - */ -export function relayStatus(): RelayStatus | null { - if (!relay || Date.now() - relay.at > RELAY_STATUS_MAX_AGE_MS) return null - return relay.status -} - /** Signals Tor to re-read torrc in place, avoiding a full daemon restart. */ export async function reloadConfig(): Promise { await send('SIGNAL RELOAD') diff --git a/startos/utils/index.ts b/startos/utils/index.ts index c9cd017..e6bff79 100644 --- a/startos/utils/index.ts +++ b/startos/utils/index.ts @@ -1,5 +1,4 @@ import { createHash } from 'crypto' -import { utils } from '@start9labs/start-sdk' import { ed25519 } from '@noble/curves/ed25519.js' import { bytesToNumberLE } from '@noble/curves/utils.js' import { base32 } from 'rfc4648' @@ -10,38 +9,11 @@ import { base32 } from 'rfc4648' * `${await sdk.getOsIp(effects)}:${socksPort}` (10.0.3.1:9050), never the * LAN. Import these instead of hardcoding. * - * These two names are a published contract: 16 packaging repos across both - * registries import them from `tor-startos/startos/utils`, and nothing in this - * repo references them. Renaming either, or moving them off that module path, - * breaks all of them with no signal here — `utils/` resolves through its - * `index.ts`, so the directory name is load-bearing too. + * These two names are a published contract (see AGENTS.md). */ export const socksHostId = 'socks' export const socksPort = 9050 -/** - * The IPv4 LXC-bridge `{ hostname, port }` for the interface on a binding of an - * already-resolved host. `.startos` DNS and container IPs are deprecated; - * containers — and the OS admin UI (`start-os`/`admin`) — are reached over this - * bridge. `ssl` picks the https vs http variant. Returns `undefined` when the - * binding exports no bridge-reachable interface. Call after `sdk.host.get(...)` - * has resolved the host. - */ -export const bridgeHost = ( - host: utils.FilledHost | null, - internalPort: number, - ssl: boolean, -) => { - const binding = host?.bindings[internalPort] - const iface = binding && Object.values(binding.interfaces)[0] - return iface - ? iface.addressInfo.filter({ - kind: 'bridge', - predicate: (h) => h.metadata.kind === 'ipv4' && h.ssl === ssl, - }).hostnames[0] - : undefined -} - const SECRET_KEY_HEADER = Buffer.from('== ed25519v1-secret: type0 ==\0\0\0') function deriveOnionHostname(pubBytes: Uint8Array): string { diff --git a/startos/utils/onions.ts b/startos/utils/onions.ts new file mode 100644 index 0000000..dc3b784 --- /dev/null +++ b/startos/utils/onions.ts @@ -0,0 +1,78 @@ +import { T } from '@start9labs/start-sdk' +import { hsDir, Onion, OnionPort, parseOnionId } from '../fileModels/store.json' +import { sdk } from '../sdk' + +/** + * Throws unless the user, or the service that owns the address, is asking. + * A caller is the id of the service that ran the action; `null` is the user. + */ +export function requireOwner(caller: string | null, packageId?: string) { + if (caller !== null && caller !== packageId) { + throw new Error( + `${caller} can only manage its own onion addresses, not those of ${packageId ?? 'another service'}`, + ) + } +} + +/** The address's .onion hostname, or null before its key is written. */ +export const onionHostname = (id: string, onion?: Onion) => + sdk.volumes.tor + .readFile(`${hsDir(id, onion)}/hostname`) + .then((content) => content.toString().trim()) + .catch(() => null) + +/** + * Whether a binding is enabled, or null when its host or the binding is gone. + * StartOS keeps a disabled binding's port and bridge address, so a resolved + * bridge address alone does not mean anything is listening. + */ +export const bindingEnabled = ( + effects: T.Effects, + opts: { packageId: string; hostId: string; internalPort: number }, +) => + sdk.host.get( + effects, + { hostId: opts.hostId, packageId: opts.packageId }, + (host) => host?.bindings[opts.internalPort]?.enabled ?? null, + ) + +export async function watchOnionTarget( + effects: T.Effects, + id: string, + { internalPort, ssl }: OnionPort, +) { + const { packageId, hostId } = parseOnionId(id) + const enabled = await bindingEnabled(effects, { + packageId, + hostId, + internalPort, + }).const() + if (!enabled) return null + return sdk.host + .getBridgeAddress(effects, { packageId, hostId, internalPort, ssl }) + .const() +} + +/** + * Whether any port of the address belongs to its service right now: an enabled + * binding that resolves, or a disabled one. A disabled binding is not served, + * but its service still holds the port and can enable it again, so its address + * is not unused. + */ +export async function isServed(effects: T.Effects, id: string, onion: Onion) { + const { packageId, hostId } = parseOnionId(id) + for (const { internalPort, ssl } of onion.ports) { + const enabled = await bindingEnabled(effects, { + packageId, + hostId, + internalPort, + }).once() + if (enabled === false) return true + if (enabled === null) continue + const target = await sdk.host + .getBridgeAddress(effects, { packageId, hostId, internalPort, ssl }) + .once() + if (target !== null) return true + } + return false +} diff --git a/startos/utils/reattach.ts b/startos/utils/reattach.ts new file mode 100644 index 0000000..9c91770 --- /dev/null +++ b/startos/utils/reattach.ts @@ -0,0 +1,122 @@ +import { + ExtendedVersion, + IST, + StartSdk, + T, + VersionRange, +} from '@start9labs/start-sdk' + +type Sdk = ReturnType['build']> + +/** The first Tor release whose Add Onion Service a service may run for its own hosts. */ +const servicesMayAttach = VersionRange.parse('>=0.4.9.13:1') + +export type OnionReattachment = { + /** The calling package's own id. */ + packageId: T.PackageId + /** The host whose unused .onion addresses move. Addresses never change host. */ + hostId: T.HostId + /** The replacement binding they move to — never the retired one. */ + to: { + interfaceId: T.ServiceInterfaceId + internalPort: number + /** Whether the onion serves the binding's SSL leg, where Tor's form offers one. */ + ssl: boolean + } + /** Reads the flag the migration that retired the old binding set. */ + pending: { const(effects: T.Effects): Promise } + /** Clears it, once every unused address has moved. */ + clear: (effects: T.Effects) => Promise +} + +/** + * An init script that moves a host's unused .onion addresses to a binding, + * keeping each hostname, after a migration retired the binding they served. + * It waits for the flag, the binding and a Tor that allows it, and keeps the + * flag set until the move succeeds. + */ +export function setupOnionReattachment( + sdk: Pick, + opts: OnionReattachment, +) { + const urlPluginMetadata = { + packageId: opts.packageId, + hostId: opts.hostId, + interfaceId: opts.to.interfaceId, + internalPort: opts.to.internalPort, + } + + async function unusedOnions(effects: T.Effects): Promise { + const served = await sdk.host + .get( + effects, + { hostId: opts.hostId, packageId: opts.packageId }, + (host) => + Object.values(host?.bindings ?? {}).flatMap((b) => + b.addresses.available + .filter( + (a) => + a.metadata.kind === 'plugin' && + a.metadata.packageId === 'tor', + ) + .map((a) => a.hostname), + ), + ) + .once() + const form = await effects.action.getInput({ + packageId: 'tor', + actionId: 'add-onion-service', + prefill: { urlPluginMetadata }, + }) + const address = (form?.spec as IST.InputSpec | undefined)?.address + if (address?.type !== 'union') return [] + return Object.entries(address.variants) + .filter( + ([id, { name }]) => + id.startsWith(`${opts.packageId}/${opts.hostId}/`) && + !served.includes(name), + ) + .map(([id]) => id) + } + + return sdk.setupOnInit(async (effects) => { + if (!(await opts.pending.const(effects))) return + + const enabled = await sdk.host + .get( + effects, + { hostId: opts.hostId, packageId: opts.packageId }, + (host) => host?.bindings[opts.to.internalPort]?.enabled ?? false, + ) + .const() + if (!enabled) return + + const torVersion = await sdk + .getServiceManifest(effects, 'tor', (m) => m?.version ?? null) + .const() + if ( + !torVersion || + !ExtendedVersion.parse(torVersion).satisfies(servicesMayAttach) + ) + return + + try { + for (const selection of await unusedOnions(effects)) { + await sdk.action.run({ + effects, + packageId: 'tor', + actionId: 'add-onion-service', + prefill: { urlPluginMetadata }, + input: ({ spec }) => ({ + urlPluginMetadata, + ...('ssl' in spec ? { ssl: opts.to.ssl } : {}), + address: { selection, value: {} }, + }), + }) + } + await opts.clear(effects) + } catch (e) { + console.warn(`.onion addresses not reattached yet: ${String(e)}`) + } + }) +} diff --git a/startos/utils/recovery.ts b/startos/utils/recovery.ts index bc96702..49c6d9b 100644 --- a/startos/utils/recovery.ts +++ b/startos/utils/recovery.ts @@ -1,6 +1,7 @@ -import { access, readdir, rm, writeFile } from 'node:fs/promises' +import { access, rm, writeFile } from 'node:fs/promises' import { T } from '@start9labs/start-sdk' import type { HealthCheckResult } from '@start9labs/start-sdk/lib/health/checkFns' +import { storeJson } from '../fileModels/store.json' import { probe, resetCircuits } from './control' import { i18n } from '../i18n' import { sdk } from '../sdk' @@ -25,26 +26,6 @@ export const wipeRequested = flag('.wipe-requested') /** The watchdog already wiped during this outage; it does not wipe twice. */ export const autoWiped = flag('.auto-wiped') -/** - * What survives a wipe: the config we generate, the onion service keys that - * *are* the user's .onion addresses, the relay's long-term identity (wiping it - * would change the relay's fingerprint), the live control socket, and the - * watchdog's own state. Everything else under the data directory is Tor's - * cached view of the network, which it rebuilds on the next start. - * - * An allow-list on purpose — a wipe that misses a cache file leaves the bad - * entry node in place, which is the whole failure being recovered from. Anything - * new the package persists on this volume must be added here. - */ -const PRESERVE = [ - 'torrc', - 'hidden_services', - 'keys', - 'control.sock', - wipeRequested.name, - autoWiped.name, -] - /** Tor must be unhealthy this long before the watchdog does anything at all. */ const STALL_MS = 5 * 60_000 @@ -55,22 +36,19 @@ const STALL_MS = 5 * 60_000 const RETRY_MS = [10, 20].map((m) => m * 60_000) /** - * Deletes Tor's cached view of the network: the `state` file that pins its entry - * nodes, the consensus and descriptor caches, and the lock. - * + * Tor's DataDirectory, which holds nothing but its cached view of the network: + * the `state` file that pins its entry nodes, and the consensus and descriptor + * caches. The torrc, the onion keys and the flags live beside it, not in it. + */ +const DATA_DIR = 'data' + +/** * Must run with no tor process attached to the volume. A running Tor holds this * state in memory and flushes it on shutdown, so deleting the files underneath * it writes the same entry nodes straight back. */ -async function wipeNetworkState(): Promise { - const removed = (await readdir(sdk.volumes.tor.path)).filter( - (entry) => !PRESERVE.includes(entry), - ) - for (const entry of removed) { - await rm(sdk.volumes.tor.subpath(entry), { recursive: true, force: true }) - } - return removed -} +const wipeNetworkState = () => + rm(sdk.volumes.tor.subpath(DATA_DIR), { recursive: true, force: true }) /** * Performs a wipe left pending by the watchdog or the Reset Tor Connection @@ -83,10 +61,8 @@ async function wipeNetworkState(): Promise { */ export async function applyPendingWipe(): Promise { if (await wipeRequested.isSet()) { - const removed = await wipeNetworkState() - console.info( - `Wiped Tor network state: ${removed.join(', ') || '(nothing to remove)'}`, - ) + await wipeNetworkState() + console.info('Wiped Tor network state') await wipeRequested.clear() } return autoWiped.isSet() @@ -154,7 +130,11 @@ export function watchdog(effects: T.Effects, wiped: boolean) { } lastProgress = progress - if (now >= nextAttemptAt) { + // Off is fail closed: nothing about Tor's connection changes by itself. + const automatic = + (await storeJson.read((s) => s.automaticRecovery).once()) ?? true + + if (automatic && now >= nextAttemptAt) { // Dropping entry nodes only means anything while Tor is answering. When // it isn't, skip straight to the wipe: a control socket unreachable this // long usually means Tor can't get through its own start-up state. @@ -201,7 +181,11 @@ export function watchdog(effects: T.Effects, wiped: boolean) { if (bootstrap.progress >= 100) return { result: 'failure', - message: i18n('Tor is bootstrapped but cannot build circuits'), + message: automatic + ? i18n('Tor is bootstrapped but cannot build circuits') + : i18n( + 'Tor cannot build circuits. Automatic Recovery is off, so run Reset Tor Connection if this does not clear.', + ), } return { result: 'loading', diff --git a/startos/utils/relay.ts b/startos/utils/relay.ts deleted file mode 100644 index 802f3a1..0000000 --- a/startos/utils/relay.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { HealthCheckResult } from '@start9labs/start-sdk/lib/health/checkFns' -import type { Effects } from '@start9labs/start-sdk/lib/types' -import { torrc } from '../fileModels/torrc' -import { i18n } from '../i18n' -import { sdk } from '../sdk' -import { relayStatus } from './control' - -/** - * How long Tor waits after starting before it warns that the OR port is - * unreachable (`TIMEOUT_UNTIL_UNREACHABILITY_COMPLAINT` in tor's `or.h`). It - * repeats that check every 20 minutes afterwards. - */ -const SELF_TEST_MS = 20 * 60_000 - -/** - * With an auto-discovered IPv6 address, Tor keeps reporting the OR port - * unreachable until one of those 20-minute checks, run after IPv4 is confirmed, - * drops the unreachable IPv6 address and publishes over IPv4 alone - * (`reachability_warnings_callback` in tor's `relay_periodic.c`). This allows - * two of those checks. - */ -const SELF_TEST_WITH_IPV6_MS = 45 * 60_000 - -/** - * The Relay Reachability health check: Tor's own verdict on whether the OR port - * is reachable from the internet, which otherwise shows only in the logs. - * - * Success needs a directory authority to have accepted the descriptor as well, - * because Tor's reachability flag is vacuously true until it has built one. - * Short of that it reports `loading`, not `failure`, until Tor has had as long - * as it gives itself. The clock starts at the first unreachable reading and - * starts over whenever the OR port or the advertised address changes, since - * Tor tests again from scratch. - * - * Tor never revisits a passed test until its address changes, so its verdict - * outlives the inbound path. With no public address enabled on the OR port - * there is no inbound path at all, and the check says so whatever Tor reports. - */ -export function relayReachability(effects: Effects) { - let unreachableSince: number | null = null - let testedConfig: string | null = null - - return async (): Promise => { - const config = await torrc - .read((t) => ({ - enabled: t.relay.enabled, - orPort: t.relay.orPort, - advertise: t.advertise, - })) - .once() - if (!config?.enabled) { - unreachableSince = null - testedConfig = null - return { result: 'disabled', message: i18n('Relay mode is off') } - } - - const exposed = await sdk.host - .getOwn( - effects, - 'or-multi', - (host) => - (host?.bindings[config.orPort]?.interfaces['or']?.addressInfo.public - .hostnames.length ?? 0) > 0, - ) - .once() - if (!exposed) { - unreachableSince = null - return { - result: 'failure', - message: i18n( - 'Not reachable from the internet. Enable the Public address on the Tor Relay OR Port interface.', - ), - } - } - - const key = JSON.stringify(config) - if (key !== testedConfig) { - testedConfig = key - unreachableSince = null - } - - const status = relayStatus() - if (!status) { - return { result: 'loading', message: i18n('Tor is not ready') } - } - if (status.reachable && status.published) { - unreachableSince = null - return { result: 'success', message: i18n('Reachable from the internet') } - } - - const now = Date.now() - unreachableSince ??= now - const allowance = status.ipv6 ? SELF_TEST_WITH_IPV6_MS : SELF_TEST_MS - if (now - unreachableSince < allowance) { - return { - result: 'loading', - message: i18n( - 'Testing whether the relay is reachable from the internet', - ), - } - } - return { - result: 'failure', - message: i18n( - 'Tor could not reach the relay from the internet. Check that the OR port is forwarded to this server, and that the Public address is enabled on only one connection.', - ), - } - } -} diff --git a/startos/versions/current.ts b/startos/versions/current.ts index 0d70269..62c7727 100644 --- a/startos/versions/current.ts +++ b/startos/versions/current.ts @@ -1,16 +1,172 @@ +import { mkdir, readdir, rename } from 'node:fs/promises' import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' +import { + onionId, + OnionPort, + Onions, + present, + storeJson, +} from '../fileModels/store.json' +import { torrcFile } from '../fileModels/torrc' +import { sdk } from '../sdk' +import { hasMarker } from '../torrc/render' +import { torrc as legacyTorrc } from './legacy/torrc' + +/** What stays at the root of the tor volume; the rest is Tor's own data. */ +const NOT_TOR_DATA = [ + 'torrc', + 'torrc.legacy', + 'hidden_services', + 'keys', + 'control.sock', + '.wipe-requested', + '.auto-wiped', + 'data', +] export const current = VersionInfo.of({ - version: '0.4.9.13:0', + version: '0.4.9.13:1', releaseNotes: { - en_US: `Updated Tor to 0.4.9.13. [Full upstream release notes](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, - es_ES: `Tor actualizado a 0.4.9.13. [Notas completas de la versión original](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, - de_DE: `Tor auf 0.4.9.13 aktualisiert. [Vollständige Versionshinweise des Originalprojekts](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, - pl_PL: `Zaktualizowano Tor do wersji 0.4.9.13. [Pełne informacje o wydaniu projektu źródłowego](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, - fr_FR: `Tor mis à jour vers la version 0.4.9.13. [Notes de version complètes du projet d'origine](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, + en_US: `**Relay and bridge mode have been removed.** A Tor relay's IP address is listed in Tor's public directory. When the same server also hosts .onion addresses, that listing gives an attacker a short list of servers to test, and load and timing measurements can then link an onion address to the server's IP. Running the two as separate processes does not prevent this; sharing a server is enough. The Tor Project advises against hosting onion services on a relay, and nearly every StartOS server hosts them, so this package no longer offers both. Relay support is planned to return as a separate service. + +If you were running a relay, it stops with this update. The Tor Relay OR Port interface is gone, so any port forward you made for it on your router can be removed. Your relay's identity keys remain on the Tor volume and in its backups. The old relay host is retired; reattach any custom domains you assigned to it to a current service interface. + +- Adds an Automatic Recovery setting. It is on by default, as before; turning it off makes Tor fail closed. +- An .onion address now follows its service when that service's ports move. +- The key behind an .onion address is never deleted automatically. An address no interface is using stays until you delete it with Delete Unused Onion Addresses, which replaces Delete Onion Addresses. +- Restoring Tor before the services that use its .onion addresses no longer deletes those addresses. +- You can add your own options to the top of Tor's configuration file, and they are kept. +- Updated Tor to 0.4.9.13. [Full upstream release notes](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, + es_ES: `**Se han eliminado los modos de relé y de puente.** La dirección IP de un relé de Tor aparece en el directorio público de Tor. Cuando el mismo servidor aloja además direcciones .onion, esa lista le da a un atacante un conjunto reducido de servidores que probar, y las mediciones de carga y de tiempos pueden entonces vincular una dirección onion con la IP del servidor. Ejecutar ambos como procesos separados no lo impide; basta con que compartan servidor. El Proyecto Tor desaconseja alojar servicios onion en un relé, y casi todos los servidores StartOS los alojan, así que este paquete ya no ofrece ambas cosas. Está previsto que el relé vuelva como un servicio aparte. + +Si tenía un relé en marcha, se detiene con esta actualización. La interfaz Puerto OR del relé de Tor desaparece, así que puede eliminar cualquier redirección de puertos que hubiera creado en su router. Las claves de identidad de su relé siguen en el volumen de Tor y en sus copias de seguridad. Se retira el antiguo host del relé; vuelva a asociar los dominios personalizados que le haya asignado a una interfaz de servicio actual. + +- Añade el ajuste Recuperación automática. Sigue activado de forma predeterminada; al desactivarlo, Tor falla en cerrado. +- Una dirección .onion ahora sigue a su servicio cuando cambian los puertos de este. +- La clave de una dirección .onion nunca se elimina automáticamente. Una dirección que ninguna interfaz usa permanece hasta que la elimine con Eliminar direcciones onion sin usar, que sustituye a Eliminar direcciones onion. +- Restaurar Tor antes que los servicios que usan sus direcciones .onion ya no elimina esas direcciones. +- Puede añadir sus propias opciones al principio del archivo de configuración de Tor, y se conservan. +- Tor actualizado a 0.4.9.13. [Notas completas de la versión original](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, + de_DE: `**Relay- und Bridge-Modus wurden entfernt.** Die IP-Adresse eines Tor-Relays steht im öffentlichen Verzeichnis von Tor. Hostet derselbe Server auch .onion-Adressen, liefert dieses Verzeichnis einem Angreifer eine kurze Liste von Servern zum Testen, und Last- und Zeitmessungen können dann eine Onion-Adresse mit der IP des Servers verknüpfen. Beides als getrennte Prozesse zu betreiben verhindert das nicht; ein gemeinsamer Server genügt. Das Tor-Projekt rät davon ab, Onion-Dienste auf einem Relay zu hosten, und fast jeder StartOS-Server hostet sie, daher bietet dieses Paket nicht mehr beides an. Die Relay-Unterstützung soll als eigener Dienst zurückkehren. + +Falls Sie ein Relay betrieben haben, endet es mit diesem Update. Die Schnittstelle „Tor-Relay-OR-Port“ entfällt, sodass Sie eine dafür im Router eingerichtete Portweiterleitung entfernen können. Die Identitätsschlüssel Ihres Relays bleiben auf dem Tor-Volume und in dessen Backups erhalten. Der alte Relay-Host wird entfernt; ordnen Sie dessen benutzerdefinierte Domains einer aktuellen Dienstschnittstelle zu. + +- Neue Einstellung „Automatische Wiederherstellung“. Sie ist wie bisher standardmäßig aktiv; ausgeschaltet verhält sich Tor fail-closed. +- Eine .onion-Adresse folgt jetzt ihrem Dienst, wenn sich dessen Ports ändern. +- Der Schlüssel einer .onion-Adresse wird nie automatisch gelöscht. Eine Adresse, die keine Schnittstelle verwendet, bleibt erhalten, bis Sie sie mit „Ungenutzte Onion-Adressen löschen“ löschen; diese Aktion ersetzt „Onion-Adressen löschen“. +- Wird Tor vor den Diensten wiederhergestellt, die seine .onion-Adressen nutzen, werden diese Adressen nicht mehr gelöscht. +- Sie können am Anfang der Tor-Konfigurationsdatei eigene Optionen eintragen; sie bleiben erhalten. +- Tor auf 0.4.9.13 aktualisiert. [Vollständige Versionshinweise des Originalprojekts](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, + pl_PL: `**Tryb przekaźnika i mostka został usunięty.** Adres IP przekaźnika Tor jest widoczny w publicznym katalogu Tora. Gdy ten sam serwer hostuje także adresy .onion, katalog ten daje atakującemu krótką listę serwerów do sprawdzenia, a pomiary obciążenia i czasu mogą wtedy powiązać adres onion z adresem IP serwera. Uruchomienie obu jako osobnych procesów temu nie zapobiega; wystarczy wspólny serwer. Projekt Tor odradza hostowanie usług onion na przekaźniku, a niemal każdy serwer StartOS je hostuje, dlatego ten pakiet nie oferuje już obu naraz. Obsługa przekaźnika ma wrócić jako osobna usługa. + +Jeśli prowadziłeś przekaźnik, ta aktualizacja go zatrzymuje. Interfejs Port OR przekaźnika Tor znika, więc możesz usunąć przekierowanie portu utworzone dla niego na routerze. Klucze tożsamości przekaźnika pozostają na wolumenie Tora i w jego kopiach zapasowych. Stary host przekaźnika zostaje wycofany; przypisz jego niestandardowe domeny do aktualnego interfejsu usługi. + +- Dodaje ustawienie Automatyczne odzyskiwanie. Jest domyślnie włączone, jak dotąd; po wyłączeniu Tor działa w trybie fail-closed. +- Adres .onion podąża teraz za swoją usługą, gdy zmieniają się jej porty. +- Klucz adresu .onion nigdy nie jest usuwany automatycznie. Adres, którego nie używa żaden interfejs, pozostaje, dopóki nie usuniesz go akcją Usuń nieużywane adresy onion, która zastępuje akcję Usuń adresy onion. +- Przywrócenie Tora przed usługami korzystającymi z jego adresów .onion nie usuwa już tych adresów. +- Możesz dodać własne opcje na początku pliku konfiguracyjnego Tora i zostaną one zachowane. +- Zaktualizowano Tor do wersji 0.4.9.13. [Pełne informacje o wydaniu projektu źródłowego](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, + fr_FR: `**Les modes relais et pont ont été supprimés.** L'adresse IP d'un relais Tor figure dans l'annuaire public de Tor. Lorsque le même serveur héberge aussi des adresses .onion, cet annuaire fournit à un attaquant une courte liste de serveurs à tester, et des mesures de charge et de temps peuvent alors relier une adresse onion à l'IP du serveur. Les exécuter dans des processus séparés n'y change rien ; partager un serveur suffit. Le Projet Tor déconseille d'héberger des services onion sur un relais, et presque tous les serveurs StartOS en hébergent : ce paquet ne propose donc plus les deux. La prise en charge du relais devrait revenir sous forme de service distinct. + +Si vous faisiez tourner un relais, il s'arrête avec cette mise à jour. L'interface Port OR du relais Tor disparaît ; vous pouvez donc supprimer la redirection de port créée pour elle sur votre routeur. Les clés d'identité de votre relais restent sur le volume Tor et dans ses sauvegardes. L'ancien hôte du relais est retiré ; rattachez ses domaines personnalisés à une interface de service actuelle. + +- Ajoute le réglage Récupération automatique. Il reste activé par défaut ; désactivé, Tor échoue en mode fermé. +- Une adresse .onion suit désormais son service lorsque les ports de celui-ci changent. +- La clé d'une adresse .onion n'est jamais supprimée automatiquement. Une adresse qu'aucune interface n'utilise reste en place jusqu'à ce que vous la supprimiez avec Supprimer les adresses onion inutilisées, qui remplace Supprimer les adresses onion. +- Restaurer Tor avant les services qui utilisent ses adresses .onion ne supprime plus ces adresses. +- Vous pouvez ajouter vos propres options au début du fichier de configuration de Tor ; elles sont conservées. +- Tor mis à jour vers la version 0.4.9.13. [Notes de version complètes du projet d'origine](https://gitlab.torproject.org/tpo/core/tor/-/tags/tor-0.4.9.13).`, }, migrations: { - up: async ({ effects }) => {}, + /** + * Takes a volume written by an earlier release to the layout this + * one reads. Each step checks its own precondition, so a run interrupted + * part-way finishes on the next one. + */ + up: async ({ effects }) => { + // The legacy torrc was the database. Read the onions out of it, parked + // ones included, then set it aside so the renderer writes a fresh one. + // The relay directives in it are dropped with it. + await sdk.MultiHost.of(effects, 'or-multi').retire() + + const raw = await torrcFile.read().once() + if (raw !== null && !hasMarker(raw)) { + const legacy = await legacyTorrc.read().once() + const onions: Onions = present( + await storeJson.read((s) => s.onions).once(), + ) + for (const [packageId, hosts] of Object.entries( + legacy?.onionServices ?? {}, + )) { + for (const [hostId, services] of Object.entries(hosts ?? {})) { + for (const [index, svc] of Object.entries(services ?? {})) { + if (!svc) continue + const ports: OnionPort[] = [] + for (const [external, p] of Object.entries(svc.ports)) { + if (!p || isNaN(p.internalPort)) continue + const { internalPort } = p + // An entry can record a mode its binding never served. Follow + // the mode it does serve, as earlier releases did on every start. + const serves = (ssl: boolean) => + sdk.host + .getBridgeAddress(effects, { + packageId, + hostId, + internalPort, + ssl, + }) + .once() + .then( + (a) => a !== null, + () => false, + ) + const ssl = + !(await serves(p.ssl)) && (await serves(!p.ssl)) + ? !p.ssl + : p.ssl + ports.push({ + externalPort: Number(external), + internalPort, + ssl, + }) + } + onions[onionId(packageId, hostId, index)] = { ports } + } + } + } + const store = await storeJson.read().once() + await storeJson.write(effects, { + ...(store ?? { automaticRecovery: true }), + onions, + }) + await rename( + sdk.volumes.tor.subpath('torrc'), + sdk.volumes.tor.subpath('torrc.legacy'), + ) + console.info( + `Imported ${Object.keys(onions).length} onion address(es) from the legacy torrc`, + ) + } + + // Tor's DataDirectory moves under data/. Carry its state file and caches + // over: starting from an empty one would re-select every server's entry + // nodes for no reason. + const root = sdk.volumes.tor.path + const entries = await readdir(root).catch(() => [] as string[]) + const torData = entries.filter((e) => !NOT_TOR_DATA.includes(e)) + if (torData.length) { + await mkdir(sdk.volumes.tor.subpath('data'), { recursive: true }) + for (const entry of torData) { + await rename( + sdk.volumes.tor.subpath(entry), + sdk.volumes.tor.subpath(`data/${entry}`), + ).catch((e) => + console.warn(`Left ${entry} where it was: ${String(e)}`), + ) + } + } + }, down: IMPOSSIBLE, }, }) diff --git a/startos/versions/legacy/torrc.ts b/startos/versions/legacy/torrc.ts new file mode 100644 index 0000000..794643d --- /dev/null +++ b/startos/versions/legacy/torrc.ts @@ -0,0 +1,346 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../../sdk' +import { socksPort } from '../../utils' + +const portInfoShape = z.looseObject({ + target: z.string().nullable(), + ssl: z.boolean(), + internalPort: z.number(), +}) +type PortInfo = z.infer + +export const onionServiceEntryShape = z + .looseObject({ + ports: z.record(z.string(), portInfoShape.optional().catch(undefined)), + }) + .catch({ ports: {} }) + +export const relayShape = z.looseObject({ + enabled: z.boolean().catch(false), + nickname: z.string().min(1).optional().catch(undefined), + contactInfo: z.string().optional().catch(undefined), + bridge: z.boolean().catch(false), + orPort: z.number().catch(9001), + bandwidthRate: z.number().catch(1024), + bandwidthBurst: z.number().catch(2048), +}) + +const shape = z.looseObject({ + onionServices: z + .record( + z.string(), + z + .record( + z.string(), + z + .record( + z.string(), + onionServiceEntryShape.optional().catch(undefined), + ) + .optional() + .catch(undefined), + ) + .optional() + .catch(undefined), + ) + .catch({}), + relay: relayShape.catch({ + enabled: false, + bridge: false, + orPort: 9001, + bandwidthRate: 1024, + bandwidthBurst: 2048, + }), + // Not user configuration: what the relay advertises, derived from the OR + // binding by init/advertiseRelay. `orPort` is the configured port it was + // derived for, so changing the OR port drops it until it is derived again. + advertise: z + .looseObject({ + orPort: z.number().nullable().catch(null), + address: z.string().nullable().catch(null), + port: z.number().nullable().catch(null), + ipv4Only: z.boolean().catch(false), + }) + .catch({ orPort: null, address: null, port: null, ipv4Only: false }), +}) + +export type TorrcConfig = z.infer + +export function hsDir(packageId: string, hostId: string, index: string) { + return `hidden_services/${packageId}/${hostId}/hs_${index}` +} + +/** + * Marks an entry `undefined` in place, which `merge` drops from the file, and + * does the same to the host and package records it empties. + */ +export function dropOnionService( + onionServices: TorrcConfig['onionServices'], + packageId: string, + hostId: string, + index: string, +) { + const hosts = onionServices[packageId] + const services = hosts?.[hostId] + if (!hosts || !services) return + ;(services as any)[index] = undefined + if (Object.values(services).every((v) => v === undefined)) + (hosts as any)[hostId] = undefined + if (Object.values(hosts).every((v) => v === undefined)) + (onionServices as any)[packageId] = undefined +} + +/** + * Returns the next sequential numeric key (as a string) for a record. + * Gaps from deleted keys are intentionally NOT reused, since keys map to + * HiddenServiceDir paths containing cryptographic key material. + */ +export function nextKey(record: Record): string { + return String( + Object.keys(record) + .map(Number) + .filter((n) => !isNaN(n)) + .reduce((acc, x) => (x >= acc ? x + 1 : acc), 0), + ) +} + +/** + * Serializes structured config to a torrc file. + * Embeds `# @service`, `# @ssl`, and `# @internalPort` comment annotations so + * fromFile() can reconstruct the structured data (packageId, hostId, SSL + * status, upstream internal port) on read. + * A port with a null target is parked: its directive is written commented out, + * and so is the HiddenServiceDir once every port of the entry is. + */ +function toFile(config: TorrcConfig): string { + const lines: string[] = [ + `SocksPort 0.0.0.0:${socksPort}`, + 'DataDirectory /var/lib/tor', + 'ControlSocket /var/lib/tor/control.sock', + '', + ] + + const onionServices = config.onionServices || {} + for (const [packageId, hosts] of Object.entries(onionServices)) { + if (!hosts) continue + for (const [hostId, services] of Object.entries(hosts)) { + if (!services) continue + Object.entries(services).forEach(([index, svc]) => { + if (!svc) return + const ports = Object.entries(svc.ports).filter( + (e): e is [string, PortInfo] => !!e[1], + ) + if (ports.length === 0) return + const served = ports.some(([, p]) => p.target !== null) + lines.push(`# @service ${packageId} ${hostId}`) + lines.push( + `${served ? '' : '#'}HiddenServiceDir /var/lib/tor/${hsDir(packageId, hostId, index)}/`, + ) + for (const [externalPort, portInfo] of ports) { + if (portInfo.ssl) lines.push(`# @ssl ${portInfo.internalPort}`) + else lines.push(`# @internalPort ${portInfo.internalPort}`) + lines.push( + portInfo.target === null + ? `#HiddenServicePort ${externalPort}` + : `HiddenServicePort ${externalPort} ${portInfo.target}`, + ) + } + lines.push('') + }) + } + } + + const relay = config.relay + if (relay?.enabled) { + const advertise = + config.advertise?.orPort === relay.orPort ? config.advertise : null + // StartOS may assign the binding a different external port than the one + // Tor listens on: advertise the assigned port, listen on the configured one. + // A bare ORPort is an IPv6 ORPort too, and with no IPv6 address to publish + // Tor logs a notice about it every hour. + const family = advertise?.ipv4Only ? ' IPv4Only' : '' + if (advertise?.port && advertise.port !== relay.orPort) { + lines.push(`ORPort ${advertise.port} NoListen${family}`) + lines.push(`ORPort ${relay.orPort} NoAdvertise${family}`) + } else { + lines.push(`ORPort ${relay.orPort}${family}`) + } + if (advertise?.address) lines.push(`Address ${advertise.address}`) + if (relay.nickname) lines.push(`Nickname ${relay.nickname}`) + if (relay.contactInfo) lines.push(`ContactInfo ${relay.contactInfo}`) + if (relay.bridge) lines.push('BridgeRelay 1') + lines.push(`RelayBandwidthRate ${relay.bandwidthRate} KBytes`) + lines.push(`RelayBandwidthBurst ${relay.bandwidthBurst} KBytes`) + lines.push('ExitRelay 0') + lines.push('') + } + + return lines.join('\n') +} + +const kbytes = (n: string, unit: string) => + parseInt(n, 10) * (unit === 'M' ? 1024 : 1) + +/** + * Parses a torrc file back into structured config. + * Uses a state machine to group HiddenServiceDir/HiddenServicePort blocks, + * reading `# @service`, `# @ssl`, and `# @internalPort` annotations to + * recover metadata. + * Bandwidth values are stored in KBytes; a torrc written by an earlier + * release carries MBytes. + */ +function fromFile(raw: string): unknown { + const res: z.infer = { + onionServices: {}, + relay: { + enabled: false, + bridge: false, + orPort: 9001, + bandwidthRate: 1024, + bandwidthBurst: 2048, + }, + advertise: { orPort: null, address: null, port: null, ipv4Only: false }, + } + + const lines = raw.split('\n') + let currentPackageId: string | null = null + let currentHostId: string | null = null + let currentIndex: string | null = null + let currentPorts: Record = {} + let nextSslInternalPort: number | null = null + let nextInternalPort: number | null = null + let listens = false + + function flushCurrent() { + if ( + currentPackageId && + currentHostId && + currentIndex && + Object.keys(currentPorts).length > 0 + ) { + if (!res.onionServices[currentPackageId]) + res.onionServices[currentPackageId] = {} + if (!res.onionServices[currentPackageId]![currentHostId]) + res.onionServices[currentPackageId]![currentHostId] = {} + res.onionServices[currentPackageId]![currentHostId]![currentIndex] = { + ports: currentPorts, + } + } + currentPackageId = null + currentHostId = null + currentIndex = null + currentPorts = {} + nextSslInternalPort = null + nextInternalPort = null + } + + for (const line of lines) { + const trimmed = line.trim() + + const serviceMatch = trimmed.match(/^# @service (\S+) (\S+)$/) + if (serviceMatch) { + flushCurrent() + currentPackageId = serviceMatch[1] + currentHostId = serviceMatch[2] + continue + } + + const sslMatch = trimmed.match(/^# @ssl (\d+)$/) + if (sslMatch) { + nextSslInternalPort = parseInt(sslMatch[1], 10) + continue + } + + const internalPortMatch = trimmed.match(/^# @internalPort (\d+)$/) + if (internalPortMatch) { + nextInternalPort = parseInt(internalPortMatch[1], 10) + continue + } + + const hsDirMatch = trimmed.match( + /^#?\s*HiddenServiceDir\s.*\/hs_([^/]+)\/?$/, + ) + if (hsDirMatch) { + currentIndex = hsDirMatch[1] + continue + } + + const portMatch = trimmed.match( + /^(#?)\s*HiddenServicePort (\d+)(?:\s+(\S+))?/, + ) + if (portMatch && currentPackageId) { + const target = portMatch[1] || !portMatch[3] ? null : portMatch[3] + if (nextSslInternalPort !== null) { + currentPorts[portMatch[2]] = { + target, + ssl: true, + internalPort: nextSslInternalPort, + } + nextSslInternalPort = null + } else { + // Prefer the `# @internalPort` annotation; fall back to the target + // port for legacy entries written before the annotation existed + // (where it equals the lxcbr0 NAT port, not the upstream internal + // port, for SSL-wrapped/port-shifted bindings). + const internalPort = + nextInternalPort ?? + (target === null + ? NaN + : parseInt(target.slice(target.lastIndexOf(':') + 1), 10)) + currentPorts[portMatch[2]] = { target, ssl: false, internalPort } + nextInternalPort = null + } + continue + } + + let m + if ((m = trimmed.match(/^ORPort (\d+)(.*)/))) { + flushCurrent() + res.relay.enabled = true + const flags = m[2].split(/\s+/) + // A NoListen line carries the advertised port; the port Tor listens on + // is written without it. + if (flags.includes('NoListen')) res.advertise.port = parseInt(m[1], 10) + else { + res.relay.orPort = parseInt(m[1], 10) + listens = true + } + if (flags.includes('IPv4Only')) res.advertise.ipv4Only = true + } else if ((m = trimmed.match(/^Address (\S+)/))) { + res.advertise.address = m[1] + } else if ((m = trimmed.match(/^Nickname (.+)/))) { + res.relay.nickname = m[1] + } else if ((m = trimmed.match(/^ContactInfo (.+)/))) { + res.relay.contactInfo = m[1] + } else if (trimmed === 'BridgeRelay 1') { + res.relay.bridge = true + } else if ((m = trimmed.match(/^RelayBandwidthRate (\d+) ([KM])Bytes/))) { + res.relay.bandwidthRate = kbytes(m[1], m[2]) + } else if ((m = trimmed.match(/^RelayBandwidthBurst (\d+) ([KM])Bytes/))) { + res.relay.bandwidthBurst = kbytes(m[1], m[2]) + } + } + + flushCurrent() + // A NoListen line that lost its listening partner still names the relay's port. + if (res.advertise.port !== null && !listens) { + res.relay.orPort = res.advertise.port + res.advertise.port = null + } + if ( + res.advertise.address !== null || + res.advertise.port !== null || + res.advertise.ipv4Only + ) { + res.advertise.orPort = res.relay.orPort + } + + return res +} + +export const torrc = FileHelper.raw( + { base: sdk.volumes.tor, subpath: '/torrc' }, + toFile, + fromFile, + (data) => shape.parse(data), +) diff --git a/startos/versions/v0.4.9.11_6.ts b/startos/versions/v0.4.9.11_6.ts index c5999d5..d6613e2 100644 --- a/startos/versions/v0.4.9.11_6.ts +++ b/startos/versions/v0.4.9.11_6.ts @@ -1,7 +1,7 @@ import { mkdir, rename, rmdir } from 'node:fs/promises' import { dirname } from 'node:path' import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' -import { hsDir, nextKey, torrc } from '../fileModels/torrc' +import { hsDir, nextKey, torrc } from './legacy/torrc' import { sdk } from '../sdk' const LEGACY_ID = 'STARTOS' diff --git a/startos/versions/v0.4.9.12_2.ts b/startos/versions/v0.4.9.12_2.ts index 07fd2e6..9e4414d 100644 --- a/startos/versions/v0.4.9.12_2.ts +++ b/startos/versions/v0.4.9.12_2.ts @@ -3,7 +3,7 @@ import { VersionInfo, z } from '@start9labs/start-sdk' import { sdk } from '../sdk' import { autoWiped, wipeRequested } from '../utils/recovery' -const legacyWatchdogShape = z.object({ +const legacyWatchdogShape = z.looseObject({ wipeRequested: z.boolean().catch(false), autoWiped: z.boolean().catch(false), }) diff --git a/test/onions.test.cjs b/test/onions.test.cjs new file mode 100644 index 0000000..89414e1 --- /dev/null +++ b/test/onions.test.cjs @@ -0,0 +1,293 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { test, beforeEach, afterEach } = require('node:test') +const ts = require('typescript') + +require.extensions['.ts'] = (module, filename) => { + const source = require('node:fs').readFileSync(filename, 'utf8') + module._compile( + ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: filename, + }).outputText, + filename, + ) +} + +const { sdk } = require('../startos/sdk.ts') +const watch = (read) => ({ + once: async () => read(), + const: async () => read(), +}) +let hosts, addresses, contents, torrc, exported, lookupFails +const root = require('node:fs').mkdtempSync( + path.join(os.tmpdir(), 'tor-onions-'), +) +sdk.volumes.tor.path = path.join(root, 'tor') +sdk.volumes.startos.path = path.join(root, 'startos') +sdk.setupOnInit = (fn) => fn +sdk.plugin.url.setupExportedUrls = (fn) => fn +sdk.plugin.url.exportUrl = async (_effects, value) => exported.push(value) +sdk.host.get = (_effects, { packageId, hostId }, project = (host) => host) => + watch(() => { + if (lookupFails) throw new Error('Host lookup failed') + return project(hosts[`${packageId}/${hostId}`] ?? null) + }) +sdk.host.getBridgeAddress = ( + _effects, + { packageId, hostId, internalPort, ssl }, +) => + watch( + () => addresses[`${packageId}/${hostId}/${internalPort}/${ssl}`] ?? null, + ) + +const store = require('../startos/fileModels/store.json.ts') +store.storeJson.read = (project = (value) => value) => + watch(() => project(structuredClone(contents))) +store.storeJson.write = async (_effects, value) => { + contents = structuredClone(value) +} +const { torrcFile } = require('../startos/fileModels/torrc.ts') +torrcFile.read = () => watch(() => torrc) +torrcFile.write = async (_effects, value) => { + torrc = value +} +const { renderTorrc } = require('../startos/init/renderTorrc.ts') +const { exportUrls } = require('../startos/plugin/url.ts') +const { addOnionService } = require('../startos/actions/addOnionService.ts') +const { + deleteOnionService, +} = require('../startos/actions/deleteOnionService.ts') +const { + deleteUnusedAddresses, +} = require('../startos/actions/deleteUnusedAddresses.ts') +const { + migrateOnionAddresses, +} = require('../startos/init/migrateOnionAddresses.ts') +const { generateOnionFiles, isClamped } = require('../startos/utils/index.ts') +const { isServed, onionHostname } = require('../startos/utils/onions.ts') +const effects = { eventId: 'onion-test', isInContext: true } +const port = { externalPort: 8333, internalPort: 58333, ssl: false } +const binding = (enabled = true) => ({ + enabled, + options: { + preferredExternalPort: 8333, + secure: { ssl: false }, + addSsl: null, + }, + interfaces: { peer: { type: 'p2p' } }, +}) +const metadata = (hostId = 'peer', packageId = 'bitcoind') => ({ + packageId, + hostId, + interfaceId: 'peer', + internalPort: 58333, +}) +const input = (selection, hostId = 'peer', packageId = 'bitcoind') => ({ + urlPluginMetadata: metadata(hostId, packageId), + ssl: false, + address: { + selection, + value: selection === 'new' ? { privateKey: null } : {}, + }, +}) +async function seed(id, ports = [port]) { + contents.onions[id] = { ports } + const files = generateOnionFiles() + await sdk.volumes.tor.writeFile( + `${store.hsDir(id)}/hostname`, + `${files.hostname}\n`, + ) + await sdk.volumes.tor.writeFile( + `${store.hsDir(id)}/hs_ed25519_secret_key`, + files.secretKey, + ) + return files +} +async function run(action, value, caller = null) { + await action.getInput({ effects, prefill: value, caller }) + return action.run({ effects, input: value, caller }) +} + +beforeEach(async () => { + await fs.mkdir(root, { recursive: true }) + hosts = { 'bitcoind/peer': { bindings: { 58333: binding() } } } + addresses = { 'bitcoind/peer/58333/false': '10.0.3.1:50000' } + contents = { automaticRecovery: true, onions: {} } + torrc = '' + exported = [] + lookupFails = false +}) +afterEach(async () => fs.rm(root, { recursive: true, force: true })) + +test('rendering and URL export require the same enabled bridge leg', async () => { + const id = 'bitcoind/peer/0' + await seed(id) + for (const [enabled, target, served] of [ + [true, '10.0.3.1:50000', true], + [true, null, false], + [false, '10.0.3.1:50000', false], + ]) { + hosts['bitcoind/peer'].bindings[58333].enabled = enabled + addresses['bitcoind/peer/58333/false'] = target + exported = [] + await renderTorrc(effects) + await exportUrls({ effects }) + assert.equal(torrc.includes('HiddenServicePort 8333'), served) + assert.equal(exported.length, served ? 1 : 0) + } + assert.equal(await isServed(effects, id, contents.onions[id]), true) + delete hosts['bitcoind/peer'] + await renderTorrc(effects) + exported = [] + await exportUrls({ effects }) + assert.equal(exported.length, 0) + assert.equal(await isServed(effects, id, contents.onions[id]), false) + assert.ok(contents.onions[id]) + assert.ok(await onionHostname(id)) +}) + +test('legacy peer-port reuse retains the hostname and public onion port', async () => { + const id = 'bitcoind/peer/0' + const files = await seed(id, [{ ...port, internalPort: 8333 }]) + await run(addOnionService, input(id), 'bitcoind') + assert.deepEqual(contents.onions[id].ports, [port]) + assert.equal(await onionHostname(id), files.hostname) +}) + +test('an unused address can move within its package without moving its keys', async () => { + const id = 'bitcoind/old-peer/0' + const files = await seed(id) + await run(addOnionService, input(id), 'bitcoind') + const moved = 'bitcoind/peer/0' + assert.equal(contents.onions[id], undefined) + assert.equal(contents.onions[moved].keyId, id) + assert.deepEqual(contents.onions[moved].ports, [port]) + assert.equal( + await onionHostname(moved, contents.onions[moved]), + files.hostname, + ) + assert.deepEqual( + await sdk.volumes.tor.readFile( + `${store.hsDir(moved, contents.onions[moved])}/hs_ed25519_secret_key`, + ), + files.secretKey, + ) + await renderTorrc(effects) + assert.ok(torrc.includes(`/var/lib/tor/${store.hsDir(id)}/`)) + await exportUrls({ effects }) + assert.equal(exported[0].hostnameInfo.hostname, files.hostname) + assert.equal(exported[0].hostnameInfo.hostId, 'peer') + + await run( + deleteOnionService, + { + urlPluginMetadata: { + ...metadata(), + hostname: files.hostname, + port: 8333, + ssl: false, + }, + }, + 'bitcoind', + ) + assert.equal(contents.onions[moved].ports.length, 0) + await run(deleteUnusedAddresses, { addresses: [moved] }) + assert.equal(contents.onions[moved], undefined) + assert.equal(await onionHostname(id), null) +}) + +test('cross-host reuse rejects an address that became disabled after opening the form', async () => { + const id = 'bitcoind/old-peer/0' + await seed(id) + const value = input(id) + await addOnionService.getInput({ + effects, + prefill: value, + caller: 'bitcoind', + }) + hosts['bitcoind/old-peer'] = { bindings: { 58333: binding(false) } } + await assert.rejects( + addOnionService.run({ effects, input: value, caller: 'bitcoind' }), + /unused onion/, + ) + assert.ok(contents.onions[id]) +}) + +test('callers cannot list or mutate another package’s addresses', async () => { + await seed('other/old-peer/0') + await assert.rejects( + addOnionService.getInput({ + effects, + prefill: input('new'), + caller: 'other', + }), + /only manage its own/, + ) + const form = await addOnionService.getInput({ + effects, + prefill: input('new'), + caller: null, + }) + assert.equal(JSON.stringify(form.spec).includes('other/old-peer/0'), false) + + hosts['other/old-peer'] = { bindings: { 58333: binding() } } + const value = input('other/old-peer/0', 'old-peer', 'other') + await addOnionService.getInput({ effects, prefill: value, caller: null }) + value.urlPluginMetadata = metadata() + await assert.rejects( + addOnionService.run({ effects, input: value, caller: null }), + /another service/, + ) + assert.ok(contents.onions['other/old-peer/0']) +}) + +test('lookup failure prevents deleting a key selected as unused', async () => { + const id = 'bitcoind/old-peer/0' + const files = await seed(id) + await deleteUnusedAddresses.getInput({ effects, caller: null }) + lookupFails = true + await assert.rejects( + deleteUnusedAddresses.run({ + effects, + input: { addresses: [id] }, + caller: null, + }), + /Host lookup failed/, + ) + assert.ok(contents.onions[id]) + assert.equal(await onionHostname(id), files.hostname) +}) + +test('an interrupted legacy import preserves absent-host keys and retries without duplication', async () => { + const files = generateOnionFiles() + const key = files.secretKey.subarray(32) + assert.equal(isClamped(key.subarray(0, 32)), true) + await sdk.volumes.startos.writeFile( + 'onion-migration.json', + JSON.stringify({ + addresses: [ + { + packageId: 'missing', + hostId: 'old-host', + hostname: files.hostname, + key: key.toString('base64'), + }, + ], + }), + ) + await fs.mkdir(sdk.volumes.startos.subpath('.onion-migration.json.bak')) + await assert.rejects(migrateOnionAddresses(effects)) + const id = 'missing/old-host/0' + assert.deepEqual(contents.onions[id].ports, []) + assert.equal(await onionHostname(id), files.hostname) + await fs.rmdir(sdk.volumes.startos.subpath('.onion-migration.json.bak')) + await migrateOnionAddresses(effects) + assert.deepEqual(Object.keys(contents.onions), [id]) + assert.equal(await onionHostname(id), files.hostname) +})