Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,11 @@ Public reachability needs the inbound path and the advertised address to agree.

### 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 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, its binding gone from a host that still exists, or its binding disabled and keeping no interface that an enabled binding of the host does not also carry, which is a port the package stopped declaring — is parked rather than served, and no interface page shows it. **Delete Onion Addresses** is where it can be removed, and adding a Tor address on one of the service's current interfaces is where it can be moved: the parked address is offered beside creating a new one.

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."

**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 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 whose binding is disabled and keeps no interface that an enabled binding of the host does not also carry, or has 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. Disabled alone is not the signal — StartOS disables every binding of a package when its container is created and strips a binding's interfaces only at the end of the package's own interface pass — and an interface id is not either: an interface moved to another port keeps a copy of its record on the old binding until StartOS 0.4.0.2, so an id an enabled binding also carries is that copy, not a claim on the port.

## Installation and First-Run Flow

Expand All @@ -134,12 +134,13 @@ Five actions: two hidden ones the plugin drives, and three for you.
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.

- **Deleting is permanent.** The secret key is removed with the entry, so the `.onion` address can never be recovered or reassigned.
- **A parked address of the same package, from any of its hosts, is offered beside creating a new one.** Choosing it moves the address to this interface: its parked ports are replaced with the new one, and an address from another host has its key directory moved under this one.

### Delete 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.
Lists every `.onion` address in `torrc` with the package, host and port it belongs to, marks the ones no longer attached to an interface, and deletes the selected entries with their key material.

- **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.
- **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, this action removes it, or adding a Tor address on one of the service's current interfaces moves it.
- **What it changes:** removes the entries from `torrc` and deletes their `hidden_services/` directories. Tor reloads when `torrc` changes.
- **Repeat safety:** deleting is permanent — the key is the address.
- **Availability: any status.**
Expand Down
10 changes: 7 additions & 3 deletions instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@

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.

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.
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, or whose service stopped using the port it was attached to, stops answering until the port is back; it keeps its key, so it returns unchanged.

### Removing a .onion address that no longer shows anywhere
### A .onion address that no longer shows anywhere

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

To keep the address, open the interface the service uses now and add a Tor address there: the parked address is offered next to **Create new address**, and choosing it moves the address to that interface, where it starts answering again.

To remove it, open Tor's **Actions** menu and run **Delete Onion Addresses**: it lists every `.onion` address the server hosts with the service and port it was attached to, marks the ones no longer attached to an interface, and deletes the ones you pick. Deleting is permanent — the key goes with the address.

### Tor is stuck connecting

