Case study

sshsyncer

Go CLI that deploys a git branch over rsync+SSH with every credential held in a dedicated 1Password vault. Pinned-key auth, ownership-guarded vaults, exactly one biometric prompt per deploy.

2026Solo Go CLI, end-to-endpre-launchgithub.com
  • Go
  • Cobra
  • Charm (bubbletea, huh, lipgloss)
  • 1Password CLI
  • rsync
  • SSH
  • GoReleaser
sshsyncer brand image with GitHub and 1Password logos flanking a rust sigil
sshsyncer brand image with GitHub and 1Password logos flanking a rust sigil

What it is

A command-line deploy tool for the "one repo, one server" case. The kind of project where a full CI/CD pipeline is overkill and a shell script is a security hazard. sshsyncer keeps every credential (host, user, port, and the SSH key itself) inside a single 1Password vault. The .sshsyncer.json file you commit to the repo contains only op:// references; resolving them requires the user to be signed in to 1Password with access to the vault.

Only the last commit pushed to origin/<branch> is ever deployed. Local uncommitted changes never reach a server.

Why it exists

Most "deploy via SSH" scripts have at least one of these problems:

  • Keys live on every developer's laptop, copied around manually.
  • Credentials live in a shared file (or, worse, environment variables).
  • The SSH agent offers every key it holds until one matches, which is slow and prompts for biometric approval on each attempt.

The third one is the interesting bug. SSH defaults to MaxAuthTries=6, which means if you have seven keys in your agent and the right one is at position eight, auth fails before it's tried. With 1Password's SSH agent you also get a biometric prompt for every key offer until one succeeds. Acceptable for a daily ssh me@server; unacceptable for a deploy tool that should feel like git push.

Pinned-key auth

sshsyncer reads the public key from the SSH Key item that the Server item points at, then forces the SSH client to offer exactly that one key:

  • For rsync, it passes -o IdentitiesOnly=yes -i /tmp/sshsyncer-pub-XXX.pub so SSH only offers the one key from the agent.
  • For the native Go SSH path used by server test and log, it filters agent.Signers() down to the signer whose public key matches.

Result: exactly one biometric prompt per deploy, regardless of how many keys the user has loaded.

The ownership guard

A 1Password vault that holds deploy credentials should not also hold anything else. Mixing items risks a destructive operation hitting an unrelated personal item. sshsyncer tags every item it creates with the literal string sshsyncer, and refuses to operate on a vault containing items it did not create. The vault is sshsyncer's, or sshsyncer walks away.

This is enforced in internal/op/vault.go at every setup, share, and deploy operation. It's the kind of constraint that costs nothing in code and saves you from a 3 a.m. mistake.

Two auth modes

Detected at runtime in internal/op/auth_mode.go:

  • Desktop app. 1Password 8 with the SSH agent enabled. Used for local development.
  • Service account. OP_SERVICE_ACCOUNT_TOKEN set in the environment, with a separate SSH agent (e.g. GitHub Actions' webfactory/ssh-agent) at SSH_AUTH_SOCK. Used in CI.

sshsyncer doesn't care which mode is active as long as the pinned public key is loaded into whatever agent is on the socket. Same binary, same .sshsyncer.json, both contexts.

Architecture

Thin Cobra handlers under cmd/, doing nothing beyond flag parsing and orchestration. Real work happens in internal/:

  • internal/op/ wraps the 1Password CLI (op://Vault/Item/Field references, vault CRUD, ownership guard, auth-mode detection).
  • internal/git/ handles branch detection, fetch, push-check, and archive.
  • internal/rsync/ builds argv, parses dry-run output, and runs the command.
  • internal/ssh/ is a Go-native SSH client with key pinning.
  • internal/deploylog/ appends and tails the remote .sshsyncer-log.

Everything that shells out (op, git, rsync, SSH) is gated behind a small interface or accepts an io.Writer, so the tests can fake it without subprocesses. The 1Password test suite uses a fakeRunner interface and runs entirely without the real op CLI installed.

Distribution

GoReleaser builds release artefacts for linux and darwin, amd64 and arm64. A Homebrew tap is wired up at davidjonidev/homebrew-tap with the formula depending on rsync and git. Both are gated until v1.0.

The bug I shipped, then fixed

The tool had two transports and two different host-key policies, which is the kind of inconsistency that survives review because each half looks fine on its own.

rsync ran with StrictHostKeyChecking=accept-new: trust the host on first sight, record its key, refuse if it ever changes. The native Go path, used by server test and log, fell back to InsecureIgnoreHostKey() whenever ~/.ssh/known_hosts was missing. That accepts every key and records none, so the connection never becomes verifiable. A first connection that cannot be verified is a reasonable trade. A connection that is never verifiable, on a machine you have used for months, is not.

Both paths now share one policy, implemented in Go rather than delegated to the ssh binary: unseen hosts are recorded on first use, a matching key passes, and a changed key is refused. If the home directory cannot be resolved it fails closed, because a host key that cannot be checked is not one to trust. Four tests cover it, and the one that matters asserts the refusal.

What I'd change

Recording on first use is honest trust-on-first-use, not verification. The stronger version bootstraps host keys into the 1Password vault alongside the SSH Key item, so even the first connection is checked against something the user already trusts. That is the v1.0 item; it needs the vault schema to carry host keys, which is a change to the data model rather than to this code path.

This site stores nothing until you opt in. Pick a setting below. You can change it any time from the footer. Privacy policy.