@parhelia/capture
v0.4.13005
Published
Parhelia's capture sidecar: real Chromium screenshots of CMS pages without an open editor. Install with `npx @parhelia/capture install`.
Readme
@parhelia/capture
Parhelia's capture sidecar: real Chromium screenshots and DOM captures of CMS pages for agents, without an editor open in anyone's browser. It long-polls Parhelia outbound (no inbound port), renders each page top-level in its own sandboxed browser behind an egress proxy, and drives it with the controller page the Parhelia host serves. The capture logic ships with the host; the sidecar carries none, so it needs no upgrade when Parhelia does.
Node 22 or later. Plan: docs/capture-sidecar-plan.md.
Install (Settings › Capture service › Add)
Prerequisites: Node 22 or later with npm; root (sudo) on Linux with systemd, an Administrator PowerShell on Windows; outbound HTTPS to Parhelia and the rendering hosts. An administrator clicks Add under Settings › Capture service, which shows the Node bootstrap line for the OS and a paste-ready command with a one-time pairing code:
sudo npx --yes @parhelia/capture install --origin https://parhelia.internal.example --pair XXXXX-XXXXX-XXXXX-XXXXX--origin is the address this machine reaches Parhelia at. The key is generated on this machine; Parhelia stores only its
SHA-256. Pipeline-provisioned hosts skip the code: the host's connection string parhelia.capture
(key=pcs1_…;capabilities=cms,public-web;update=notify) names a key, and install --key-file <file> uses it.
What install sets up on Linux: the users parhelia-capture (the worker) and parhelia-capture-browser (the browser host,
whose only group is parhelia-capture-ipc); the code under /opt/parhelia-capture/versions/<version> with the current
link, root-owned and read-only to both; the key /etc/parhelia-capture/worker.key (0600, the worker's); Chromium's libraries
and an emoji font on apt systems; the units parhelia-capture-worker, parhelia-capture-browser-host (no network of its
own, see below) and parhelia-capture-updater with its timer; and the command /usr/local/sbin/parhelia-capture. On Windows:
the code under %ProgramFiles%\Parhelia Capture, the data and key under %ProgramData%\Parhelia Capture with inheritance
off, the browser's outbound firewall rule, and three scheduled tasks.
parhelia-capture status # versions, services, last poll, public websites, the updater's last run
parhelia-capture doctor # names what is wrong (Node, origin, SSO, key, permissions, sandbox, proxy or CA)
parhelia-capture update # an update now (also for "Notify only" workers); --from <tarball> for air-gapped ones
parhelia-capture uninstall # removes everything; revoke the worker under Settings › Capture serviceUpdates
The Parhelia host names the sidecar version it wants, with the tarball's integrity hash, each browser archive's SHA-256 and a
security minimum (written by the release pipeline, frontend/scripts/write-capture-manifest.mjs). The privileged updater
installs nothing whose hash the host did not name; it installs the new version beside the current one, runs its self-test
(a test capture, and the isolation proof when public websites are on), stops the worker between jobs, switches current,
and waits for the new version's first poll, else it rolls back. Each worker's policy in Settings is Automatic (the
default) or Notify only. Below the security minimum, or with a browser past its age, screenshots of public websites stay
off by themselves; CMS captures continue.
What each job gets
- A browser of its own (the sidecar's Chromium build, sandbox on) whose every connection goes through the job's egress proxy: the Parhelia origin, the page's head and the host's media origins, plus public addresses; private, loopback, link-local and metadata addresses are refused at the point of connection (each redirect hop included).
- One cookie: the job-bound capture cookie from the poll answer, on the Parhelia origin. No login, no editor session.
- The host's controller page and the target page; every call between them is authorized by the worker (the job's own pages, their main frames, the attempt still active).
Screenshots of public websites (public-web)
The agent tool capture-url-screenshot takes screenshots of public pages. Such a page is untrusted and the sidecar usually
sits inside the network, so the browser for it runs in a separate browser host process that has no network of its own:
on Linux a systemd unit with PrivateNetwork=yes and an empty view of every directory sockets live in, apart from its own
socket directory. Every connection it makes goes through a Unix socket to the worker's egress proxy. That proxy resolves
each name itself, refuses private, loopback, link-local and metadata addresses, the host's blocked ranges, and the addresses
of Parhelia and of this machine, and connects only to the address it checked. A public-web job gets no cookie.
# the worker, offering public-web once the browser host is connected and its isolation is proven
npx @parhelia/capture start --origin https://parhelia.internal.example --key-file /etc/parhelia-capture/worker.key --web
# the browser host, inside its hardened unit (print the unit's properties with `unit-properties`)
npx @parhelia/capture browser-host --socket-dir /run/parhelia-capture/browser-host
# the isolation self-test on its own
npx @parhelia/capture doctor --profile public-web --origin https://parhelia.internal.example --key-file /etc/parhelia-capture/worker.keyThe worker offers public-web only after the self-test passes, and repeats it when the browser host reconnects and once
an hour. The test first checks that the worker itself can fetch a nonce from Parhelia (another host) and one from its own
loopback. With the proxy switched off, the real browser must then reach neither. The browser host's view must hold no
socket but its own, D-Bus must be unreachable, and Chromium must run sandboxed. With the proxy on, Parhelia's address must
be refused. When Parhelia runs on the same machine, name a probe on another host with --probe-url. On Windows, a sidecar
on the machine that runs Parhelia refuses public-web by name, because loopback is not filtered there.
Local development: npm run capture (repository root) runs a worker from source against :5090 with a dev key;
scripts/run-scenario.ps1 <scenario> -Capture starts one with the scenario and gives the host the matching key.
