car-tracker/docs/SSH_RUN.md
Antoine Vandeplanque e57144c9b1
All checks were successful
CI / Lint, PostgreSQL tests, and package (push) Successful in 12s
CI / Build and publish tested image (push) Successful in 1m14s
run selected releases through restricted SSH instead of GitOps polling
2026-10-01 17:20:40 +02:00

6.1 KiB

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 exit status. Exit 0 is green; any nonzero pipeline exit fails the job. Green does not guarantee complete source coverage: see pipeline outcomes. Scraper output stays in private host logs; CI gets only request/revision/exit summaries. Migrations are manual, DB credentials stay on the homelab, and there are no automatic retries.

1. Replace the old consumer manually

On the homelab, disable polling and check that no old job is active:

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:

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:

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):

restrict,command="/usr/bin/python3 /home/tonio/containers/car-tracker/host_run.py" ssh-ed25519 <public-key-data> 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="<builder-LAN-IP>" 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 <UUID> <SHA> <digest> 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:

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:

192.168.1.56 ssh-ed25519 <host-public-key-data>

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:

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

Local outcomes: ~/containers/car-tracker/state/requests/<uuid>.json. Private runtime logs: the neighboring <uuid>.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 returns the recorded exit status. 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.