Run declared, sandboxed jobs on a host over forced-command SSH.
oddjob lets a low-trust caller — a Home Assistant automation, a cron job on another machine, anything holding one restricted SSH key — start work on a host by name, without being able to run anything else there. Jobs are declared in TOML files on the host; each one runs as a transient systemd user unit, either as a host command or as a podman container, with journald logging and a hard timeout.
The SSH key is installed with a forced command, so a connection can do exactly
one thing: ask oddjob serve for a job by name.
command="oddjob serve",restrict ssh-ed25519 AAAA... caller@somewhere
oddjob setup wires this up for you on the host:
$ oddjob setup --generate ./ha-key --comment hagenerates a passphrase-less ed25519 keypair (via ssh-keygen), appends the
forced-command line to ~/.ssh/authorized_keys (0600, created if missing),
and creates the jobs directory. Move the private key to the caller and you
are done. Use --pubkey <file> instead if the caller already has a key, and
--print-only to inspect the line without installing it. Setup is
idempotent: an already-installed key is left alone, and a key that is present
with different options is reported, never rewritten.
serve reads SSH_ORIGINAL_COMMAND, splits it shell-style, and looks the
first word up in the jobs directory (~/.config/oddjob/jobs.d by default).
Only names declared there can run. Caller-supplied arguments are rejected
unless the job opts in with allow-args, and they are passed as an argv
array — never through a shell.
One TOML file per job; the file stem is the job name (lowercase letters, digits, inner dashes).
# ~/.config/oddjob/jobs.d/jellyfin-update.toml
description = "Refresh the Jellyfin library"
command = ["jellyfin-update.sh"] # host executable, argv form
allow-args = true # append caller args (default: false)
timeout = "10m" # hard limit via RuntimeMaxSec (default: 10m)
[env]
JELLYFIN_URL = "http://media.local:8096"Container jobs declare image instead of command (exactly one of the two
is required):
# ~/.config/oddjob/jobs.d/mail-archive.toml
description = "Archive mail attachments to the NAS"
image = "localhost/mail-archiver:latest"
allow-args = true
detach = true # return at once; job keeps running
volumes = ["/mnt/nas/documents:/out"]Every job runs under systemd-run --user --collect, so logs land in the user
journal (journalctl --user -u 'oddjob-<name>-*') and the timeout is enforced
by systemd, not by oddjob staying alive. With detach = true the unit name is
printed and oddjob returns immediately; otherwise output streams back to the
caller and the job's exit code becomes oddjob's.
oddjob list # declared jobs and their descriptions
oddjob run <name> [args] # run a job locally
oddjob serve # SSH forced-command entry point
oddjob setup # install the SSH entry point (see above)
The jobs directory can be overridden with --jobs-dir or ODDJOB_JOBS_DIR.
shell_command:
jellyfin_update: >-
ssh -i /config/.ssh/oddjob -o StrictHostKeyChecking=accept-new
runner@host.example jellyfin-updateUse detach = true for jobs that outlive Home Assistant's 60-second
shell_command timeout.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
