CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# OnionPress Project Memory
## Meta
- **This file holds rules a change must respect**, each with the test that
holds it. Not history: what broke, in which release and why goes in the
commit message or PR. `docs/DESIGN-NOTES.md` has the story behind every
rule here, under the same heading (a 2026-10-26 snapshot, not maintained).
- A new rule: a bullet or two under the section it belongs to, ending in its
test. If a rule needs a paragraph of explanation, put that in docs/.
- Run `/mission` to evaluate a change against OnionPress's three author
commitments: safety, ease of publishing, and content durability.
## Naming Rules (IMPORTANT)
- **OnionPress** (one word, capital O and P), never "Onion.Press",
"onion.press" or "onion-press". Data dir `~/.onionpress/`. Repo
`brewsterkahle/onionpress`.
- **"onion service"**, never "hidden service", in all user-facing text
(external identifiers like `/var/lib/tor/hidden_service/` excepted).
## Key Architecture
- macOS menubar app (py2app, `src/menubar.py`) is a view of the daemon's
state file; `onionpress daemon` (`src/onionpress/daemon.py`, a launchd
agent) runs every loop ("The Mac Daemon").
- Launcher `app/MacOS/onionpress` (Mac) and `linux/onionpress` (Linux);
Docker containers (tor, wordpress, mariadb, onionheaven…) run inside a
Colima VM on macOS.
- **A function that differs by platform takes `os_name`** (`"macos"` /
`"linux"`, `detect_os().value`; read as `OS(os_name)`, so any other name
raises), never a `mac=` or `linux=` flag. `TestPlatformName` in
`tests/test_first_run_key_seed.py` (a census).
- Logs in `~/.onionpress/` (`logs/`, `onionpress.log`, `launcher.log`);
user-visible content in `~/OnionPress/` (backups, Creations).
## Repo Layout
- `src/menubar.py` — py2app entry point, the only flat module.
- `src/onionpress/` — the shared package (everything else). A new submodule
needs one line in `setup.py`'s `includes`.
- `app/` — the .app bundle source (`Info.plist`, `MacOS/`, `Resources/`:
docker configs, plugins, icons, templates). `linux/` — the Linux side.
- `OnionPress.app/` — **gitignored build output**; never commit it.
- py2app because Macs ship no usable Python; users must never need Xcode
tools.
## Build & Release Process
- **After editing any `src/` file the app uses, rebuild the MenubarApp**
(`build/rebuild-menubar.sh`): the bundle holds compiled `.pyc`s, and the
daemon and every `onionpress.cli` command run the bundle's package.
Hot-patching: only `MenubarApp/Contents/Resources/menubar.py` (and its
pyc) is the live entry point.
- **Releases: `build/release.sh` only, on `brewsterkahle/onionpress`, never
a fork.** It passes `-R`, refuses unless HEAD is upstream `main`'s tip,
builds both `.dmg` and `.deb` (Mac side first; Linux only attaches a
`.deb` to a release that has the `.dmg`), writes `SHA256SUMS`, publishes
from draft only after reading it back. Hand-run `gh release` needs
`-R brewsterkahle/onionpress`. No Internet Archive uploads.
`tests/test_release_sha256sums.py`.
- **Every release adds its notes to `RELEASE_HISTORY.md`** before it is cut:
a `## X.Y.Z — YYYY-MM-DD` section at the top, saying what changed for the
people who run OnionPress (what to do, what to know; hub/OnionHome steps
under their own heading). Write it in the bump PR. `release.sh` puts the
section above GitHub's generated PR list and refuses a version without
one; the tree's version must have one. `TestReleaseHistory` in
`tests/test_release_sha256sums.py`.
- **`onionpress update` is one updater on both platforms: `cli.cmd_update`
over `updater.py`** (`find_release`/`check_release`: the latest, or
`--version X.Y.Z`; `--pre` allows a pre-release; a draft, a tag that is
not `v` + a release version, or an answer for another tag is refused;
`version_newer` compares numbers, `--force` reinstalls or downgrades).
Both launchers' `update` cases delegate; the Linux launcher keeps only
the install leaves the Python calls back, `update-install-deb` and
`update-from-checkout`. No second lookup, comparator or `SHA256SUMS`
parser anywhere. `tests/test_linux_release_update.py`,
`tests/test_cli_dispatch.py`.
- On Linux `updater.fetch_deb` installs only a release `.deb` matching
its `SHA256SUMS` line, the onionpress package of the tag's version,
newer than installed (`/opt/onionpress/VERSION`), downloaded from the
release's own URL into a 0700 folder; then `sudo apt-get install -y
--no-remove` in the leaf and `update_finish` (`pull --policy missing`).
Off a terminal it needs `--yes` (the launcher's `-y` filter passes it
on). `tests/test_linux_release_update.py`.
- The Settings page's update only checks, on both platforms (anything in
WordPress can request it): `handle_action._update` runs `update --check
--json`, keeps the answer five minutes in `update-check.json` (GitHub
allows 60 lookups an hour) and writes `update-result.json` with
`installable` and how to install. `TestUpdate` in
`tests/test_handle_action.py`.
- The Mac update mounts a DMG only if it matches the release's `SHA256SUMS`.
`DmgChecksumTest` in `tests/test_updater_staged_swap.py`.
- Every curl in `updater.py` is `_curl_argv` (`--fail`, HTTPS only,
`--cacert /etc/ssl/cert.pem` only where it exists); `--check --json`
always exits 0 and reports `error`, which UIs show as failure.
`tests/test_update_check_ca_bundle.py`.
- **What goes into `/opt/onionpress` is one list: `linux/install-tree.sh`**
(the .deb, `install.sh`, `update --from-checkout`). It refuses links and
special files; `--from-checkout` takes only a clean, non-world-writable
checkout owned by the user or root, fast-forward only.
`tests/test_linux_install_tree.py`.
- `linux/install.sh`: never `rm -rf` a folder named by the environment
(`tests/test_install_sh_clone_cleanup.py`); installs the newest release
tag, not main's HEAD; body is `main() {…}; main "$@"` so a cut-off
`curl | bash` runs nothing; Docker's apt key is checked by fingerprint.
`tests/test_install_sh_release.py`.
- The .deb postinst restarts the units and tray on upgrade and never fails
the install (`tests/test_deb_upgrade_restarts.py`); a first install sets
up rootless Docker, judging success by `docker info`, using
`user_systemctl` (not `--machine`) and `--no-block`
(`tests/test_deb_first_install_docker.py`).
- The rootless setup tool is looked for on PATH, then in docker.io's
contrib, then in the package's own copy of Docker's two scripts
(`linux/docker-rootless/`, pinned by commit and sha256, Apache-2.0 in
`linux/copyright`; Ubuntu's docker.io ships none). A RootlessKit without
`--detach-netns` (Ubuntu 24.04's 1.1.1) gets the user's
`docker.service.d/onionpress-rootlesskit.conf` drop-in turning it off. A
skipped or failed attempt says why at the terminal and in
`~/.onionpress/docker-rootless-setup.log`. **No Compose v1 in
`Depends`**: every Compose alternative is v2 (`docker-compose-plugin |
docker-compose-v2 | docker-compose (>= 2)`), and the launcher's start
checks `docker compose version`. **The .deb Recommends nothing**:
torbrowser-launcher is a Suggests, since as a Recommends it brought the
system `tor` on to 9050 (Ubuntu installs Recommends by default) and the
site moved to 18080. `tests/test_docker_rootless_vendored.py`,
`tests/test_deb_first_install_docker.py`.
- **Docker Engine 20.10+ and Compose 2.22+ are the floors**
(`docker_versions.py`, `onionpress check-docker`): the Linux launcher's
`start` refuses older ones with what was found and how to upgrade, the
postinst warns at install time (never fails). Moving a floor means new
evidence in that module's docstring. `tests/test_docker_versions.py`.
- **Every Mac build input is pinned by hash**: downloads through
`fetch_checked` against the `*_SHA256_*` constants; Python from the
`build/*-macos.lock` files with `--require-hashes`
(`build/refresh-python-locks.sh`); mkp224o at `MKP224O_COMMIT`. Bumping
a version means its new hash. `tests/test_dmg_build_pins.py`.
- **A release's Mac app is universal** (`ONIONPRESS_RELEASE_BUILD=1`
refuses single-arch Python; every Mach-O must have arm64 + x86_64). The
`.deb`'s files are root's, with no AppleDouble `._` members and no PAX
headers (dpkg cannot unpack them; the Mac's bsdtar writes GNU tar
without xattrs, `build/check-deb-tar.py` checks; run
`build/check-deb.sh` on any package before handing it out).
`tests/test_build_release_hygiene.py`.
- CI rebuilds, lintian-checks and attests the `.deb` on every published
release and rewrites its `SHA256SUMS` line; write permission only in the
release job. `tests/test_linux_package_workflow.py`.
- **Every workflow action is pinned to a commit SHA** with a `# vX.Y.Z`
comment; dependabot bumps both. `tests/test_build_release_hygiene.py`.
- **Version bump: `build/bump-version.sh X.Y.Z`.** Canonical sources:
`src/menubar.py` (`self.version`) and `app/Info.plist`.
- The build lock keeps `setuptools<81` (py2app #557) until a build has
tried 81+; see the `.in` file's comment before dropping it.
## Local Build Path
- **`docs/BUILDING.md` is the map**; `make doctor` reports missing tools.
`make images` / `make dev-up` build and run local images.
- Every `docker pull` is gated on `onionpress.containers.using_local_images`
(the launchers delegate to it). `tests/test_local_image_mode.py`.
- The tor image is built on Onimages' C Tor image; its Dockerfile must
`USER root` and `CMD []`, and the entrypoint's per-start
`chown -R /var/lib/tor` stays. **C Tor only**: `TOR_IMPL` must not come
back (the control port is load-bearing).
- **The key volume is every install's identity**: `onionpress-onion-keys`,
PEM at `<name>/ks_hs_id.ed25519_expanded_private`.
`migrate_key_volume` (from the old `onionpress-arti-state`) runs before
anything starts tor and before first-run detection, fails loudly rather
than copy into an empty volume, and is resumable via
`.key-volume-migration-pending`. Every identity wipe drops that marker
and keeps the old volume name in its wipe list. `ContainerManager.start_tor` refuses while a
migration is due. A failed
first-run key seed removes its volume and stops (`start.KeySeed`; one seed, `containers.run_key_seed`, for
start and restore). `tests/test_install_invariants.py`,
`tests/test_first_run_key_seed.py`.
- **Linux's first run pulls the tor image before `generate-vanity`** (#519:
the postinst's first start had pulled nothing, "no image" read as "no
mkp224o", Tor minted a key that never reached the key volume as a PEM,
and every backup of that site was unrestorable). **A backup holds the
PEM or says it has no key** (`backup._stage_onion_key`, both platforms):
the key is read out of the tor container by `key_manager.extract_keys`
(the delivered PEM, else C Tor's files, the public key from `hostname`
when tor wrote none); no key readable is a WARNING and
`excludes_onion_key` + `onion_key_unreadable` in metadata (the restore
then keeps the install's key, as for the page's backup; `unchecked_notes`
says so with the finished backup), never an archive no restore accepts.
`TestLinux` in `tests/test_first_run_key_seed.py`,
`tests/test_linux_backup_key_pem.py`.
- **All Dockerfile inputs are pinned** as `ARG` defaults (images by
multi-arch index digest, mkp224o by commit, wp-cli by sha256);
`MKP224O_VERSION` matches `build/build-dmg-simple.sh`. Re-pin the
Onimages base right before every image publish (its registry drops old
digests). `build/base-image-digest.sh` resolves tags.
- `make build` runs `build-dmg-simple.sh` (`build-dmg.sh` is deleted and
stays deleted). Firefox's extension = `extension/` overlaid with
`extension-firefox/`; keep only differing files there.
- `docker-publish.yml` needs nothing outside GitHub and publishes to the
repo owner's namespace. On `development` `IMAGE_PREFIX: dev-`; reset to
`""` when merging to main. `tests/test_publish_workflow.py`.
## Container Image Pins
- **`build/image-pins.env` is the single source of truth** for every image
the stack runs. **Never hand-edit a digest**: `build/refresh-image-digests.sh`
(`--propagate` without a daemon; resolves from the registry, `--local`
only on request). Pins are multi-arch index digests; **mariadb moves
forward only**. `tests/test_image_pins.py`.
- Starts pull `--policy missing` only (`start.start_core`). **Images change
only with a release**: no refresh toggle, no `update-images`, no image
pass in Check for Updates (with every image pinned they re-pulled the
same digests); Linux's `onionpress update` pulls its release's pins.
`tests/test_no_image_refresh.py`, `tests/test_image_pins.py`.
- **The start removes what a moved pin superseded**
(`start.prune_superseded_images`, last in post-up): per image the stack
runs (`compose config --images`), only once its pinned image is here,
only untagged images of that repository and of a mirror's former one
(`FORMER_REPOSITORIES`, held equal to `build/image-mirrors.txt`;
OnionPress pulls by digest; a
user's tagged image is never touched), plain `image rm`, never forced
(an image a container uses stays); nothing for local images.
`TestPruneSupersededImages` in `tests/test_start_post_up.py`.
- **A release builds no images**: it only points `:latest`/`:<version>` at
the pinned digests. `tests/test_publish_workflow.py`.
- `ONIONPRESS_TOR_IMAGE` / `ONIONPRESS_WORDPRESS_IMAGE` repoint the stack at
local images.
- **Image pins move with the launcher**; changes to files in the tor or
wordpress image reach users only after an image publish + re-pin.
## A Damaged Key Never Becomes a New Address (issue #311)
- The delivered PEM is the identity; C Tor's files are a copy re-derived at
every start unless they match exactly (old files set aside, never
deleted). No usable PEM and no secret key: refuse to start.
- The watchdog mints (`ADD_ONION NEW:BEST`) only with no trace of an
address at all, keeps a minted key in memory, saves it O_EXCL.
`tests/test_onion_key_never_reminted.py`.
## Onion Service Recovery (issue #266)
- The app's dual probe (`health.py`, fetching `/.onionpress-health`) is the
only measure of reachability. `RepublishTrigger` (rate-based: ≥ 50 % of
10 min failing; 5 min for `desc-missing`/`desc-stale`) asks the watchdog
for DEL_ONION + ADD_ONION via `launcher_ops.request_watchdog_republish`.
**The daemon's `check_status` runs it on both platforms** (#407): one
`RecoveryWindow`, no `reachability.json`, no second driver.
`tests/test_linux_recovery_loop.py`, `tests/test_mac_daemon.py`.
- Rate limits: 15 min host, 15 min watchdog, never within 5 min of an
upload. `REPUBLISH_ON_UNREACHABLE=no` makes it log-only. The one wake
handler, `Daemon.handle_wake`, resets the window (test-enforced), run
by IOKit on a Mac and by logind's PrepareForSleep on Linux
(`power.LogindPowerHandler`).
- Locked together by tests: `PROBE_PATH`/`PROBE_BODY_MARKER` and the
image's `onionpress-health.conf`; the request path literal in
`launcher_ops.py` and `tor-watchdog.py`. Every Python entry point in
`linux/` is installed by both `install.sh` and `build-linux.sh`.
`tests/test_linux_recovery_loop.py`.
- **The daemon on Linux reaches the Docker daemon the launcher uses**
(#503): `Daemon._setup_environment` puts `resolve_paths`' socket into
DOCKER_HOST before the first `docker` call (a preset one kept; the unit
sets no DOCKER_HOST, and must not: a system-Docker install has none).
`TestPlatformSeams` in `tests/test_linux_daemon.py`.
- The watchdog is started only through entrypoint.sh's `supervise_watchdog`
(a function, not a `*tor-watchdog*` script: hosts signal
`pgrep -f '[t]or-watchdog'`). `tests/test_tor_watchdog_supervised.py`.
## License Declarations
- **AGPL-3.0-or-later** (that SPDX form), "Copyright (C) 2026 Internet
Archive and OnionPress contributors": LICENSE, README, Info.plist,
setup.py, `linux/copyright`. Entry scripts carry the GNU short notice;
plugin headers and the theme's style.css carry `License:` + `License URI:`.
- The DMG's `THIRD-PARTY-NOTICES.txt` covers bundled binaries and the
bundle's Python distributions (`build/python-notices.py`); a missing text
fails the build. A new dependency whose wheel has no license text needs a
`LICENSE_URLS` row. `tests/test_license_declarations.py`.
## Test suite
- `make test-unit` (`unittest discover`, uv, Python 3.14, PyNaCl). **No
pytest**: every test file holds `unittest.TestCase` classes.
- **On a Mac running OnionPress, set
`DOCKER_HOST=unix:///var/empty/no-docker-for-tests.sock`** so
docker-backed tests skip instead of touching the live site.
- `make coverage` enforces floors (`build/coverage-floors.sh`); raise a
floor when tests catch up, never lower one to pass.
`tests/test_coverage_gate.py`.
- **The launchers' `main` is run, not just read**: `tests/launcher_harness.py`
runs a copy of each launcher with docker, the CLI and named helpers
stubbed. A launcher verb's options, exit status and the CLI argv it
produces go in `tests/test_launcher_dispatch_{mac,linux}.py`; the tray
runs only verbs the Linux `main` has (same file).
- **A new `onionpress` subcommand needs a row in `tests/test_cli_dispatch.py`**
(the census fails otherwise; grouping commands go in `PARENTS`), and its
handler a case in `tests/test_cli_commands.py` / `test_cli_edges.py`.
- **e2e tiers after T0 start from `ready_node`** (`e2e/harness/scenario.
ensure_ready`: install, start, setup, each only if T0 has not), live in
`e2e/test_t<N>_<letter>_*.py` (run.sh's glob, name order: read-only
modules first), and keep their logic in `e2e/harness/` where the unit
suite reaches it; T1 (a)'s capability check imports
`tests/test_container_capabilities.py`'s census, never a copy.
`ScenarioTest` / `HardeningTest` in `tests/test_e2e_harness.py`.
- The e2e suite's platforms are OS families, `macos` and `linux`
(`e2e/harness/platforms.py`); the distro is a node fact. Linux-host gates
use `HostFacts.debian_based` (`ID` or `ID_LIKE`), never `distro ==
"debian"`; a `linux-vm` guest is one of `LINUX_GUESTS` (Debian 13 the
default). `tests/test_e2e_harness.py`.
## Security
- Database passwords are random per install (`ensure_secrets`,
`~/.onionpress/secrets`); never defaults, never committed or logged.
- **Crypto comes from established libraries (libsodium via PyNaCl), never
constructions of our own.**
- Secrets never go on argv: stdin or the environment.
`tests/test_secrets_off_argv.py`.
- **A backup password given as an argument is refused** (#504; exit 2 from
the launchers' `read_backup_password`, identical in both, and
`cli.read_backup_password` / `scrub`): `backup`, `restore`, `scrub` and
`setup --from-backup` take it from `ONIONPRESS_BACKUP_PASSWORD`, stdin or
a prompt only. The old positional forms are still parsed so they get the
refusal, never a mis-read argument. `tests/test_backup_password_handoff.py`,
`tests/test_terminal_setup.py`.
## WordPress Branch Moves With the Image
- `WORDPRESS_BRANCH` in the wordpress Dockerfile is the branch the audit
moves sites up to; never past it, never down. Raise it only after testing
mu-plugins and pinned plugins on it (docs/BUILDING.md).
`tests/test_wordpress_branch_upgrade.py`.
## A Core Package Is Checked Before It Is Installed (issue #395)
- Core packages come only from `https://downloads.wordpress.org` and every
zip member is held to WordPress.org's md5 list before install; a refused
package is removed. `tests/test_core_update_verified.py`.
## Settings Page Config Writes Are Allowlisted
- Anything in WordPress can write the request files in
`/var/lib/onionpress/`. Config changes go only through
`config.apply_settings_updates` (`SETTINGS_PAGE_KEYS`, every value
checked; the rest dropped, logged by key name only), applied by
`handle_action.apply_config` on every `handle-action` run and by
`onionpress apply-config`, on both platforms (each launcher's case
delegates; no bash twin). `tests/test_settings_page_updates.py`.
- **Never add to `SETTINGS_PAGE_KEYS`** image overrides,
`ONIONHEAVEN_ADDRESS`, `ONIONHEAVEN_ACCEPT_REGISTRATIONS`,
`ADDRESS_PREFIX`, ports, paths or secrets other than
`CLOUDFLARE_TUNNEL_TOKEN` (write-only field). Log changes via
`describe_change`/`loggable_value`. Every page key reaches a reader
(`tests/test_settings_page_keys_are_read.py`).
- **The page never sets `CLOUDFLARE_TUNNEL_TOKEN` unattended** (#463; its
tunnel's owner sees the host's IP): `PAGE_HELD_KEYS` values are held,
asked on a Mac ("clearnet-tunnel"), refused on Linux; removal is
applied. `TestTunnelToken` in `tests/test_handle_action.py`.
- **Page requests are handled only by `onionpress handle-action`**
(`handle_action.py`, both platforms): fixed verbs (`ACTIONS`), each run
through the launcher; `update` only checks on both platforms; on a Mac an
address change stops for the person at the Mac (`pending-action/`).
`tests/test_handle_action.py`.
- Every result carries the request's id (`checked_request_id`);
`tests/test_settings_page_results.py`. Reads are size-capped
(`READ_LIMITS`), actions throttled (`MIN_INTERVAL`), unknown request text
never logged (`TestBoundedReads`, `TestThrottle`).
- Every `wp_ajax_` handler with a side effect `check_ajax_referer`s
(`tests/test_settings_page_nonces.py`, a census).
- Page Stop/Restart go through the launcher's cases (Linux asks the
daemon for `/offline` first). `tests/test_linux_settings_page_stop.py`. Share Now is
handle-action's `upload-analytics` (`analytics_sharing.request_upload`'s
data-dir marker, refused while sharing is off; nothing in the volume, no
per-cycle `docker exec`). `tests/test_settings_page_share_now.py`.
- **The page does none of the host's work** (#528): its release line is
`status.json`'s `release` (`status_writer.release_status`, the cache of
`handle_action.check_release`; its button is the `update` action), never
a request of its own; `config-current.json` has one writer, the status
writer (the form shows unapplied `config-updates.json` on top); the
status page writes no `requested-action`. Every PHP copy of a host
constant (fields, token pattern, restart keys, result files, actions,
request id, the onion shape) is held to its source.
`tests/test_settings_page_host_constants.py`.
- **The page's archive.org login is the host's** (#528 item 3):
handle-action's `archive-login` over `multisite.fetch_archive_s3_keys`
(archive.org's onion over Tor) and `save_archive_s3_keys`; no login code
in WordPress. The account and password cross the volume only in the
`archive-login` request file (0600, written only when `status.json`'s
`page_actions` lists the verb), read once and removed before the login
or with a throttled request (`INPUT_FILES`); neither is logged or in the
result. `TestArchiveLogin` in `tests/test_handle_action.py`,
`TestArchiveLoginIsTheHosts` in `tests/test_settings_page_host_constants.py`.
- **`onionpress config get|set|unset|list` is the apps' one writer of
`~/.onionpress/config`** (`config.CLI_CONFIG_KEYS`). A new setting gets
its `config set` entry first (check, `CONFIG_KEY_HELP`, `NEEDS_RESTART` /
`CODE_DEFAULTS`); a UI only calls the command. `ONIONPRESS_*` and
`ONIONHEAVEN_ACCEPT_REGISTRATIONS` are hand-edit only. Secrets only as
`config set KEY -`. `tests/test_cli_config.py`.
- **One rule reads the config** (#404): the first `KEY=` line wins, the
value is all after its first `=`, trimmed: `config.read_value` /
`read_config` and the launchers' `config_value` (identical in both;
never `grep | cut`). Writes are a temp file + rename under
`.config.lock`, one line per key. `tests/test_config_one_reader.py`.
- The page's backup leaves out host secrets (`include_host_secrets=False`,
config cut to `SAFE_CONFIG_KEYS`); no restore takes the token from a
backup. `tests/test_backup_host_secrets_off_wordpress.py`.
## WordPress Host Controls Are the Owner's
- `manage_options` is per-site in the multisite: **gate everything that
reaches the host or every site on `onionpress_settings_owner_allowed()`**
(super admin), in every handler, AJAX endpoint and REST
`permission_callback`. Same for onboarding, Wayback, blog snapshots and
My Creations upload; social importers stay per-site.
`tests/test_settings_owner_only.py`.
- The backup download sends/deletes only an
`OnionPress-<≤8>-<YYYY-MM-DD-HH-MM>.zip` regular file; changing the name
in `handle_action.py` means changing the pattern.
`tests/test_backup_download.py`.
- **A key import never loses the current key**: every import is
`onionpress import-key` (`key_import.replace_key`, both platforms; the
launchers' `import_key` is a delegation, and the one ADDRESS_PREFIX rule
is `config.prefix_for_address`): check, stage, copy old keys to
`~/.onionpress/replaced-keys/`, then release hubs and swap. On a Mac the
page's import needs the person at the Mac (native dialog, Cancel default,
5 min = Cancel); Linux refuses it from the page. The CLI speaks the
check/confirm protocol on both platforms (`address-change`, and
`hub-holds-key` when a hub may still keep the key).
`tests/test_onion_key_replacement.py`.
- An import Docker was not up for (`.import-key-pending`) is finished by
the next start, first: `ContainerManager.apply_pending_key_import` wipes
the old key, migration-era and C Tor volumes, each confirmed absent;
anything else keeps the marker and stops the start.
`tests/test_pending_key_import.py`.
- **Code in WordPress never reads or replaces the site's onion key without
the owner at the host**: the page's backup excludes it on both platforms
(`cmd_settings_page_backup`, `include_onion_key=False`; every
`create_backup` call says which); a keyless backup never mints an
address on any path; a page restore of another address asks (Mac) or is
refused (Linux: `cmd_settings_page_restore`, `ADDRESS_CHANGE_REFUSED_MSG`);
a hub/OnionHome node, or one that cannot say, restores nothing from the
page. `tests/test_settings_page_onion_key.py`.
## The Daemon (issue #367, CLI-first phase 3; Linux since #407)
- **`onionpress daemon` runs every loop on both platforms; the menubar and
the tray are views** (menu, dialogs, notifications, setup windows,
starting the daemon). The menubar runs no loop naming docker, the hub
or Tor, no timer but the splash tips. `tests/test_mac_daemon.py`.
- **Every platform difference in the daemon is a branch on `self.os_name`**
(`detect_os()`, overridable for tests), never a second module: sleep
and wake (`mac_power.MacPowerHandler` / `power.LogindPowerHandler`, one
interface: `register`, `run_loop_once`, `stop`, `OFFLINE_MAX_TIME`; the
sleep and wake policy is `handle_sleep` / `handle_wake` once, the
`/offline` within the handler's budget: IOKit 5 s, logind 3 s of its
5 s delay), the sleep inhibitor (`CaffeineManager` / `SystemdInhibitor`),
the Docker environment, `_runtime_ready` (Colima's `.initialized` only
on a Mac), the start of Tor after the first run's setup (`start-tor`,
Mac only: Linux's start brings Tor up), the hub gate
(`hub_registration_allowed`, below), the rootless Docker namespace watch
(`_watch_rootless_netns` → `wordpress-egress`, Linux), and what only a
Mac has: thumbnails, the clearnet dmesg capture, the extension's proxy.
`tests/test_linux_daemon.py` (the Linux half of the harness),
`tests/test_mac_daemon_paths.py`.
- **Linux runs it as the `onionpress-daemon.service` user unit**
(`linux/onionpress-daemon.service`: `Before=onionpress.service`, so the
site's `ExecStop` asks it for `/offline` first; `Restart=always`), and
`onionpress.service` is a oneshot that stays active (`RemainAfterExit`):
the launcher's `start` brings the containers up, runs `daemon start`
(`daemon_client`'s systemd half: `systemd_unit(data_dir)`, derived per
data dir like the launchd label; `--no-block`) and returns. `stop` and
`restart` run `daemon offline` before `stop_containers`; an update
stops the daemon `--no-offline` and starts it again. No heartbeat
client, no bash status loop, no watcher timer, no system-sleep hook, no
`write-status` one-shot: the .deb, `install.sh` and the launcher's
uninstall retire the old units (`onionpress-heartbeat.service`,
`onionpress-watcher.timer`) and lift their runtime mask.
`tests/test_linux_daemon.py`, `tests/test_deb_upgrade_restarts.py`,
`tests/test_launcher_dispatch_linux.py`, `tests/test_linux_install_tree.py`.
- **The menubar performs no operation itself**: every action is an
`onionpress …` subcommand run through the launcher; it imports none of
`backup`, `hub_backup`, `updater`. A new menu action gets its subcommand
first. `tests/test_menubar_actions.py`.
- **Neither UI runs a program of its own** (#547) beyond
`UI_SUBPROCESS_ALLOWED` (desktop openers, the menubar's pid-file boot
time and relaunch, the tray's uninstall terminal): the bundle's
admin-group sharing is `updater.share_bundle_with_admins` (the staged
copy, and once per Mac daemon start), the host cloudflared check the
daemon's on both platforms (`host_cloudflared` in its state, shown via
`ui_client.host_cloudflared_notice`), the tray's Quit the Linux
launcher's `quit --leave-tray`. `TestTheMenubarRunsNoOperation` in
`tests/test_menubar_actions.py`, `TestTheTrayRunsNoOperation` and
`TestQuit` in `tests/test_launcher_dispatch_linux.py`,
`tests/test_host_cloudflared.py`.
- **The menubar and the Linux tray are widgets of one client** (#526,
`src/onionpress/ui_client.py`: stdlib + `confirm` + `launcher_ops`, so
the menubar's docker-free import closure holds): both call the
launcher only through `ui_client.Launcher` (secrets in the environment,
stdin /dev/null) and read its answers only through `run_json` /
`run_confirmed` (the `--check --json` → ask → `--confirmed` loop),
`plan_backup` / `run_backup`, `plan_restore` / `run_restore` /
`restore_summary` (the dialogs an object the UI supplies: `BackupUI`,
`RestoreUI`) and `update_outcome`; no `subprocess` argv in either names
the launcher. The tray imports the package hard (the .deb installs it
beside the tray): no degraded fallbacks. `tests/test_ui_client.py`.
- **Both UIs paint `ui_client.status_view(state, now)`**: category,
title, icon and the actions it enables from daemon-state.json; a state
older than 90 s or from a dead pid is "reconnecting", never its last
word (the tray recomputes it every tick). The tray's log gets the
menubar's redaction (home as `~`, `redact.make_scrub_fn` on rolled
files). `TestStatusView` in `tests/test_ui_client.py`,
`tests/test_linux_tray_protocol.py`.
- **The daemon's onionname retry is `onionpress onionname set`** (run
through `run_cli`, no registration of its own), and there is one name
rule, `onionnames_client.validate_name` (`setup_logic.validate_onionname`
and config's ONIONNAME check are it). `TestOnionnameRetry` in
`tests/test_mac_daemon_paths.py`, `tests/test_onionname_as_data.py`.
- launchd agent `org.onionpress.daemon` (`KeepAlive` on failure only,
`AbandonProcessGroup`); `onionpress daemon start|stop|status|offline`.
The launcher's `stop` runs `daemon offline` before stopping containers;
`quit` runs `daemon stop` first.
- **Labels are per data dir** (#505: launchd's gui domain is per user, and
a second install on the account took the real daemon for its own):
`daemon_client.data_dir_label` keeps `org.onionpress.daemon` /
`com.onionpress.launcher` for the account's own `~/.onionpress`
(`default_data_dir`, the passwd home, never `$HOME`) and derives
`<label>.<8 hex of the real path's sha256>` for any other; every
launchctl target and plist name goes through `agent_label(data_dir)` /
`login_item.launchagent_label(data_dir)`. The two literals stay
(e2e/harness/safety.py checks the default). `TestLabelPerDataDir` in
`tests/test_mac_daemon.py`.
- State in `~/.onionpress/daemon-state.json` (atomic, 0600); a state older
than 90 s or from a dead pid shows "Reconnecting", never "Stopped". Two
processes never write one RotatingLog.
- `.setup_complete` is written by every completed setup path
(`setup_logic.mark_setup_complete`). `tests/test_setup_complete_marker.py`.
- Quit takes the site down unless `QUIT_KEEPS_SITE_RUNNING=yes`. Update and
uninstall stop the daemon first (on both platforms).
- **A key import or restore stops no daemon** (#407, as the Mac always
was): the hub client reads the key per request and the record's
"released" hold keeps a beat off a released key; `heartbeat_unit` is
gone. `tests/test_settings_page_onion_key.py`,
`tests/test_onionheaven_lifecycle.py`.
- **The daemon's start never stops Colima**: with no daemon alive a running
VM is a crash restart's or a terminal start's live site; its port is read
(`resolve_port_offset`). `TestResolvePorts` in
`tests/test_mac_daemon_paths.py`.
## One Status Writer (issue #523, consolidation stage 2)
- **`onionpress.status_writer` writes every status document on both
platforms**: `daemon-state.json` (`state_document`, with the status
document under `"status"`), the volume's `status.json`, `version`,
`config-current.json` and `recent-logs.txt` (`write_volume`, as
www-data, `redact.status_for_wordpress` and `wordpress_log_excerpt` on
the way in), and the full `~/.onionpress/status.json` host copy
(`write_host_copy`: the analytics snapshot, the launcher's port
detection). The daemon's `write_status_to_volume` and `publish_state`
are `write(self)` / `publish(self)`, the one caller on both platforms
(#407 retired Linux's `onionpress write-status` one-shot and the
launcher's 30 s tick; `handle_action` runs no `write-status` after an
action: the daemon's pass writes it). Nothing else writes those names:
the census in `tests/test_status_writer.py`.
- The fields Linux's bash gathered (`wp_installed`, `onboarded`,
`wayback_queue_count`, `mkp224o_available`, `vanity_in_progress`,
`serving_from_wayback`, `wp_admin_password_path`, `wp_port`) are one
function each there, gathered on both platforms, docker asked only
while the stack runs; `mkp224o_available` is `launcher_ops.
tor_image_has_mkp224o` on `DEFAULT_TOR_IMAGE` (generate-vanity's
image), its answer kept by image id in `.mkp224o-probe`. Platform
differences are `detect_os()` seams in the module (`platform_name`,
`host_boot_time`, `wedge_warning`), never a second writer.
- `state` is the daemon's display state (`running`/`starting`/`stuck`/
`offline`/`stopped`); the tray reads it, and everything else, from
`daemon-state.json` through `daemon_client.read_state` (`"status"`),
never `status.json`. `tests/test_status_writer.py`,
`tests/test_linux_tray_protocol.py`, `TestStatusPass` in
`tests/test_mac_daemon.py`.
## The Start Is onionpress.start (issue #442)
- **Each launcher's `start_containers` is the Docker wait (Mac: VM
recovery, fsck drop-in), then `onionpress start-containers`** (`start.start_containers`): pending key import →
key volume migration → first-run key → core services → post-up, each key
step stopping the start. Linux runs `--until-core`, its install-state
steps (#464) and `start-post-up`. The CLI has no start of its own
(#405). `tests/test_start_sequence.py`.
- **A change to the start is checked against the recorded traces**
(`tests/test_start_traces.py`, fixtures under
`tests/fixtures/start-traces/`); a fixture changes only on purpose
(`OP_UPDATE_START_TRACES=1`), said in the commit.
- Best-effort steps name their failures in the Mac START SUMMARY through
`--errors-file` (`start.StartErrors`, read by `note_start_errors`).
`tests/test_start_errors_contract.py`.
- **The wait after a start is `start.wait_for_services`** (#529,
`onionpress wait-for-services`, both launchers' `wait_for_services` a
delegation): WordPress on its port, Tor bootstrapped and WordPress
reachable from the tor container; a WordPress not installed ends the
wait with the address written; then `write_shared_onion_address` (as
www-data) and `healthcheck_address` (one whole v3 address, cached for
the daemon). Always 0; a timeout is the WARNING and `wait-for-services`
in the Mac's START SUMMARY. The start's "Onion address:" line reads
`onionpress address`. `tests/test_wait_for_services.py`.
- **The Mac's `start-tor` is `onionpress start-tor`** (#529:
`ContainerManager.migrate_key_volume` then `start_tor`, never before the
key volume is in place; the daemon runs the launcher verb once setup has
installed WordPress). **The database passwords are `onionpress
ensure-secrets`** (`secrets_file.ensure_secrets`, an incomplete file
refused); each launcher's `setup_db_passwords` keeps only the `source`
and the export compose reads. `tests/test_ensure_secrets.py`.
## `onionpress setup` Is setup_logic's (issue #442)
- The launchers' `setup)` case is `run_setup` (passwords moved into the
environment, never the CLI's argv) → `setup_logic.setup_from_terminal`,
which installs through `install_fresh_wordpress`; its exit says what the
launcher does next (10: restart after a backup's restore).
- **`setup --from-backup` is the restore** (#509): `setup_from_terminal`
runs `cmd_restore`'s install-from-backup rebuild (the backup's key,
database and content; the address from the key, never metadata.json; no
key, no new one), marks the setup complete and has the launcher restart.
Never a WordPress under temporary credentials. `tests/test_terminal_setup.py`,
`TestLauncherRestorePaths` in `tests/test_backup_metadata_validation.py`.
- **Both UIs install through `onionpress setup --json`** (#527; the tray's
legacy branch `provision --json`): the password in
`ONIONPRESS_SETUP_PASSWORD` (`ui_client.SETUP_PASSWORD_ENV`, kept by
`run_setup` unless `--pass`), progress on stderr, the answer stdout's
last line (the case's closing lines on stderr). The onionname is
registered before the install on both platforms
(`setup_logic.setup_onionname` over `app_commands.register_onionname`,
`onionname set`'s); a taken one installs nothing and answers
`{"error": "taken", "suggestions"}`, `--register-later` records it
unregistered; a later collision is `onionname set NAME --rename-admin`
(`rename_wp_user`: $wpdb, logins on stdin, `site_admins`). Neither UI
imports the install, the registrar, `onion_hostname`, `auto_login` or
`wp_files`; `--language` is one of `SETUP_LANGUAGES` (both UIs' lists
test-held). `tests/test_setup_through_the_cli.py`.
## The App's Functions as Subcommands (issue #382)
- `version`, `address`, `check-reachability`, `logs`, `open`, `onionname`
live in `src/onionpress/app_commands.py`, shared by both platforms, never
constructing `OnionPressCLI`. The browser never gets the login token.
`tests/test_cli_app_commands.py`.
- **One open-logged-in implementation: `launcher_ops.open_onion`**
(`open onion --json`, both UIs, #527): Tor Browser or an extension
browser gets `login_target`'s file:// page, Brave the bare URL; a
running Linux Tor Browser answers `{"paste_url"}`, a cold launch waits
for firefox.real and sends the tab through `start-tor-browser`; a Mac
with none answers `{"needs": "tor-browser"}`. `TestOpenOnion` in
`tests/test_auto_login_off_argv.py`.
- **`onionpress open` makes every choice of browser; the UIs launch
none** (#546): the Mac's choice is `browser.detect_best_browser` (the
menu title only names it), the first run's extension wait is
`--extension-wait`, the download page is `open get-tor-browser`. The
UIs keep the menu item, the install dialog and the install monitor.
`TestTheBrowserIsTheCLIs` in `tests/test_ui_client.py`.
- **Each launcher's default case delegates to `onionpress.cli`**: a verb
with no case of its own (`ensure-archive-s3-keys`, `check-for-update`)
is the CLI's, and every verb the daemon, menubar, tray, `handle_action`,
`setup_logic` and `launcher_ops` run through a launcher is a case or a
subcommand. The daemon runs a CLI-only subcommand itself
(`Daemon.run_cli`). `tests/test_launcher_verbs_reach_the_cli.py`,
`TestTrayRunsOnlyLauncherVerbs` in `tests/test_launcher_dispatch_linux.py`.
## Check/Confirm Protocol
- **Every destructive command speaks it** (`confirm.py`): `--check --json`
changes nothing and answers ok / `{"confirm": …}` (exit 4) / error;
the UI asks (Cancel default, 5 min = Cancel) and reruns with
`--confirmed <what>`. Off a terminal it refuses without confirmation
(exit 3). `{"ask": "password"}` (exit 5): secret via env or stdin.
- A new question: `confirm.add_arguments`, `confirm.require`, the name in
`QUESTIONS`, **its one wording in `ui_client.question_text`** (the
menubar's `_ask_cli_question` and the tray's `_ask_question` render it;
a word to type is `typed`), a flow in
`tests/test_cli_confirm_protocol.py` and `tests/test_ui_client.py`. The
tray's own side is `tests/test_linux_tray_protocol.py`.
- **`uninstall` and `reset` are the CLI's on both platforms** (#529):
`cmd_uninstall` asks `hub-holds-key` then the typed `uninstall`, and on
Linux at a terminal offers the backup first (not under
`ONIONPRESS_YES=true`), then `uninstall.run`'s platform branch (Linux:
the units, the tray, the containers and volumes, `/opt/onionpress` and
the symlinks with sudo, the desktop entries, `~/.onionpress`); the tray
runs `uninstall --check --json` through `ui_client.run_confirmed` and
opens its terminal with the `--confirmed` flags. `cmd_reset` on Linux
ends with the launcher's start and `onionheaven.reset_hub_state`. Each
launcher's case is the delegation, `-y` confirming through
`ONIONPRESS_CONFIRMED` / `--yes`. `tests/test_uninstall_linux.py`.
- **A Mac uninstall moves OnionPress.app to the Trash, last, only when
nobody else uses it** (`uninstall.trash_app`): only the bundle the code
runs from, directly in `/Applications` or `~/Applications`; kept while
any other account (or another data folder of this one) has a
`~/.onionpress`, or a home cannot be looked into. Foundation's
`trashItemAtURL` (Put Back works), else a rename into `~/.Trash`; never
a write into the bundle. The answer's `app` is shown through
`ui_client.uninstall_app_line`. `tests/test_uninstall_trashes_app.py`.
- **The launchers hold no verb of their own** (#529): `restore` is the
CLI's pass-through (its argparse and `cli.read_backup_password` do the
refusals), `scrub` is `scrub.run_scrub`'s platform cycle (the Mac's
reset in place: `tests/test_scrub_mac.py`), `validate-oh-address` is
`onionheaven.validate_hub_address` (`tests/test_validate_oh_address.py`),
`help` is `cli.launcher_help` (`tests/test_launcher_help.py`),
`stop_containers` is `onionpress stop-containers`
(`ContainerManager.stop`, the farm with it;
`tests/test_stop_containers.py`). What stays bash: the Colima bootstrap, the Linux systemd/rootless and
install-state functions (`ONIONPRESS_BIND_HOST`), `config_value`,
`start_cloudflare_tunnel`, `read_backup_password`, the daemon's and
`start`'s pid lock and wait.
## Blog Snapshots Stay Off the Web
- My Creations is published: nothing private there under a name not
starting with `.`. WXR snapshots live in `.blog-archives/`; every
publishing layer (`is_public_path`, `.htaccess`, `onionpress-creations.conf`
with `<DirectoryMatch>`) refuses hidden paths and the legacy
`blog-archives`. `tests/test_blog_snapshots_private.py`.
## My Creations Listings, Serving and Uploads (issue #319)
- Listings go through `cached_listing()` (stale-while-rebuild); cached
entries hold no URLs. `serve_file` handles ETag/304/ranges and streams.
`tests/test_creations_listing_and_serving.py`.
- Script-capable documents (`SANDBOXED_EXTENSIONS`, matched by the conf's
`FilesMatch`) are served with `Content-Security-Policy: sandbox`
(no `allow-scripts`) and `nosniff`, on both paths.
`tests/test_creations_sandboxed_serving.py`.
- Thumbnails are never removed when part of a folder could not be read.
- **Host code that walks the Creations mount follows no link** (#460):
`.thumbs` must be a real folder, linked sources are skipped, removals and
writes go through `O_NOFOLLOW` folder fds (`thumbnails.prune`/`install`),
qlmanage renders into a private temp folder.
`tests/test_creations_thumbnails.py`.
## Follow Requests Stay Off the Container Network
- **Never call `wp_remote_*`/`fetch_feed` directly for a follow**: use
`onionpress_follow_remote_get/_head/_fetch_feed` (URL shape check, no DNS
lookup, redirects held to the same rule). `tests/test_follow_requests.py`.
## Imported Bodies Are Filtered at Ingest (issue #403)
- Every foreign HTML field goes through `wp_kses_post()` where it is read.
`tests/test_importers_kses_at_ingest.py` (census).
- **Every importer comment goes through `onionpress_social_insert_comment`**
(#462; `wp_insert_comment` runs no comment filter): author, URL and
content are cleaned before storing. Tweet text is escaped first.
Same test.
- **Every foreign title and excerpt is `onionpress_social_plain_text()`**
(#472, `onionpress-social-archive.php`:
`sanitize_text_field( wp_strip_all_tags( … ) )`, never
`html_entity_decode` afterwards, which turned `<script>` back
into markup): the_title() and the_excerpt() run no kses, and the
save-time `title_save_pre` kses that stripped a Reddit thread's
`<img onerror>` title under wp-cron is off for the owner's own Sync
now or ZIP upload (unfiltered_html). Reddit thread titles are cleaned
when fetched (raw_json=1), when the cache is read and when applied;
CSV/API titles and every `onionpress_reddit_insert_post`; the blog
importer's `title.rendered` / `excerpt.rendered`; the previews cut
from bodies (Twitter, Bluesky, Mastodon, blog). The census holds every
`post_title` / `post_excerpt` to the helper (an attachment's
`sanitize_title` aside); a new importer's titles go through it.
## Importer ZIP Uploads Are Held to Limits (issue #473)
- The Twitter and Reddit importers take a ZIP from any site administrator
and used `extractTo` on every member with an allowed extension: no cap
on count, size, total or ratio. Both now unpack through
`onionpress_social_zip_extract( $zip_path, $target_dir, $pattern )`
(`onionpress-social-archive.php`), modelled on
`backup.check_archive_size`: before a byte is written the archive may
list at most `ONIONPRESS_SOCIAL_ZIP_MAX_MEMBERS` (100,000) members,
none over `ONIONPRESS_SOCIAL_ZIP_MAX_MEMBER_BYTES` (2 GiB), adding up
to at most the larger of `ONIONPRESS_SOCIAL_ZIP_MIN_UNPACK_BUDGET`
(1 GiB) and `ONIONPRESS_SOCIAL_ZIP_MAX_EXPANSION` (20) times the
upload's own size, the wanted members must fit the disk with
`ONIONPRESS_SOCIAL_ZIP_FREE_RESERVE` (512 MiB) to spare, and a
symlink, special file, absolute, `..`, backslash or control-character
name or a wanted name listed twice refuses the archive whole. Only the
members an importer reads are extracted, by name pattern
(`ONIONPRESS_TWITTER_ZIP_MEMBERS`: `data/tweets*.js`, `data/account.js`,
the tweet media files; `ONIONPRESS_REDDIT_ZIP_MEMBERS`: `posts.csv`,
`comments.csv`; each at the root or one folder down), each written by
the plugin from `getStream` and held to its listed size (a member that
inflates past it fails the extraction and what was written is removed).
The workdir is 0700; a refusal is shown with its reason. Nothing in
the plugins may call `extractTo` again. `tests/test_social_zip_limits.py`
(the limits, the patterns, and archives built with zipfile run under
php where installed).
## The Blogroll Refreshes in wp-cron, Never in a Page View (issue #314)
- The serve path only reads cached items and schedules
`onionpress_blogroll_refresh`; **never fetch from it**. The refresh is
locked, time-budgeted, backs off and keeps a failing follow's items.
wp-cron's loopback goes to `http://localhost/`.
`tests/test_blogroll_refresh.py`.
## WordPress Resolves No Name Outside Docker
- A DNS lookup in the wordpress container is a leak. onionpress-tor-proxy
checks URLs by shape (`onionpress_remote_url_is_safe()`), never by lookup;
redirects stay on their route. `dns: 127.0.0.1` for wordpress and db, on a
user-defined (not `internal`) network. Raw `curl_init` in plugins must
use a socks5h proxy or talk only to a container.
`tests/test_no_clearnet_dns.py`.
- **Nor any connection (issue #144)**: `wordpress_egress.py`'s
ONIONPRESS-WP chain refuses everything from bridge `onionpress-wp0`
except replies; ONIONPRESS-TOR keeps `onionpress-tor0` off the host and
private ranges (DNS excepted); INPUT closes WordPress's published port to
every bridge. Applied before every start's first `compose up`; best
effort (a WARNING, never a stopped start). The wordpress network's name
sorts after `onionpress-network` and stays IPv4-only.
`tests/test_wordpress_egress.py`.
- `migrate_tor_network` removes an old onionpress-network before the first
`compose up`, removing only known containers by exact name.
## The onionheaven Container Is a Core Service (issue #504)
- `onionheaven` is in `containers.CORE_SERVICES` with `restart:
unless-stopped` under autoheal: every start brings it up on every node.
**No launcher watcher for it** (the Linux 30 s poll forked from every CLI
run outlived it, one orphan per `restart`/`setup`, each with its parent's
argv in ps); web-server.py writes no activation flag (nothing read it).
`tests/test_start_sequence.py`, `tests/test_start_post_up.py`,
`tests/test_onionheaven_hub_admission.py`.
## The Docker Socket Stays Out of onionheaven
- Only `autoheal-proxy` and `onionheaven-farm` mount the socket (`:ro`,
`network_mode: none`, `cap_drop: [ALL]`, `read_only`).
- **Never add a `docker` call to a tor-image script other than
onionheaven-farm.py**, and never a farm verb that takes a command line, a
path, an image, a mount or a capability. `tests/test_onionheaven_farm.py`.
## A Plain Node's Address Carries No Hub API (issue #393)
- Port 8083 (web-server.py) is mapped onto the onion address only when
`hub_api_exposed()` (hub or OnionHome). **The image serves no HTTP from
shell**: the healthcheck (web-server.py's `HealthcheckHandler`,
127.0.0.1:8081, a plain 200 that asks WordPress nothing) and the redirect
(`onionheaven-redirect.py`) are `http_listener.ServiceHandler`s, at most
`SERVICE_CONNECTIONS` (64) each, `IDLE_TIMEOUT` 30 s.
`tests/test_tor_node_surface.py`, `tests/test_takeover_page_escaping.py`.
- **do_POST checks every body's length before a handler runs** (digits
only, the path's limit, no chunked); bodies are read under a deadline,
at most `MAX_CONNECTIONS` connections (#461). Handlers read only via
`_read_json` and check `is False`. `tests/test_hub_request_limits.py`.
## Containers Drop Every Capability
- **Every compose service, takeover worker and one-shot `docker run` has
`cap_drop: ALL`, `no-new-privileges:true`, and adds back only the set
`tests/test_container_capabilities.py` lists for it.** A new `docker run`
fails the census until listed. Never NET_RAW, NET_ADMIN, SYS_ADMIN,
SYS_PTRACE, SYS_MODULE, DAC_READ_SEARCH, ALL or privileged (the one
exception: the Linux system-Docker egress helper, held to its shape).
Write compose lists block style. Change a set only after a live test.
- One-shot helpers: `--network none`, entrypoint overridden, sources
mounted read-only, the **pinned** tor image (`containers.tor_image`,
`ensure_tor_image`; the launchers run no helper), never a floating tag. Never `docker run -v` a volume
that may not exist (it creates an empty one that passes for a key).
- Never make a non-root process call su or sudo in the images; nothing uses
raw sockets.
## The Data Dir Is the User's Alone (issue #397)
- `$DATA_DIR` is 0700 on every start; every creation of `config` is 0600;
every `os.makedirs` of the data dir passes `mode=0o700`.
`tests/test_data_dir_permissions.py`.
- **The .deb postinst and install.sh make it as the user, never as root**
(issue #471): the same `plain_path` / `data_dir_setup` /
`first_install_files` in both (test-held identical), run through
`runuser -u "$REAL_USER" --` (postinst) or `run_as_user` (install.sh);
`install -d -m 700`, secrets and config under `umask 077` + `set -C`
(0600 from the first byte, a link at the name fails the write), a link
or a non-folder/non-file at `~/.onionpress`, `secrets` or `config`
refused with a message (the launcher makes what is missing), existing
files never rewritten; the root `chown -R` fixup is gone. The Docker
bootstrap's `docker-rootless-setup.log` is opened by the user's shell
(`as_user_logging_to`, #490): root opens nothing under the data dir.
`tests/test_deb_postinst_data_dir.py`.
## Container Output Never Reaches Bash Arithmetic (issue #466)
- Inside `$(( ))`, `(( ))`, `[[ a -gt b ]]`, `let` and `declare -i` bash
evaluates a variable's VALUE as an expression, and `a[$(cmd)]` in it
runs cmd (bash 3.2 and 5). The Mac launcher's `detect_container_runtime`
put the tor container's MemTotal (`docker exec … awk`) straight into
`$(( ))`: root in that container chose what the next `onionpress start`
ran as the Mac user. Every number read out of a container, or derived
from docker's answer, is now held to `[[ "$x" =~ ^[0123456789]{1,N}$ ]]
|| x=0` before arithmetic sees it (`_vm_mem_kb`, `_vm_cpus`). `[ a -gt
b ]` (the test builtin) does not evaluate
expressions, but write the guard anyway: the census in
`tests/test_launcher_container_arithmetic.py` follows a value assigned
from `docker`/`wp_cli`/`wp_exec` output (transitively) into any
arithmetic context in the three launcher scripts and fails on one
without that guard line between assignment and use; it also runs the
guarded blocks under bash with `a[$(>file)]` as the container's answer.
## The Launchers' Python Ignores the Current Directory (issue #470)
- `python3 -m x` and `python3 -c` put the cwd first on sys.path (`-m`
ahead of PYTHONPATH), and WordPress has My Creations read-write: a
planted `socket.py` or `onionpress/__init__.py` there ran as the host
user when the owner ran `onionpress …` from that folder. Linux: every
`python3` in `linux/onionpress` runs `-I` (no cwd or script dir on
sys.path, no PYTHON* environment, no user site; `-P`/PYTHONSAFEPATH
need 3.11, which the .deb's `python3` does not promise). `op_py` is
`python3 -I -B -c '<boot>' "$ONIONPRESS_PYTHONPATH" "$@"`: the boot
puts the argument on sys.path and `runpy.run_module("onionpress.cli")`;
`-B` keeps `PYTHONDONTWRITEBYTECODE`'s effect; PYTHONPATH stays in the
environment for the Python the CLI starts itself (derivatives'
`sys.executable -m onionpress.cli`, still un-isolated: open). `op_py`
is self-contained (the harnesses extract it alone); `OP_PY` is the same
argv for `run_detached`. A script run by path (`"$SCRIPTS_DIR/key_manager.py"`)
gets its own root-owned dir on sys.path, not the cwd, and stays as is
(the key-replacement test holds the two launchers' bodies equal).
Mac: `bundled_python` sets `PYTHONSAFEPATH=1` (never `-I`: it would
ignore PYTHONHOME) and its one `-c` says `-P` too. No `cd /`: nothing
in either launcher depends on the cwd, and relative backup paths would
break. `tests/test_launcher_python_isolated.py` (census; op_py and the
snippets run from a folder of planted modules).
## WordPress's Installer Is the Owner's Alone (issue #464)
- Until WordPress is installed, `wp-admin/install.php` takes the first
visitor's choice of admin (user 1, super admin after the multisite
conversion); the .deb starts the units at once, Linux binds WordPress's
port to every interface by default, Tor serves the site as soon as the
key exists, and a setup that found WordPress installed adopted it.
- **Only a WordPress OnionPress installed is adopted.** Every install
writes `~/.onionpress/.wordpress-installed-by-onionpress`
(`setup_logic.INSTALLED_BY_ONIONPRESS_MARKER`: user 1's `user_registered`
as WordPress records it, else `unknown`): `install_fresh_wordpress`
(after the password is set), the one install path (the setup window,
the Linux tray, `onionpress setup` since #442.10).
`provision_existing_wordpress`
refuses an installed WordPress whose marker is missing or does not
match the live install (`FOREIGN_INSTALL_MSG`, False; the Linux tray's
legacy branch gets the same answer) unless `adopt_existing`:
`onionpress provision --adopt-existing`. A restored site's stamp
differs from the marker: `provision` is not for restored sites.
`tests/test_setup_adopts_only_its_own_install.py`.
- **Linux binds WordPress's port to 127.0.0.1 until WordPress is
installed.** `start_containers` composes with
`ONIONPRESS_BIND_HOST="$(wordpress_bind_host_for_start)"` (the config
value only once a start has seen WordPress installed:
`$DATA_DIR/.wordpress-installed`, a cache verified every start), then,
with `wp core is-installed`'s answer, `bind_wordpress_port_for_install_state`
records the verdict and recreates the wordpress container when its
mapping (`docker port`) is not the one the state calls for: once per
install, right after setup. Every setup path ends in
`after_wordpress_installed` (the port moved): the
`provision-post-install` subcommand that `install_fresh_wordpress` runs
(the tray, `provision`), the `setup` case, `start_containers` for an
installed site. Only the wordpress service's port reads
`ONIONPRESS_BIND_HOST` (test-held), so the per-command override reaches
nothing else. `tests/test_linux_wordpress_port_until_installed.py` (the
functions under bash against stub docker).
- **Nothing registers the address with the hub before WordPress is
installed: the daemon's own gate** (#407; until then a runtime mask of
the heartbeat unit, held by the launcher, #464/#506). On Linux
`Daemon.hub_registration_allowed()` is True only once
`_check_wordpress_installed` has seen `wp core is-installed` answer
yes (Tor runs before setup there); `_hub_loops` passes it into
`hub_client.tick`. A Mac has no gate: its Tor starts after setup. No
unit is masked, so nothing outlives the package; the .deb and
`install.sh` only lift an older install's mask. `TestHubGate` in
`tests/test_linux_daemon.py`, `TestRetiredUnits` in
`tests/test_deb_upgrade_restarts.py`.
- **The image denies `install.php` and `setup-config.php` to every
source but the owner's**: `onionpress-install-page.conf` (`<Files ~>`,
`Require ip 127.0.0.0/8 ::1` or `%{REMOTE_ADDR} == osenv('ONIONPRESS_WP_GATEWAY')`,
the container's default gateway, where the published port's requests
arrive from on both platforms: auto-login's "local" rule). Tor visitors
arrive from the tor container, cloudflared's from its own address. The
entrypoint's `gateway_from_route` (the plugin's /proc/net/route parse,
every field checked before the arithmetic) exports the variable before
Apache starts; none means loopback only. Verified against Apache 2.4.58
(`TestUnderApache` runs where apache2 is installed). The rule stays
after setup. **Needs a WordPress image publish**; until then an old
image still serves the installer to Tor, which the Linux bind and the
adopt refusal do not cover. No `DISALLOW_FILE_MODS` (it would break the
owner's plugin installs). `tests/test_wordpress_installer_local_only.py`.
## Colima VM Sandboxing
- The VM has exactly two mounts: `~/.onionpress/shared:w` and
`~/OnionPress:w`. **No new `--mount` without a security review.** All
other container data is in named volumes.
- **App state stays in `~/.onionpress/`**, never `~/Library/Application
Support/` (spaces and the 104-char socket limit break Colima/Docker).
- New VMs get a 20 GiB disk (`VM_DISK`, 100 for a hub; fixed at creation).
- **One VM start: the launcher's `start_vm`** (its `start`, and `start-vm`
for the app bundle's `launcher.sh`, which starts the MenubarApp, runs
`start-vm` while the icon is gray, and runs no Colima of its own, #545).
`tests/test_app_entry_script.py`, `TestStartVm` in
`tests/test_launcher_dispatch_mac.py`.
- **`$COLIMA_HOME` must exist before anything runs Colima** (else Colima
silently uses `~/.colima`). `tests/test_colima_home_before_first_start.py`.
## OnionHeaven Hub Data Volume
- Registry and registrants' keys live on `onionpress-onionheaven-data`
(`/var/lib/onionheaven`), mounted by onionpress-tor, onionheaven and
workers only — **never wordpress, never under `/var/lib/onionpress`**.
`tests/test_onionheaven_data_volume.py`.
- The legacy move (`onionheaven-data-migrate.py`) reads WordPress-owned
paths by fd only; **SQLite never opens a path there**; fails closed only
with a real key at stake, never after the marker; older containers are
retired before `compose up`. `tests/test_onionheaven_data_move_hardening.py`.
- **An error is never a "no"**: `hub_data_state()` / `onionhome_registry_present()`
are tri-state; an unchecked backup says so in log, metadata and notes.
- Hub backups are sealed to the recovery key when
`hub-backup-recovery.pub` exists (`hub_backup.py`, PyNaCl `SealedBox`);
the private half is never stored, never on argv, never under `shared/` or
in config. Every hub-data restore is checked first (`hub_backup.check`).
`tests/test_hub_backup_sealed.py`.
- **Hub data never goes through WordPress**: the page's backup is
`include_hub_data=False` and refused outright on a hub/OnionHome or a
node that cannot say (fail closed); the page's restore refuses backups
with hub keys or sealed data and never writes hub volumes.
`tests/test_backup_hub_data_off_wordpress.py`.
- Compose and tor image ship together; never downgrade a hub across this
change (docs/DOCKER-SOCKET.md).
## Registration Caps (issue #391)
- web-server.py bounds free registrations (`registration_room`,
`analytics_room`, `MAX_NAMES_PER_ADDRESS`); existing pairs always have
room. The numbers are still the issue's decision.
`tests/test_hub_registration_caps.py`.
- **Dead rows are bounded too** (#492): `unregister_entry` keeps the
newest `MAX_DEAD_ROWS_PER_ADDRESS` (4, at least 1, so #467's 404 still
knows the address) and `MAX_DEAD_ROWS` (`MAX_REGISTRANTS` × 4) hub-wide
(`prune_dead_rows`); `/status`'s `unregistered` counts kept rows.
`DeadRowsTest` in the same file.
## The Hub Bounds What a Free Key Leaves and Tells (issues #467, #468, #469)
- #467: `/offline`, `/unregister` and the signed status query answer 404
for an address the registry has no row for, before a replay mark or an
address log line is written (`_verify_signature(..., registered_only=True)`:
after `verify_payload`, before `note_signed_request`; only `/online`,
bounded by `registration_room`, may leave anything). Every free-text
body field goes through `onionheaven_common.short_text` (64 printable
characters: the registry's `version`, the address log lines, the
analytics metadata and audit). `_addr_logs` is an LRU of
`_ADDR_LOGS_MAX_ADDRESSES` (8192, above `MAX_REGISTRANTS`), and
`note_signed_request` deletes marks older than the tolerance on every
insert (`SIGNED_REQUEST_MARK_TTL`, an index on `last_timestamp`).
`UnknownAddressTest` and the pruning test in
`tests/test_hub_signature_verification.py`, `BoundedGrowthTest` in
`tests/test_hub_registration_caps.py`, the stored version in
`tests/test_hub_stored_keys.py`.
- #468: `/logs/manifest` and `/logs/upload` take a v3 `healthcheck_address`
only (with "" the files landed in `<content>/` itself, outside the
per-instance quota; the body has to be read to see it), and
`analytics_pair_room` holds the pair's folder, sidecars included, to
`ANALYTICS_PER_INSTANCE_QUOTA` before every write (507). `analytics_disk_full`
is the one reader of `shutil.disk_usage`, on `ANALYTICS_DIR` (a bind
mount of the host's folder; "/" was the container's disk). The cleanup
trims stray files under `<content>/` as a group of their own;
`MAX_MANIFEST_FILES` bounds a manifest. No replay marks on `/logs/*`:
the client re-sends the same signed upload when an ACK is lost (same
second, so the same signature), and a replay re-stores identical bytes
within the quota. `AnalyticsQuotaTest` in `tests/test_hub_registration_caps.py`.
- #469 (decided conservatively, 2026-10-29): `GET /status/<address>`
answers `{address, registered, status}` for a content address only (a
healthcheck address is 404: it names nothing on its own); the detail
(healthcheck address, version, every time, audit, `recent_logs`) answers
`POST /status/<address>` signed by the address's own key under
`STATUS_ENDPOINT` ("status"), replay-marked like `/offline`, 404 when
never registered; the aggregate `/status` keeps its counts and lost
`takeover_workers` (names, per-worker queues; nothing read it).
Consumers: the Mac daemon's `onionheaven._heartbeat_landed` (did a
timed-out `/online` land?) is the one reader of the detail and signs
its query (`onionheaven.STATUS_ENDPOINT`, test-tied); the launchers'
hub-address check and the heartbeats read only the aggregate; linux/,
the plugins and tests/stress read neither form. A hub without this
release still answers the old GET. `tests/test_hub_status_privacy.py`
(server, client and a consumer census).
## Registry Names Reach Docker Only as Workers'
- Registry data is attacker-writable: a name reaches docker only through
`worker_name_ok`/`is_worker_name`, an address only through
`onion_address_ok`. `tests/test_hub_registry_names.py` (census; a new
docker argv must be guarded or listed in `REVIEWED`).
## Stored Registrant Keys Are Checked and Written in One Step
- `/online`'s PEM must derive to the address (`private_key_problem`,
`keypair_error` via libsodium); else 400, stored key untouched.
- Key files are never rewritten in place: temp O_EXCL|O_NOFOLLOW 0600,
fsync, rename, fsync dir. `tests/test_hub_stored_keys.py`.
## Request Signatures Are Made and Checked by libsodium
- Verify is PyNaCl's `VerifyKey.verify`; without PyNaCl it raises
`VerificationUnavailable` (503), never says yes. Signing composes
libsodium primitives only (no arithmetic of our own); the pure-Python
fallback warns and must never be reached in shipped builds.
- The only verifier is web-server.py; `src/onionpress/onion_auth.py` is the
source of the image's and stress image's copies (`build/sync-onion-keys.sh`).
`tests/test_onion_auth.py`.
- **A v3 address is derived only by `onion_auth.derive_onion_address`; an
onion key's OpenSSH PEM is read, built and checked only by
`onion_keys`** (source in `src/onionpress/`, copied into the tor image by
the same script), **and C Tor's `hs_ed25519_*` files only by its
`parse_ctor_secret` / `parse_ctor_public` / `build_ctor`**. No second
parser or derivation in web-server.py, key-convert.py, tor-watchdog.py,
onionnames.py or key_manager.py (a `hostname` is decoded by
`onion_auth.decode_onion_address`, checksum checked). `tests/test_onion_keys.py`.
- **`tor_control.py` is the image's one Tor control-port client** (cookie
auth, whole replies): the watchdog, the queue manager, the takeover
worker and the farm's `bootstrap-phase` / `onions-detached` verbs use
it. Every takeover and release in a worker goes through its queue
manager (no tor-manager script). `tests/test_tor_control.py`.
- A verified request is accepted once (`note_signed_request`, replay →
403). `tests/test_hub_signature_verification.py`.
## Inside a Takeover Worker
- The queue manager socket is 0600 from bind(); every client is root.
`healthcheck_address` is fetched only if it is one v3 address.
`tests/test_takeover_worker_hardening.py`.
## OnionHome Name Registry Volume
- `onionnames.db` lives on `onionpress-onionnames-data`, mounted by
onionpress-tor only — never wordpress. `tests/test_onionhome_registry_volume.py`.
- **A restore never puts the backup's file on the volume**: it is rebuilt
by the image's `onionhome-registry-migrate.py` and swapped in via
SQLite's backup API; keep that script's flags and marker JSON stable.
`tests/test_onionhome_registry_restore.py`.
- web-server.py serves no name (503) until the move's marker exists; an
unreadable registry is held, never abandoned. Never downgrade OnionHome
across this change.
## No Message Channel From the Hub (removed 2026-10-25)
- The healthcheck service is GET-only and nothing shows hub messages to the
owner. A future hub→owner channel must be signed and designed for it.
`tests/test_install_invariants.py`, `tests/test_mac_daemon.py`.
## The Directory Answers Visitors Only on OnionHome (issue #394)
- The directory's visitor hooks run only when the node's own address is
OnionHome's (never on the Host header alone); misses cached 60 s.
`tests/test_onionname_redirect.py`.
## OnionHeaven Is On by Default
- **`REGISTER_WITH_ONIONHEAVEN`: `yes` or unset registers, `no` opts out,
anything else does neither.** Every gate is `== "yes"` after
normalizing, never `!= "no"`. Nothing prompts. User-facing text says the
heartbeat ships the private key to the hub and how to turn it off.
`tests/test_onionheaven_opt_in.py`.
- The choice is re-read before every request carrying the key; nothing
goes out unless registering. An explicit `no` sends one signed
`/unregister`, retried every 15 min. `tests/test_onionheaven_opt_out.py`.
- **The probe asks the hub only while registering**: the failure path's
reference onions (`HealthChecker.reference_onions`) take the hub from
`hub_reference` (the daemon's: `onionheaven.reference_hub`, the
configured hub or "" unless the choice is `yes`), else only
`WELL_KNOWN_ONIONS`. `daemon offline` holds the probe until a pass sees
the stack down (`Daemon._stop_hold`, at most `STOP_HOLD_SECONDS`).
`TestTheProbeAsksTheHubOnlyWhileRegistering` in
`tests/test_onionheaven_opt_in.py`, the stop-hold cases in
`TestStatusPass` (`tests/test_mac_daemon.py`).
- **One client, `onionheaven.OnionHeavenClient`, in one daemon on both
platforms** (#407): the hub lock (`onionheaven-hub.lock` next to the
record, #404) orders every /online, /unregister and record write with
the CLI's `onionheaven-unregister`; no second implementation in
`linux/`. `tests/test_onionheaven_opt_in.py`,
`TestTheHubLockSpansProcesses` in `tests/test_onionheaven_lifecycle.py`.
- **Every end of a hub's hold on a key takes it back** (uninstall, reset,
key import, restore of another address, hub change) via
`release_registration` / `onionheaven-unregister`; what could not be
released stays in `left_on_hubs` and is shown. `_hub_lock` orders it
against heartbeats. `tests/test_onionheaven_lifecycle.py`.
- Hub mode is decided by address (`is_onionheaven_instance`), never by a
running container. `tests/test_linux_hub_mode.py`.
- **Onionnames go with OnionHeaven** (#554): nothing registers an
onionname with OnionHome unless `REGISTER_WITH_ONIONHEAVEN` is on (the
`registration_choice` rule) and `ONIONHOME_ADDRESS` (the key analytics
sharing reads; absent is the default) is not empty or invalid:
`onionnames_registrar.configured_onionhome` for setup's
`register_onionname` and the daemon's retry, and
`onionpress_namesync_allowed` (from `config-current.json`, unreadable is
no) before the name-sync plugin's per-user and daily registrations. Off,
the name is recorded unregistered and never retried; names already
registered stay. The e2e baseline config turns both off.
`TestConfiguredOnionHome` / `TestSetupOnionname` in
`tests/test_setup_through_the_cli.py`, `TestOnionnameRetry` in
`tests/test_mac_daemon_paths.py`, `tests/test_name_sync_gate.py`,
`tests/test_e2e_harness.py`.
## Backups Carry Original Images Only (issue #257)
- Backups prune WordPress derivatives by `src/onionpress/derivatives.py`'s
rule (`backup.create_backup`, both platforms); restores
regenerate them in the background (`--only-missing`, marker cleared
only when complete) and the `onionpress-image-fallback.php` mu-plugin
serves originals meanwhile. The two halves ship together.
## Backup and Restore Are the CLI's on Both Platforms (issue #406)
- **No backup or restore runs in bash**: both launchers' `backup`,
`restore`, `settings-page-backup` and `settings-page-restore` cases
delegate to `onionpress.cli` (`cmd_backup` → `backup.create_backup`,
`cmd_restore`, `cmd_settings_page_backup`, `cmd_settings_page_restore`
→ `restore_from_backup(through_wordpress=True)`). No platform seam is
left (#407): no unit is stopped around a restore on either platform.
`TestLinuxSettingsPageBranches` in `tests/test_backup_hub_data_off_wordpress.py`,
`tests/test_launcher_dispatch_linux.py`.
## Backup/Restore Time Limits Grow With the Site (issue #317)
- No fixed copy limit: floor + size at `wp_files.COPY_BYTES_PER_S`
(`sized_timeout`); dumps stream to file. UIs wait for the launcher's
backup/restore without a timeout. `tests/test_backup_timeouts.py`.
- **The Settings page's restore is never the launcher's `restore`**: it
stays the in-place `restore_from_backup(through_wordpress=True, …)`
(`cmd_settings_page_restore`), which carries the hub/OnionHome refusals
and address-change check; a WordPress-uploaded backup must never reach
install-from-backup.
- `wp_files.own(recursive=True)` never enters `creations` and follows no
link.
## A Restore Works From Any Folder and Leaves the Site Up (issue #508)
- Linux's compose files are the install tree's: `resolve_paths` takes
`platform.linux_docker_dir()` (`$ONIONPRESS_INSTALL_DIR/docker`,
`<root>/docker` beside `lib/onionpress`, a checkout's
`app/Resources/docker`); nothing in the Python side depends on the
current folder. `TestLinuxDockerDir` in `tests/test_onionpress_platform.py`.
- A restore never writes `wp-content/creations` (the host's `~/OnionPress`,
bind-mounted; under rootless Docker www-data's tar cannot set its
times): `put_wp_content` passes `WP_CONTENT_NEVER_RESTORED` as
`put_tree(skip=)`. `TestPutTree` in `tests/test_wordpress_writes_as_www_data.py`.
- A restore that fails past its teardown starts Tor again (`tor_back` in
`cmd_restore`) and keeps its staging; **both** platforms' starts import a
kept `.install-from-backup` (`start.post_up`). `TestCmdRestore` in
`tests/test_cli_commands.py`, `tests/test_start_post_up.py`, the
`install-from-backup*` start traces.
## Backup Encryption (backup format 2)
- Format 2: plain `.zip` with `README.txt` + `onionpress-backup.enc`
(Argon2id INTERACTIVE + secretstream XChaCha20-Poly1305 via PyNaCl, as
they are). Layout in `backup_crypto.py`'s docstring;
`tests/fixtures/backup-format-2.zip` is sha256-pinned — **never
regenerate it**; changing the bytes means a new format.
`tests/test_backup_manager.py`.
- KDF must stay restorable on a 1 GB Raspberry Pi; readers refuse headers
above opslimit 4 / 128 MiB as "newer".
- PyNaCl is a dependency everywhere (Mac bundle rebuilt with `-O2` by
`build/py2app-extensions.sh`; `python3-nacl (>= 1.5.0)` on Linux),
imported guarded so format 1 works without it.
- **Format 1 (`zip -P`) must restore forever; never write `zip -P` again.**
- Every backup is written by `backup.pack_backup` and read by
`extract_backup` (both platforms', through the CLI's backup and
restores); nothing else zips or unzips one.
## Restores Extract Plain Files Only
- `validate_archive_members` runs before any extractor (no links, devices,
absolute/`..`/NUL/empty/duplicate names); `check_staged_tree` rechecks
before the first docker cp. `pack_backup` never stores links (`zip -y`).
`tests/test_backup_archive_members.py`.
- Zip-bomb limits (`check_archive_size`); format 2's inner zip is extracted
by zipfile only; format 1's `unzip -P` under `ulimit -f` and a disk
watchdog. `tests/test_backup_extract_limits.py`.
- A restored site key is checked before use; the hostname is written from
the key. `tests/test_tor_watchdog_service_ids.py`.
- **The healthcheck key is in no backup on either platform**: a start makes
a new one and the heartbeat tells the hub; a restore takes none and
removes a key an older restore staged.
`tests/test_healthcheck_key_never_backed_up.py`.
- **A restored dump runs only SQL**: `backup.db_import` streams it on stdin
to a client run `--sandbox --binary-mode --local-infile=0` (refuse a
client without `--sandbox`); the DB password re-set uses the host
secrets file, never a wp-config.php value, checked by
`_reject_unsafe_db_value`. `tests/test_db_import_sandbox.py`.
## Backups Stage Plain Files Only
- What comes out of a container may hold planted links: unpack with
`backup.unpack_plain_tar` (never a host `tar -x`), then
`scrub_staged_tree`; `pack_backup` scrubs again.
`tests/test_backup_staging_links.py`.
- The page's upload is copied to a private temp dir and must be a regular
file (`check_uploaded_backup`; `tests/test_settings_restore_upload.py`);
the finished page backup is written in as www-data, never root's
`docker cp` + `chown` (`tests/test_settings_backup_handoff.py`).
## Root Never Writes Through www-data's Names
- **Everything the host hands the WordPress container goes in as www-data**
via `onionpress.wp_files` (`put_file`, `put_tree`, `exec_args`; bash
`wp_put`). Root's only chown is `wp_files.own_args` — never `chown …
/var/www/…`, never a trailing slash. wp-content is read for backups as
www-data. The onion address is written by www-data and read by the
watchdog O_NOFOLLOW, one v3 address only.
`tests/test_wordpress_writes_as_www_data.py`.
## wp-cli Runs as www-data
- **Never pass `--allow-root`.** Host Python: `wp_files.wp_cli_args` /
`Docker.wp`; launchers `wp_cli` / `wp_cli_in`; image scripts
`onionpress-wp`. Root acts on nothing wp-cli prints (except the audit's
core zip at its fixed name). `tests/test_wp_cli_as_www_data.py`.
- Provisioning calls run offline (`wp_cli_args(offline=True)`,
`ONIONPRESS_WP_OFFLINE=1`). `tests/test_wp_cli_offline.py`.
- **`docker.Docker` is the one way Python runs docker** (#477): a module
takes a `Docker` from its caller (the CLI's, the daemon's, the
menubar's, built from `OnionPressPaths`; a test's
`tests/fake_docker.FakeDocker`) and runs through `run` / `exec` /
`wp(offline=…)` / `compose` / `popen` / `run_to`; nothing else builds a
`"docker"` argv, finds the binary or threads `docker_bin` / `docker_env`
by hand, and `Docker.wp` is the one wp-cli front end, every caller
saying `offline=`. The native messaging host builds its own
`Docker(resolve_paths())`. A `"docker"` string outside `docker.py` is a
path or the binary's name, listed with its reason in the census's
`REVIEWED`. `tests/test_docker_runner_census.py`.
- `onionpress-web-root-owner.sh` follows no link, prunes `creations`, and
changes nothing if mounts look wrong.
- Plugins install only from pins (`plugin_pin`, `build/pin-wp-plugin.sh`).
`tests/test_plugin_pins.py`. App and WordPress image ship together.
## Cache Enabler and the Link Fixer Are Active
- `onionpress.bundled_plugins` (both launchers delegate:
`install-bundled-plugins`, `setup-bundled-plugins`) installs pinned
releases, configures (refusing without their policy mu-plugin) and
activates: Cache Enabler `--network`, Link Fixer `--each-site`.
`INSTALL_IA_PLUGIN` / `INSTALL_CACHE_ENABLER=no` skip one.
- Cache policy: store only anonymous pages for Host == the onion address;
serve-time exclusions are forced on every site; stray hosts' settings
files are removed. Link Fixer policy: never names an onion/local/private
link, always verifies TLS.
- **Re-pinning either plugin means re-reading these against its internals**
(docs/BUILDING.md). `tests/test_bundled_plugins.py`.
## The Hit Counter Has One Writer (issue #398)
- `onionpress-hit-counter.php` is the plugin folder's only PHP; the
increment checks its nonce. `tests/test_hit_counter.py`.
## The Wayback Sweep Is the Network's (issue #322)
- archive.org keys live in the main site's options; lock and back-off are
network options via `onionpress_wayback_net_*` only; only the main site's
cron runs the sweep; one archive.org login
(`multisite.fetch_archive_s3_keys`, over Tor), the Settings page's too.
`tests/test_wayback_network_state.py` (tests use the
`onionpress_wayback_net_key` filter, never live keys).
## Auto-Update Swaps Bundles by Rename
- **Never write into `/Applications/OnionPress.app` while the app runs**:
stage the new bundle in a hidden sibling, then rename old away and new in
(`stage_new_bundle`, `swap_in_bundle`). Writing in place makes macOS
Gatekeeper revoke the copier's access mid-update.
`tests/test_updater_staged_swap.py`.
- The running app's updater performs the update, so an updater fix takes
effect one release later.
## Multi-User Support (v2.4.11+)
- Several macOS users can run one `/Applications/OnionPress.app`, each with
their own data dir, VM and containers. Extra users offset host ports by
+10000 each (18080/19050/19077…); container-internal ports never.
- **Detect ports with a socket bind test, never `lsof`** (it sees only the
current user's processes). **`ports.resolve_at_start` is the one rule**
(#405): a preset `ONIONPRESS_PORT_OFFSET` as it is (#507: a bind probe
beside this install's own running stack moved a restore to the next
offset), else the previous instance's port waited for
(`.previous-port-offset`, `wait_for_previous_port`), the running
stack's published port, else the probe. The daemon runs it with no
preset (`_resolve_ports_at_start`: it is the resolver the others take
their preset from), sets `onion_proxy`'s module globals (`_apply_ports`)
and publishes `ports`; `OnionPressCLI` and `onionpress launcher-env`
run it with the environment's. **Each launcher's `main` opens with
`launcher_environment`**: one `launcher-env` call whose `KEY=VALUE`
answer (the four port variables, `DOCKER_DIR`; on Linux `DOCKER_HOST`
and `DOCKER_SOCK` by the daemon's #503 rule, from `resolve_paths`) is
the only place the launchers export them; no answer keeps the preset
or 0 with an ERROR in the log. The Mac's Colima environment stays
bash. `tests/test_launcher_env.py`, `TestPortOffsetFromTheLauncher` in
`tests/test_onionpress_cli.py`, `TestResolvePorts` in
`tests/test_mac_daemon_paths.py`.
- **No `LSMultipleInstancesProhibited`** in any Info.plist (enforced across
users). Launcher `pgrep` uses `-u "$(whoami)"` and matches this bundle's
MenubarApp only. `tests/test_launcher_pgrep_anchor.py`.
## Browser Extension (issue #318)
- `.onion` routes to the daemon's HTTP proxy (`onion_proxy.py`), never to
Tor's SOCKS port. `extension-firefox/` holds only the Firefox manifest.
- **The proxy carries `.onion` only and answers no other site's page**:
`_from_a_web_page` refuses foreign `Origin`/`Sec-Fetch-Site`/`Referer`;
no clearnet fetch, no `CONNECT`. `tests/test_browser_extension.py`,
`tests/test_strict_onion_addresses.py`.
- **And only a request whose `Host` names it** (issue #465, `own_hosts`:
`127.0.0.1`/`localhost`/`[::1]` with its port; a bare name only on port
80): a page at a name its DNS points at 127.0.0.1 (rebinding) sends
`Sec-Fetch-Site: same-origin` and no `Origin`, what the setup page's
fetch sends. Checked first on every handler, OPTIONS and POST included;
only an absolute-form request (`GET http://x.onion/`) names its target
in `Host`. 403 without echoing the request.
- **A `/proxy/` page is sandboxed off the proxy's origin**: every response
under `/proxy/` carries `PROXIED_PAGE_CSP` (`sandbox allow-forms
allow-scripts allow-popups allow-modals allow-downloads`, never
`allow-same-origin`, added in `end_headers`), so a visited onion site's
script is no longer same-origin with `/status` or `/setup`. Its own
requests then arrive as `cross-site` with `Origin: null` or none, what
another site's page sends, so `_rewrite_onion_links` writes
`/proxy/<PROXY_PAGE_TOKEN>/<host>/...` (a per-run secret only a page the
proxy served knows) and a tokened request skips `_from_a_web_page`; the
plain `/proxy/<host>/` form (tools, the address bar) keeps the check.
`_setup_post_same_origin` also needs `Sec-Fetch-Site: same-origin` when
it is sent. Absolute-form responses get no CSP: that page is the
onion's own origin.
- Fallback pages only link the Wayback Machine, never redirect. Not
live-tested on a Mac; the native host likely does not start from a
py2app build.
## Cloudflare Tunnel Runs on Both Platforms (issue #352)
- `CLOUDFLARE_TUNNEL_TOKEN` adds `docker-compose.cloudflare.yml`.
**Only `start_cloudflare_tunnel` starts cloudflared** (identical in both
launchers): with a token and an installed WordPress, last in a start
(after provisioning and the bundled plugins) and at the end of every
setup path (no restart needed); otherwise the container is removed.
Stops always name the override file, token or not
(`ContainerManager.stop`, `tests/test_stop_containers.py`). Ingress must
be `http://wordpress:80`. `tests/test_cloudflare_tunnel.py`.
## Colima Networking Gotcha
- **SOCKS (9050) does not work through Colima port forwarding.** For any Tor
traffic from the Mac, `docker exec` into the tor container; never
`curl --socks5-hostname 127.0.0.1:9050` from the host.
- `wget` in the tor container cannot reach external `.onion` (no SOCKS).
Use `socat` with SOCKS4A:
`printf "GET / HTTP/1.1\r\nHost: <addr>.onion\r\nConnection: close\r\n\r\n" | socat -t 10 - SOCKS4A:127.0.0.1:<addr>.onion:80,socksport=9050`.
The WordPress container has `curl`.
- Internal path check: `docker exec onionpress-tor wget -q -O /dev/null http://wordpress:80/`.
## The OnionHeaven Overview (one view of both roles)
- `onionheaven.overview(app)` is the one view of client and hub roles; UIs
read it (status.json, daemon-state.json, `onionpress onionheaven
overview`) and never ask docker themselves. Registry rows are validated
before display (`hub_sites`). `tests/test_onionheaven_overview.py`.