Reaching the queue from the internet¶
How to submit jobs to a vq queue host that lives behind a home/lab router, from anywhere — securely.
The short version: SSH on a non-standard port, key-only auth, and vq
needs no code changes. vq’s entire transport is already SSH (ssh HOST vq <verb> for everything; scp for vq fetch). Make SSH
reachable and hardened, and vq is reachable and hardened — for free.
This is not the web dashboard. The dashboard (vq web) is read-only
plus a few kill/pause/resume endpoints; it has no submit path and is
not meant to face the internet. See Do not expose the
dashboard below.
The model¶
router
internet ───────────▶ ext port E ──forward──▶ compute :22 (sshd)
│
laptop ~/.ssh/config: │
Host compute │
HostName compute.example.com │
Port E │
User myuser │
IdentityFile ~/.ssh/compute_ed25519 │
│
vq ~/.config/vq/config.toml: │
[hosts.compute] │
ssh = "compute" ◀── alias, resolved by ─────────┘
~/.ssh/config
Three layers, each doing one job:
Router forwards an external port
Eto the queue host’s sshd.Eis not 22 (and not 80/443 if those are taken by another box).sshd on the queue host is hardened to key-only auth — that’s the security boundary.
~/.ssh/configon the laptop ties thecomputealias to the public domain + portE+ the right key. vq’sconfig.tomlkeepsssh = "compute"unchanged — it never needs to know the port.
That last point is the crux: vq stays transport-agnostic. The port
lives in ~/.ssh/config, not in vq’s config. vq.transport runs
ssh <alias> and scp; both honour ~/.ssh/config. Nothing in vq
changes when the queue host moves onto the internet.
Choosing the external port¶
Use a high port in the dynamic/private range 49152–65535. Reasons:
Low ports get scanned. Automated scanners sweep the whole low range — port 24, 28, 222, 2222 all get probed nearly as hard as 22. Picking a low or “obvious alt-SSH” port earns you none of the noise-reduction benefit.
The dynamic range is never assigned to registered services, so there’s no collision risk with anything else on the host or network.
The port is not a security control. Key-only auth (below) is the security. A non-standard port is purely log-noise reduction — it keeps your auth log from filling with drive-by scan attempts. So the only thing that matters is “high and unassigned.”
Pick any number in 49152–65535 you’ll remember. This doc uses E as a
placeholder; substitute your real choice everywhere.
Walkthrough¶
Do these in order. Step 3 verifies key auth works before step 4 disables passwords — skip the ordering and you can lock yourself out.
1. Generate a dedicated SSH key (on the laptop)¶
ssh-keygen -t ed25519 -f ~/.ssh/compute_ed25519 -C "vq-queue compute"
ed25519 — modern, fast, short keys.
Dedicated key — not a key you reuse for GitHub/other hosts. If it leaks you revoke exactly one thing.
Use a passphrase. The key is a code-execution credential (see Security model); a passphrase + ssh-agent means you type it once per laptop boot, and a stolen key file alone is useless.
2. Install the public key on the queue host¶
While you still have working SSH access (password or an existing key), on a normal port:
ssh-copy-id -i ~/.ssh/compute_ed25519.pub compute
# or, if ssh-copy-id isn't available:
# cat ~/.ssh/compute_ed25519.pub | ssh compute 'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
3. Verify key auth works — BEFORE hardening¶
ssh -i ~/.ssh/compute_ed25519 -o PasswordAuthentication=no compute hostname
This must print the hostname. The -o PasswordAuthentication=no
forces key-only for this one connection, proving the key works on its
own. If it fails, fix it now — do not proceed to step 4.
4. Harden sshd on the queue host¶
vq ships a ready drop-in at
contrib/sshd_config.d/vq-hardening.conf.
It disables password auth, scopes login to the queue user, and trims
unused SSH features. On the queue host:
sudo cp ~/path/to/vibe-queue/contrib/sshd_config.d/vq-hardening.conf \
/etc/ssh/sshd_config.d/
sudo sshd -t # validate — must print nothing
sudo systemctl reload sshd
Keep your current SSH session open while you do this. Then, in a second terminal, open a fresh connection to confirm it still works:
ssh -i ~/.ssh/compute_ed25519 compute hostname
If the fresh connection fails, your still-open first session can undo
it (sudo rm /etc/ssh/sshd_config.d/vq-hardening.conf && sudo systemctl reload sshd).
The drop-in ships a single AllowUsers entry as a placeholder — set it
to your queue host’s account before you reload sshd, and keep the first
session open while you check.
5. Router: forward the external port¶
In the router admin UI, add a port-forward rule:
external TCP E ─▶ <queue host LAN IP> : 22
(22 because the drop-in leaves sshd on 22 internally; the
non-standard port lives only in the forward + ~/.ssh/config. If you
prefer sshd itself on a non-22 port, set Port E in the drop-in and
forward E -> E instead — see the comment in that file.)
The DNS A record (compute.example.com -> public IP) you’ve
already set up handles name resolution; the router handles the port.
6. Point the laptop’s ~/.ssh/config at it¶
Add (or edit) the compute host block in ~/.ssh/config:
Host compute
HostName compute.example.com
Port E
User myuser
IdentityFile ~/.ssh/compute_ed25519
IdentitiesOnly yes
IdentitiesOnly yes makes ssh offer only this key, not every key in
your agent — cleaner, and avoids MaxAuthTries exhaustion if you have
many keys loaded.
7. Verify end-to-end¶
ssh compute hostname # plain SSH via the alias
vq programs # vq's SSH transport, through the alias
vq queue --active # the real thing
vq should work identically to how it did on the LAN — because to vq,
nothing changed. Same alias, same commands; ~/.ssh/config quietly
swapped the LAN address for the public domain + port.
Diagnosing a broken chain¶
Add a bastion and every failure starts to look the same. OpenSSH reports a failure anywhere in a proxied chain against the final target:
ssh: connect to host pbs-cluster port 22: Connection closed by UNKNOWN port 65535
That message is compatible with the local link being down, the jump host
being down, the target being down, and the key being rejected. The line
that names the real cause is one row up in ssh -v, which is not where
anyone looks first.
vq doctor HOST starts with a local leg that answers this before it
tries to reach anything:
vq doctor pbs-cluster
== vq doctor: pbs-cluster ==
OK config: configured ssh='pbs-cluster' scheduler='pbs'
OK ssh_route: 'pbs-cluster' -> ProxyJump 'gateway' -> gw.example.org:22 -> user@pbs-cluster.example.org:22
FAIL ssh_first_hop: jump host 'gateway' gw.example.org:22: connection refused
verdict: gw.example.org:22 refused the connection, so the jump host is up
but its sshd is not accepting on that port
next: nothing is wrong with vq or your key. Check sshd on gw.example.org
(or the port in ~/.ssh/config); the target host was never contacted.
verdict: failed
Three checks make up the leg:
ssh_routeasks OpenSSH itself (ssh -G) what the alias resolves to, and whether aProxyJumporProxyCommandsits in front of it. A nested jump chain is walked to the endpoint your machine dials first. This one is worth reading even when everything works: it makes an otherwise invisible bastion visible.ssh_first_hopopens a bare TCP connection to that endpoint. It runs below the SSH layer, so it separates “no path to the host” from “the host rejected my key” without offering a credential to anything. When it fails, doctor stops there. The remote checks would only reproduce the same failure, slower.ssh_transportappears when the first hop answered but ssh still failed. Doctor spends onessh -vand reports a named verdict plus the transcript lines that actually name a cause, including whatever aProxyCommandwrote to its own stderr.
A ProxyCommand is an opaque local program, so vq cannot know its first
hop and says so rather than guessing. The route line still prints the
command, and the ssh -v transcript still classifies the failure.
One case where a dead first hop is not the end of the story: if your
config uses ControlMaster / ControlPersist, an established master
socket keeps working after the route underneath it dies. Doctor checks
for a live master (ssh -O check, a local question) before it stops, and
when one exists it still reports the dead hop but runs the remote checks
anyway, because those are the only things that can say what vq can reach
right now. Expect that host to fail once the master expires.
vq doctor --all probes a shared bastion once, not once per host, so a
fleet behind one gateway costs one probe.
None of this knows what a VPN is. “First hop unreachable” is the actionable verdict either way: check the local link, then the gateway.
Security model¶
vq submit runs arbitrary code on the queue host as the queue
user. vq submit foo.py runs foo.py; vq submit -d dir -- bash x.sh
runs x.sh. That’s the nature of a job queue, not a flaw — but it
means:
The SSH private key is a code-execution credential for the host. There is no “submit jobs but can’t run arbitrary code” mode — submitting a job is running code.
So treat ~/.ssh/compute_ed25519 like a root password:
Passphrase-protected, loaded into
ssh-agent(type it once per boot).Dedicated to this host — never reused, never committed to a repo.
If a laptop holding it is ever lost: remove the pubkey line from the host’s
~/.ssh/authorized_keysimmediately, generate a fresh key.
What you get in exchange for that discipline: key-only auth on a non-standard port is a small, well-understood attack surface. It’s the same sshd the whole internet relies on, with passwords off.
Also recommended on the queue host:
fail2ban (or sshguard) — bans IPs after repeated auth failures. Cheap insurance against scan volume; likely already installed.
Keep the OS +
openssh-serverpatched.
Do not expose the dashboard¶
The web dashboard (vq web run, the FastAPI app) is not part of
this. It’s read-only HTML plus bearer-token-gated kill/pause/resume —
no submit endpoint — and exposing it publicly would mean a second
public port, its own TLS certificate, and the bearer token as a
(weaker-than-an-SSH-key) credential.
If you want the dashboard in a browser while away from the LAN, tunnel it over the SSH connection you already have — zero extra public exposure:
ssh -L 8765:localhost:8765 compute
# leave that running, then open http://localhost:8765 in your browser
(The vq-hardening.conf drop-in deliberately leaves
AllowTcpForwarding at its default so this works.)
Multiple submitters (later)¶
Today the model is single-user: one account, one key, one AllowUsers
entry in the sshd drop-in. Adding a second authorized submitter is:
Append their public key to the queue host’s
~/.ssh/authorized_keys(or give them their own account).Add their account to
AllowUsersin the drop-in if it’s a separate account.
vq’s JobSpec already records a submitter field, so per-job
attribution works. Per-user quotas and ownership checks on
kill/fetch are a genuine feature — tracked on the roadmap under
v0.6 multi-user — but they aren’t needed while it’s just you.