Host setup
A host is any Mac or Windows PC the app attaches to. It needs three things: an SSH server reachable
over Tailscale, herdr on the PATH that an SSH session sees, and the device's public key
authorized. The app opens SSH with a PTY (TERM=xterm-256color), runs the attach command for the
host's platform, and renders the herdr TUI. herdr keeps the session and its agents running on the
host between connections.
Attach commands
| Platform | Default session | Named session |
|---|---|---|
macOS, Linux (unix) | exec "$SHELL" -lc 'herdr' | exec "$SHELL" -lc 'herdr session attach NAME' |
Windows (windows) | herdr | herdr session attach NAME |
HerdrCommand.attach(platform:session:)produces these strings, and a host'sremoteCommandoverrides them.- On unix, sshd runs the command with
$SHELL -c, which is neither a login shell nor an interactive one. On macOS that PATH is/usr/bin:/bin:/usr/sbin:/sbinplus whatever~/.zshenvadds, so Homebrew's/opt/homebrew/bin/herdris missing."$SHELL" -lcreads the login profile (~/.zprofile,~/.bash_profile,~/.profile, fish'sconfig.fish), which is where Homebrew and most installers set PATH. - On Windows, sshd gives the session the user's registry PATH, so bare
herdrresolves. The same string works whether sshd's default shell iscmd.exeor Windows PowerShell. herdr session attach NAMEandherdr --session NAMEare the same code path. Both start the session when it is not running.defaultnames the default session.- herdr accepts
NAMEonly as 1–64 bytes ofA-Z a-z 0-9 . _ -, and not.or... That charset needs no quoting in sh, zsh, bash, fish, cmd or PowerShell, so the app rejects any other name rather than escaping it.
What the TUI does over SSH:
ctrl+b qdetaches. herdr exits, the SSH channel closes, and the session keeps running. The next attach shows the same screen.- Below 64 columns herdr switches to its mobile layout (
ui.mobile_width_threshold, default 64). SSH window-change reflows it live between the mobile and desktop layouts. - The most recently attached client becomes the foreground client, and every pane in that session is resized to its terminal. When it detaches, the panes return to the remaining client's size.
Tailscale
- Install Tailscale on each host (macOS: the Mac App Store or standalone app; Windows: the installer from tailscale.com) and sign in to the tailnet.
- On iPhone or iPad, install the Tailscale app, sign in to the same tailnet, and turn the VPN on.
- Add hosts in the app by MagicDNS name (e.g.
jamess-macbook-pro) or100.x.y.zaddress.tailscale statuson any device lists both.
The app uses the host's own sshd. Tailscale only carries the traffic, and nothing from Tailscale is embedded in the app.
macOS host
-
Turn on Remote Login: System Settings › General › Sharing › Remote Login, with "Allow access for" including your user. (
sudo systemsetup -setremotelogin ondoes the same.) -
Install herdr:
brew install herdr, orcurl -fsSL https://herdr.dev/install.sh | sh(installs to~/.local/bin). -
Make sure a login shell finds it. From another machine, or over loopback:
ssh you@host 'exec "$SHELL" -lc "command -v herdr"'If that prints nothing, the PATH line is only in an interactive file such as
~/.zshrc. Move it to~/.zprofile(zsh) or~/.bash_profile(bash). -
Keep the Mac reachable. A sleeping Mac drops off the tailnet. System Settings › Battery › Options › "Prevent automatic sleeping on power adapter when the display is off" keeps it up.
Windows host
-
Install and start OpenSSH Server in an elevated PowerShell:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 Start-Service sshd Set-Service sshd -StartupType AutomaticThe capability adds the
OpenSSH-Server-In-TCPfirewall rule for port 22. -
Default shell (optional). sshd uses
cmd.exeunlessHKLM:\SOFTWARE\OpenSSH\DefaultShellnames another shell. The attach command works with either. To use Windows PowerShell:New-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -PropertyType String -Force ` -Value C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exesshd starts PowerShell without
-NoProfile, so the profile runs on every connection. Keep it quiet and fast. -
Install herdr as the user who will connect:
powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex"The installer puts
%LOCALAPPDATA%\Programs\Herdr\binon the user PATH. That folder, like%USERPROFILE%\.herdr\packages\standalone\current, is a junction to the current…\standalone\releases\<version>folder, created at medium integrity.- Standard users: their SSH sessions run at medium integrity and follow the junction, so nothing more is needed.
- Administrators: their SSH sessions run elevated (High Mandatory Level). Windows refuses
to follow a junction that a medium-integrity process created: "The path cannot be traversed
because it contains an untrusted mount point". An admin therefore needs the real
releases\<version>folder on the user PATH, and must move that entry to the new folder after eachherdr update. The updater keeps three releases, so a stale entry keeps resolving the older client for two updates and then disappears.
Check with a new SSH session:
ssh you@host herdr --versionshould print the version thatherdr --versionprints on the desktop. -
Start the default session from the desktop (open a terminal and run
herdr, thenctrl+b q). A herdr server survives the SSH disconnect that started it, but it then lives in the SSH logon session: for an administrator that is an elevated token (new panes are titledAdministrator: …), outside the console desktop.
herdr runs in the ConPTY that Windows OpenSSH allocates for the PTY. On connect, ConPTY sends
ESC[?9001h (win32-input-mode) and ESC[?1004h (focus reporting). A terminal that doesn't know
mode 9001 ignores it, and ConPTY falls back to plain VT input.
Pair with a QR code
On a host that is already set up (sshd on, herdr on the SSH PATH, Tailscale connected), pairing needs no copying or typing on the phone. The protocol is ADR 0002.
-
Put
scripts/herdr-pair.pyandscripts/qrcodegen.py(Nayuki's MIT QR encoder) in one folder on the computer. They need Python 3.9 or newer:/usr/bin/python3on macOS, the python.org build on Windows.ssh-keygenandssh-keyscancome with OpenSSH on both. -
In a terminal logged in as the account the phone should use, run
python3 herdr-pair.py [--session NAME](pythonon Windows). It refuses to run without a terminal on stdin, because approving a device needs a person at the keyboard. It prints a black-on-white QR code, about 73×37 cells, and the host-key fingerprint.To pair a Windows PC from a Mac, run the PC's copy over SSH, here installed in
%USERPROFILE%\herdr-ios:ssh -t volpe@supedupsilly 'python herdr-ios\herdr-pair.py'. The single quotes keep the backslash from the Mac's shell. The QR code and the approval prompt both come through the SSH terminal, which must be at least about 75 columns wide or ConPTY wraps the code. Keep the session open until the helper exits. If it drops, Windows ends the helper before its cleanup, and the one-time key stays until its expiry-time. -
Scan it with the Herdr app, or with the Camera app, which opens
herdr://pair?…. The phone shows the computer and account. -
The computer asks:
Pairing request from 'iPhone' device key SHA256:… Approve this device? [y/N]iOS reports only the model ("iPhone", "iPad") as the device name, so the fingerprint is what identifies the phone. The phone shows its own device key on the pairing screen. Approve only if it matches the prompt. Anything typed before the prompt appears is discarded, so only an answer given after reading it counts.
yauthorizes that exact key asssh-ed25519 … herdr-ios:<name>:<date>. The phone pins the host key from the QR code (no TOFU prompt), saves the host and attaches.
What the helper does:
-
It reads the host names (MagicDNS name, short name,
100.xaddress) and the computer name fromtailscale status --json, and the ed25519/ecdsa host keys fromssh-keyscan 127.0.0.1.--host NAME(repeatable) replaces the host names, and then the system hostname is the computer name.--portselects sshd's port. Characters the phone rejects (control, bidi and other format characters, separators, private use) are dropped from the computer name. -
It adds one line to the same authorized_keys file
authorize-key.shtargets:restrict,expiry-time="…",from="100.64.0.0/10,fd7a:115c:a1e0::/48,127.0.0.1,::1",command="<python> <herdr-pair.py> --enroll=<id> --state=<dir>" ssh-ed25519 … herdr-pair:<id>The key only runs the enroll command, only from the tailnet or loopback, for 10 minutes.
restrictrefuses PTYs and all forwarding, and the forced command also replaces any exec or subsystem request. On Windows the command uses 8.3 short paths, so it needs no quoting under either cmd or PowerShell. -
The line is removed on approval, denial, expiry, Ctrl+C, SIGTERM/SIGHUP and errors. If the process is killed outright,
expiry-timeretires the key; so does the case where, on Windows, the SSH session that started the helper drops. To clean up early, delete lines ending inherdr-pair:<id>. -
The pairing id is single use. The first enroll claims it with an exclusive create, and any later enroll for that id gets
USED. The approval is bound to the key shown in the prompt. The deadline is checked again at approval and before the key is written, so a lateyauthorizes nothing. -
Handoff files live in
~/.herdr-pair/(macOS) or%LOCALAPPDATA%\herdr-pair(Windows), and the folder is empty between pairings. The helper never prints the one-time seed or the full URL.
The enroll command answers the phone with one token on stdout:
| Token | Exit | Meaning |
|---|---|---|
OK | 0 | Approved; the device key is authorized |
DENIED | 3 | Denied at the prompt |
EXPIRED | 4 | The 10-minute code or the 120 s approval window passed, or the answer came after the deadline |
INVALID | 2 | The key isn't a bare ssh-ed25519, or the device name is empty, longer than 64 characters, or has control characters |
USED | 5 | Another enroll already claimed this code; the prompt on the computer is for that device, so deny it |
When the Windows default shell is PowerShell, sshd reports 3, 4 and 5 as exit 1, so the token is what counts.
Checks: python3 scripts/herdr-pair.test.py covers URL escaping, approve, claim-once,
deny, expiry, a late approval, invalid input, the terminal requirement, discarded typeahead and
signal cleanup. It uses a temp keys file, plus a
private loopback sshd on macOS for the forced-command probes. Adding
--remote-windows volpe@supedupsilly 'C:\Users\volpe\herdr-pair-test' runs the same probes
against the PC's real sshd from the Mac. The Windows ssh client hangs on exit when it runs without
a console, so the probes can't run on the PC itself. For an unattended end-to-end run, set
HERDR_PAIR_TEST=1 and pass --print-url, which prints the URL instead of the QR code, and
answer the prompt from a pipe (echo y | …). Without that variable, --print-url, --state,
--keys-file, --ttl and a piped stdin are all refused.
Authorize the device key by hand
Without the QR code, the app shows its Ed25519 public key for copying. Authorize it with
scripts/authorize-key.sh:
scripts/authorize-key.sh local "ssh-ed25519 AAAA… iPhone" # on the Mac itself
scripts/authorize-key.sh james@jamess-macbook-pro ~/Downloads/ipad.pub # a unix host over SSH
scripts/authorize-key.sh volpe@supedupsilly "ssh-ed25519 AAAA…" --platform windows
- Each key is written as
<type> <base64> herdr-ios[:<comment>], sogrep herdr-iosfinds every device key. Revoke a device by deleting its line. - Running it again with the same key changes nothing. The script accepts only a bare public key on
one line (no
command=or other options). - Reaching a remote host needs SSH access it already accepts (a password or another key).
localneeds none. - unix: appends to
~/.ssh/authorized_keysand sets~/.sshto700and the file to600(and runsrestoreconwhere SELinux is present). - windows: detected automatically when
--platformis omitted. An administrator whoseC:\ProgramData\ssh\sshd_confighas an activeMatch Group administratorsblock getsC:\ProgramData\ssh\administrators_authorized_keys, restricted to Administrators and SYSTEM. Otherwise, including administrators on a host where that block is commented out, the key goes in%USERPROFILE%\.ssh\authorized_keys, restricted to the user, SYSTEM and Administrators. The file is written as UTF-8 without a BOM, because sshd can't read the UTF-16 that>>andOut-Fileproduce in Windows PowerShell.
scripts/authorize-key.test.sh checks the script locally without touching any host.
Verify a host
From a Mac on the tailnet, run the same command the app runs:
ssh -tt you@mac 'exec "$SHELL" -lc herdr' # full layout
ssh -tt you@mac 'stty cols 50; exec "$SHELL" -lc herdr' # mobile layout
ssh -tt you@pc herdr
ctrl+b q returns to the local shell with the session still running (herdr session list on the
host).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
zsh:1: command not found: herdr, exit 127 | herdr's PATH entry is only in an interactive rc file | Move it to ~/.zprofile / ~/.bash_profile, or set the host's remote command to an absolute path |
The term 'herdr' is not recognized… (Windows) | herdr isn't on the user PATH | Re-run the installer, then reconnect. With a PTY, Windows sshd reports exit status 0 here, so the only sign is the text |
ssh you@pc herdr --version prints an older version than the desktop (admin account) | The user PATH still names the previous releases\<version> folder; elevated SSH sessions can't use the Programs\Herdr\bin junction | Point the PATH entry at the current releases\<version> folder |
Permission denied (publickey) on Windows | Key in the wrong file for the Match Group administrators setting, a UTF-16 file, or loose ACLs | Run authorize-key.sh; check the Match block in C:\ProgramData\ssh\sshd_config |
Permission denied (publickey) on unix | ~/.ssh or authorized_keys writable by others, or a group-writable home directory | authorize-key.sh fixes ~/.ssh and the file; chmod go-w ~ |
| App refuses a changed host key | The host was reinstalled, or the name now points at another machine | Compare the fingerprint with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub (macOS) or C:\ProgramData\ssh\ssh_host_ed25519_key.pub (Windows), then remove the pinned key in the app |
New Windows panes print The variable '$global:__HerdrOriginalPrompt' cannot be retrieved… | A PowerShell profile turns on Set-StrictMode, which herdr's prompt integration doesn't tolerate; the prompt still works, but herdr stops tracking the pane's cwd | Keep Set-StrictMode out of profile scope (don't dot-source scripts that set it) |
| Desktop panes change size while the phone is attached | herdr sizes panes to the most recently attached client | Expected; they return when the phone detaches |
| Connection times out | Tailscale is off on the device, or the host is asleep | Turn the VPN on; tailscale ping <host> |
