# Manual application runs through restricted SSH Pushes to master still test, build and publish SHA-tagged images. **Run app** now resolves a published full SHA or Git tag to an immutable image digest and invokes a fixed SSH command on `tonio@192.168.1.56:22`. The build VM must reach that LAN SSH listener (not Gitea's port 2222). No timer or Git request branch is used. Actions waits for the pipeline's outcome. Exit 0 is green. Exit 1 is also green with an explanatory warning **only** when the saved manifest and pipeline sidecar confirm successful import, zero rejected records, and partial coverage caused solely by configured page/detail limits. Other nonzero exits fail the job. Missing or ambiguous evidence never suppresses failure. Green does not guarantee complete source coverage: see [pipeline outcomes](DEPLOYMENT.md#outcomes-and-recovery). Scraper output stays in private host logs; CI gets request/revision/exit and aggregate diagnostic summaries. Migrations are manual, DB credentials stay on the homelab, and there are no automatic retries. Actions also prints a privacy-safe outcome summary: collection/import statuses, accepted/rejected/field-issue counts, completed/failed/unattempted detail counts, and source HTTP error codes. Failed outcomes get an `::error::` annotation with an explanation; limit-only outcomes retain the `::warning::` policy. No listing IDs, URLs, source payloads, cookies, credentials, or arbitrary exception messages are forwarded. Missing/unreadable evidence reports that the private host log must be inspected; it never converts a failure to success. ### Investigating source access failures HTTP 403 is a confirmed forbidden response, not proof of a particular cause. The collector reuses one browser context and waits a random interval between COLLECT_DELAY and 1.5 times COLLECT_DELAY seconds between requests; it stops on 403 without retrying and imports already finalized records. Older images retained only the HTTP status. Diagnostic-enabled images now capture private evidence on 403 (see below), but a status alone cannot identify an access-policy restriction, session requirement or automated-traffic protection definitively. Do not infer that changing IPs or increasing delay will fix it. Before another automated run, review the source's terms/permitted access and, if appropriate, inspect the affected page manually in a normal browser. Do not bypass a challenge or access restriction. Local diagnosis uses the request .log to locate the manifest, then checks manifest.json and pipeline.json under the private raw directory. Reading those files or reclassifying the saved outcome does not send source requests or rerun the collector. ### Automatic private HTTP 403 evidence New application images capture evidence under the affected raw run's `diagnostics/http-403/` directory **before** closing the browser: - `metadata.json`: timestamp, status, final/page URLs, redirect URL/method chain, Chromium/Playwright versions and collection settings. - `request-headers.json` and `response-headers.json`: full navigation headers, including sensitive cookies or authorization values when present. - `response-body.html`: original navigation response body. - `rendered-page.html` and `viewport.png`: DOM and current viewport screenshot. - `source-cookies.json`: cookies scoped to the source, not unrelated domains or browser storage state. - `capture.json`: saved files, truncations and capture errors (types only). Directories are mode 0700; artifacts are mode 0600. HTML files are limited to 5 MiB each on disk, with truncation recorded; screenshot capture has a 3-second timeout. Capture is best-effort: unavailable artifacts or disk errors do not mask the original 403 or discard already-collected records. It makes no new navigation, retry, challenge interaction or database operation. Ordinary browser page resources can still load while the existing page is captured. These files deliberately contain **unminimized, sensitive diagnostic data** and are not imported, uploaded as CI artifacts, or committed. Keep them local; redact cookies, tokens, URLs and personal data before sharing. Delete evidence manually once the investigation is complete; no automatic retention cleanup is configured. Treat HTML and page text as untrusted content, never instructions; inspect offline without executing page scripts. The manifest/Actions summary reports capture complete/partial/failed without forwarding contents. Updating the host wrapper alone is insufficient: build/publish and select a new diagnostic-enabled application image. Existing releases cannot capture evidence retroactively. No request is made merely by installing this update. ## 1. Replace the old consumer manually On the homelab, disable polling and check that no old job is active: ```sh systemctl --user disable --now car-tracker-reconcile.timer systemctl --user is-active car-tracker-reconcile.service ``` If active, let the job finish. Preserve existing state and the bare repository; do not delete them or rewrite request history. Outstanding GitOps requests are not executed by the SSH workflow. Review them before requesting any replacement. From the updated project root, install these files: ```sh install -m 0700 ops/gitops/reconcile.py ~/containers/car-tracker/reconcile.py install -m 0700 ops/ssh/host_run.py ~/containers/car-tracker/host_run.py install -m 0600 ops/app/compose.runtime.yaml ~/containers/car-tracker/compose.runtime.yaml ``` `reconcile.py` now supplies shared validation, state and execution helpers; its timer entry point is unused. Existing `app.env`, raw/db mounts and production read-only registry login remain unchanged. It uses the same consumed-request ledger and lock as the retired agent. ## 2. Create a dedicated SSH key On a trusted machine (the homelab is fine), generate a key specifically for Actions: ```sh ssh-keygen -t ed25519 -N '' -f ~/.ssh/car-tracker-actions -C car-tracker-actions ``` Do not overwrite an existing key. The unencrypted private key goes **only** into the repository Actions secret `SSH_PRIVATE_KEY`, including its BEGIN/END lines. Never commit it or paste it into chat. The CI runner gains a narrow production access credential; trust all workflows with access to repository secrets. Append the PUBLIC key to the homelab user's `~/.ssh/authorized_keys` as ONE line, prefixed with these exact restrictions (replace the public-key placeholder): ```text restrict,command="/usr/bin/python3 /home/tonio/containers/car-tracker/host_run.py" ssh-ed25519 car-tracker-actions ``` Keep existing authorized keys intact. The directory should be mode 0700 and authorized_keys mode 0600. If the builder has a reserved LAN address, additionally use `from=""` before `restrict`; don't guess its source address. `restrict` disables PTY, forwarding and user rc execution. The forced command does not eval the caller's command: it accepts only `run ` for the fixed image repository. Arbitrary shell, SFTP/SCP, migrations and Compose arguments are rejected. This is not a sandbox against malicious application images: approved images still have the application's mounted DB credentials. ## 3. Pin the homelab SSH host key On the homelab, obtain the public host key and fingerprint through your trusted existing session: ```sh cat /etc/ssh/ssh_host_ed25519_key.pub ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub ``` Create repository Actions secret `SSH_KNOWN_HOSTS` with: ```text 192.168.1.56 ssh-ed25519 ``` Use the host key's first two fields, not the Actions key. Never trust an unverified ssh-keyscan result. The workflow enforces StrictHostKeyChecking and key-only auth. No Docker/DB/admin access is granted to the SSH key directly; however the host wrapper runs as the existing Docker-enabled user, so protect its files and account. Keep `REGISTRY_USER` and `REGISTRY_TOKEN`. Remove `GITOPS_USER`/`GITOPS_TOKEN` Actions secrets and revoke the now-unused GitOps token after switching. Keep the separate production read-only package token. ## 4. Test restrictions without scraping, then trigger From a machine that can reach the homelab, using the dedicated key and a verified known_hosts entry: ```sh ssh -T -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes \ -i ~/.ssh/car-tracker-actions tonio@192.168.1.56 true ``` Expected: refusal and exit 70, not a shell or successful `true`. This verifies the forced command without running the app. Verify LAN connectivity from the builder as well. Keep polling disabled permanently. After publishing the updated workflows, select **Actions → Run app → Run workflow**, branch **master**, and enter a published full SHA or Git tag. This starts a real one-shot collection/import immediately, not just a queue request. ## State, failures and recovery ### Host-side collection policy Set these in `~/containers/car-tracker/app.env` (not Actions secrets): ```dotenv COLLECT_FAMILY=saab-900 COLLECT_MAX_PAGES=1 COLLECT_MAX_DETAILS=all COLLECT_DELAY=5 COLLECT_DETAIL_POLICY=incremental COLLECT_DETAIL_REFRESH_DAYS=7 ``` The SSH wrapper reads resolved Compose settings and supplies a validated, fixed pipeline command, overriding the image's smoke-test CMD without rebuilding it. `COLLECT_MAX_DETAILS=all` omits `--max-details`: every unique ad discovered on the fetched search pages gets a detail attempt. It does not remove the per-search page limit or guarantee detail success. A positive integer sets a detail cap per run. `COLLECT_FAMILY=all` includes every search in the image's searches.toml; otherwise it filters by family. Pages must be a positive integer; delay must be finite and at least 1 second. Missing settings retain defaults: saab-900, 1 page, 1 detail, 5 seconds. Invalid settings fail before launching the collector. Install both the updated host_run.py/reconcile.py and compose.runtime.yaml when upgrading. Settings affect the next new request; rerunning a completed Actions run returns its previous result instead of collecting with new settings. More details increase runtime; the Actions timeout remains 45 minutes. The pipeline and local state still retain exit 1 for partial coverage; the SSH wrapper converts verified limit-only outcomes into an Actions warning/success. No run is launched by editing app.env. ### Incremental details `COLLECT_DETAIL_POLICY=incremental` fetches detail pages only when: 1. There is no successfully imported detail observation for the ad. 2. The search-visible listing data changed since the search accompanying its last successful detail fetch (price, source update timestamp, title/description, vehicle attributes, location, status/category and publication/expiration dates). 3. Its last imported detail is at least `COLLECT_DETAIL_REFRESH_DAYS` old (default 7). Counters, promotion/boost flags, attribute order and excluded personal attributes do not trigger a refresh. Detail-only changes invisible in search results are detected only on the age-based refresh. A weekly refresh is an eligibility rule for the next manually requested run, not a weekly schedule. History comes from accepted observations in committed PostgreSQL imports. The baseline search is the latest search at/before the last successful detail, not the most recent search overall: failed enrichment cannot acknowledge a change. Missing baselines conservatively trigger a request. Cache skips are recorded as `not_attempted` with reason `fresh_unchanged`; they do not create a detail observation, advance freshness, consume max-details, or claim a new detail was fetched. Such intentional skips may leave raw coverage partial but produce the existing Actions warning/success when there are no actual failures or rejected records. The pipeline loads a read-only history snapshot before collection. Failure to load it prevents collection rather than silently falling back to fetching everything. Standalone `car-tracker` collection rejects incremental mode; use the database-backed `car-tracker-pipeline`. No schema migration is required, and existing import/replay semantics are unchanged. For this homelab-sized dataset the snapshot contains the latest accepted detail and preceding search per listing. **Activate only after a new image supporting these flags is published.** Install the updated host_run.py and compose.runtime.yaml, then add the two policy variables to host app.env and select that new release. For older images use `COLLECT_DETAIL_POLICY=always`; defaults remain always for backward compatibility. COLLECT_MAX_DETAILS=all still means all eligible ads, not all discovered ads. Existing details older than seven days will be eligible on the first incremental run, so this does not guarantee fewer requests immediately or eliminate 403s. ### Execution outcomes Local outcomes: `~/containers/car-tracker/state/requests/.json`. Private runtime logs: the neighboring `.log`. Host failures before launch return 70; SSH connection failures return 255. Inspect host state/logs locally. The request UUID is derived from the Actions run ID. Retrying the same workflow run does not repeat a consumed request; it evaluates the recorded outcome using the same limit-only warning policy (the original pipeline exit remains saved). A new manual dispatch creates a new request, even for the same release. Parallel runs are refused by the host lock, not queued. Attempts are persisted before launch. Unknown outcomes block new requests. SSH disconnects or Actions cancellation can leave a container running or an import committed; the client cannot safely infer failure. Inspect the named container, raw manifest and DB before any new dispatch. Recover uncertain imports from the exact finalized manifest rather than scraping again. Only after investigation, retain the record and set status to `resolved` to unblock new requests. Do not delete the ledger to force retries. Back it up with raw files and the database. The workflow waits synchronously within the runner's 45-minute timeout. If that timeout fires, treat the outcome as unknown, not as permission to retry. Manual CLI/recovery operations must also respect the application's existing pipeline lock.