oddjob

Run declared, sandboxed jobs on a host over forced-command SSH


Keywords
automation, home-assistant, podman, ssh, systemd
Licenses
MIT/Apache-2.0

Documentation

oddjob

CI crates.io

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.

Security model

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 ha

generates 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.

Declaring a job

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.

Commands

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.

Calling from Home Assistant

shell_command:
  jellyfin_update: >-
    ssh -i /config/.ssh/oddjob -o StrictHostKeyChecking=accept-new
    runner@host.example jellyfin-update

Use detach = true for jobs that outlive Home Assistant's 60-second shell_command timeout.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.