Expand Down
86 changes: 53 additions & 33 deletions startos/actions/addOnionService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { hsDir, nextKey, torrc } from '../fileModels/torrc'
import { mkdir, rename } from 'node:fs/promises'
import { dirname } from 'node:path'
import { dropOnionService, hsDir, nextKey, torrc } from '../fileModels/torrc'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { bridgeHost, generateOnionFiles } from '../utils'
Expand Down Expand Up @@ -70,9 +72,7 @@ const inputSpec = InputSpec.of({
prefill?.urlPluginMetadata ?? {}

const config = await torrc.read().once()
const entries =
(packageId && hostId && config?.onionServices?.[packageId]?.[hostId]) ||
{}
const hosts = (packageId && config?.onionServices?.[packageId]) || {}

// Which onion bindings this interface can serve, mirroring the execution
// path: a plaintext primary unless the service terminates its own TLS, plus
Expand All @@ -96,33 +96,40 @@ const inputSpec = InputSpec.of({
}
> = {}

for (const [key, entry] of Object.entries(entries)) {
if (!entry || internalPort == null) continue
for (const [host, entries] of Object.entries(hosts)) {
for (const [key, entry] of Object.entries(entries ?? {})) {
if (!entry || internalPort == null) continue

const bindingPorts = Object.values(entry.ports).filter(
(p) => p?.internalPort === internalPort,
)
const hasNonSsl = bindingPorts.some((p) => p && !p.ssl)
const hasSsl = bindingPorts.some((p) => p?.ssl)
const bindingPorts =
host === hostId
? Object.values(entry.ports).filter(
(p) => p?.internalPort === internalPort,
)
: []
const hasNonSsl = bindingPorts.some((p) => p && !p.ssl)
const hasSsl = bindingPorts.some((p) => p?.ssl)
const parked = Object.values(entry.ports).every(
(p) => !p || p.target === null,
)

// 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
// Offer an address serving this binding with a mode to spare, or a
// parked one from any host of the package, which moves here.
if (!hasNonSsl && !hasSsl && !parked) continue
if ((!availNonSsl || hasNonSsl) && (!availSsl || hasSsl)) continue

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
}
variants[key] = {
name: hostname,
spec: InputSpec.of({}),
let hostname = key
try {
const content = await sdk.volumes.tor.readFile(
`${hsDir(packageId!, host, key)}/hostname`,
)
hostname = content.toString().trim()
} catch {
// hostname file doesn't exist yet
}
variants[`${host}/${key}`] = {
name: hostname,
spec: InputSpec.of({}),
}
}
}

Expand Down Expand Up @@ -256,9 +263,19 @@ export const addOnionService = sdk.Action.withInput(
const services = onionServices[packageId][hostId]

if (address.selection !== 'new') {
// Reuse existing address by key
const existing = services[address.selection]
if (existing) {
const [fromHost, key] = address.selection.split('/')
const existing = onionServices[packageId][fromHost]?.[key]
if (existing && fromHost !== hostId) {
const moved = nextKey(services)
const dest = sdk.volumes.tor.subpath(hsDir(packageId, hostId, moved))
await mkdir(dirname(dest), { recursive: true })
await rename(
sdk.volumes.tor.subpath(hsDir(packageId, fromHost, key)),
dest,
)
dropOnionService(onionServices, packageId, fromHost, key)
services[moved] = { ports: newPorts }
} else if (existing) {
const duplicate = Object.values(existing.ports).some(
(p) => p?.ssl === ssl && p?.internalPort === internalPort,
)
Expand All @@ -273,8 +290,11 @@ export const addOnionService = sdk.Action.withInput(
),
)
}
services[address.selection] = {
ports: { ...existing.ports, ...newPorts },
const parked = Object.values(existing.ports).every(
(p) => !p || p.target === null,
)
services[key] = {
ports: parked ? newPorts : { ...existing.ports, ...newPorts },
}
}
} else {
Expand Down
9 changes: 8 additions & 1 deletion startos/actions/deleteOnionAddresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ const inputSpec = InputSpec.of({
const attached = Object.values(svc.ports).some(
(p) => p && p.target !== null,
)
const internalPorts = Array.from(
new Set(
Object.values(svc.ports).flatMap((p) =>
p ? [p.internalPort] : [],
),
),
)
const hostname =
(await onionHostname(packageId, hostId, index)) ??
i18n('address not generated yet')
values[`${packageId}/${hostId}/${index}`] =
`${hostname} — ${packageId}/${hostId}` +
`${hostname} — ${packageId}/${hostId}:${internalPorts.join(',')}` +
(attached ? '' : ` (${i18n('no longer attached to an interface')})`)
}
}
Expand Down
32 changes: 23 additions & 9 deletions startos/init/reconcileOnionTargets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ import { sdk } from '../sdk'
* 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
* Each target is a `getBridgeAddress` const watch and each binding's state a
* mapped host watch, so this re-runs whenever an address it forwards to or a
* binding it depends on 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.
* A port whose binding is disabled and keeps no interface an enabled binding
* of the host does not also carry, or has 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()
Expand Down Expand Up @@ -51,8 +54,19 @@ export const reconcileOnionTargets = sdk.setupOnInit(async (effects) => {
let ssl = portInfo.ssl
let target: string | null
try {
target = await bridge(ssl)
if (target === null) {
// Boot disables every binding but moves no interface: final state.
const superseded = await sdk.host
.get(effects, { packageId, hostId }, (h) => {
const binding = h?.bindings[portInfo.internalPort]
if (!h || !binding || binding.enabled) return false
const live = Object.values(h.bindings).filter((b) => b.enabled)
return Object.keys(binding.interfaces).every((id) =>
live.some((b) => id in b.interfaces),
)
})
.const()
target = superseded ? null : await bridge(ssl)
if (target === null && !superseded) {
// 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.
Expand Down Expand Up @@ -82,7 +96,7 @@ export const reconcileOnionTargets = sdk.setupOnInit(async (effects) => {

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(', ')}`,
`Parked onion services whose port is no longer declared or has no reachable address; they stop answering until it is back, until Delete Onion Addresses removes them, or until adding a Tor address to a current interface moves them: ${parked.join(', ')}`,
)
}
if (resumed.length) {
Expand Down
Loading
Loading