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.