From 4938de2d8b0fb6aaa540f7b93dbc63373d99d123 Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 1/4] Windows machines: herdr-core over Git Bash, and herdr-push pair there Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++ .../makefunstuff/herdr/core/hc/HcDetection.kt | 38 ++++++++++- .../makefunstuff/herdr/core/HerdrCoreTest.kt | 65 +++++++++++++++++++ .../com/makefunstuff/herdr/core/PushTest.kt | 2 +- host/herdr-push/README.md | 2 + host/herdr-push/herdr-push | 65 ++++++++++++++++--- 6 files changed, 168 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 57bee462..bc000059 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,14 @@ How it works and what it touches: [`host/herdr-push/README.md`](host/herdr-push/ 2. Add it to `~/.ssh/authorized_keys` on the machine running herdr. 3. In **Machines**, tap **Add machine**, then **Type the address instead**, and enter `user@host[:port]`. The app pins the host key on first connect. +**A Windows machine** works with the Herdr desktop app's herdr-core (the Agents, chat and Terminal screens; classic herdr, notifications and the health screen need Linux or macOS). It needs OpenSSH Server running with [Git for Windows](https://gitforwindows.org)' Bash as its shell, once, in a PowerShell opened as administrator: + +```powershell +New-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -Value 'C:\Program Files\Git\bin\bash.exe' -PropertyType String -Force +``` + +Then pair as above with `python herdr-push pair`. For a user in Administrators, Windows' sshd reads keys from `C:\ProgramData\ssh\administrators_authorized_keys`, so run `pair` in a terminal opened as administrator too (or add the key there by hand). + Machines reachable only through another one can use it as a jump host ("Connect through"). For zero-touch or read-only machines, **Allow host scripts** (on by default) can be turned off so the app skips copying its helper scripts (`herdr-usage`, `herdr-logins`, `herdr-push`) there. From a machine's menu, **Import herdr machines** adds the machines its herdr already knows (`herdr machine list`). ## How it talks to herdr diff --git a/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcDetection.kt b/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcDetection.kt index d7c15a12..c80e40d7 100644 --- a/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcDetection.kt +++ b/core/src/main/kotlin/com/makefunstuff/herdr/core/hc/HcDetection.kt @@ -19,6 +19,12 @@ data class HcProbe( val coreExes: List = emptyList(), /** The other sessions whose core runs , by name. */ val others: List = emptyList(), + /** + * A Windows machine (OpenSSH with Git Bash as its shell): there the core listens where no `sh` + * can see it, so there is no [socket]; `hc server status` says whether it runs, and `connect` + * finds it the way hc does ([HcDetection.windowsConnectCommand]). + */ + val windows: Boolean = false, ) { /** * The executable to run `connect` with: a running core's own build that is also installed where @@ -93,8 +99,22 @@ object HcDetection { */ val probe: String get() = "sh -c ${quote(PROBE)} hc" + /** + * Windows (Git Bash, MSYS or Cygwin under OpenSSH): `windows`, then, when the default session's + * core answers `server status`, `coreexepid` and `binexe--versioncore` + * for the herdr-core on PATH, else the desktop app's own install. Other sessions aren't looked for. + */ + private const val WINDOWS = "case \"\$(uname -s 2>/dev/null)\" in MINGW*|MSYS*|CYGWIN*) " + + "echo windows; b=\"\$(command -v herdr-core 2>/dev/null)\"; " + + "if [ -z \"\$b\" ] && [ -n \"\$LOCALAPPDATA\" ]; then b=\"\$(cygpath -u \"\$LOCALAPPDATA\" 2>/dev/null)/Programs/herdr-core/herdr-core\"; fi; " + + "[ -n \"\$b\" ] && [ -f \"\$b\" ] || exit 0; " + + "j=\"\$(\"\$b\" server status --json /dev/null)\" || exit 0; " + + "p=\"\$(printf '%s' \"\$j\" | sed -n 's/.*\"pid\":\\([0-9][0-9]*\\).*/\\1/p' | head -n 1)\"; [ -n \"\$p\" ] || exit 0; " + + "printf 'core\\t%s\\t%s\\n' \"\$b\" \"\$p\"; " + + "printf 'bin\\t%s\\t%s\\tcore\\n' \"\$b\" \"\$(\"\$b\" --version /dev/null | head -n 1)\"; exit 0;; esac; " + private val PROBE: String - get() = SOCKET + + get() = WINDOWS + SOCKET + "seen=' '; found=''; " + "emit() { [ -n \"\$2\" ] && [ -f \"\$2\" ] && [ -x \"\$2\" ] || return 0; " + "k=\"\$1:\$2\"; case \"\$seen\" in *\" \$k \"*) return 0;; esac; seen=\"\$seen\$k \"; found=1; " + @@ -162,9 +182,20 @@ object HcDetection { val coreRows = rows.count { it.size >= 2 && it[0] == "core" } // The session's lock held (Linux), else some core process (pgrep), else the socket alone. val locked = rows.any { it.size >= 2 && it[0] == "lock" } + if (rows.any { it.singleOrNull() == "windows" }) { + // Windows: the core answered `server status`, which is all there is to know. + return HcProbe(null, running = coreRows > 0 && binaries.isNotEmpty(), binaries = binaries.distinctBy { it.path to it.source }, coreExes = cores, windows = true) + } return HcProbe(socket, running = socket != null && (locked || coreRows > 0 || noPgrep), binaries = binaries.distinctBy { it.path to it.source }, coreExes = cores, others = others) } + /** + * ` connect` on Windows, where hc finds the default session's core itself; refused when + * `server status` says none runs, so `connect` never starts one there either. + */ + fun windowsConnectCommand(binary: HcBinary): String = + "sh -c ${quote("\"\$1\" server status --json /dev/null 2>&1 || { echo 'herdr-core stopped' >&2; exit 3; }; exec \"\$1\" connect")} hc ${quote(binary.path)}" + /** * ` connect` on [socket]: the core's API on the channel's stdin/stdout, never a new core * elsewhere. [session]: another session's name, which a core `connect` started would take, so @@ -195,6 +226,11 @@ object HcDetection { /** Connects to [session]'s core as [probe] found it (null: the default session's); null where it doesn't run. */ suspend fun open(conn: MachineConnection, scope: CoroutineScope, probe: HcProbe, session: String?): HerdrCore? { + if (probe.windows) { + if (session != null || !probe.running) return null + val binary = probe.binary ?: return null + return HerdrCore(binary, HcConnection.open(conn.start(windowsConnectCommand(binary)), scope), null) + } val socket = if (session == null) probe.socket?.takeIf { probe.running } else probe.others.firstOrNull { it.name == session }?.socket socket ?: return null val binary = probe.binary ?: return null diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/HerdrCoreTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/HerdrCoreTest.kt index 15b88d79..11770b78 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/HerdrCoreTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/HerdrCoreTest.kt @@ -476,6 +476,71 @@ class HerdrCoreTest { assertNull(HcDetection.parse("").binary) } + @Test + fun windowsHasNoSocketAndConnectsTheWayHcDoes() { + val out = """ + windows + core /c/Users/dev/AppData/Local/Programs/herdr-core/herdr-core 12660 + bin /c/Users/dev/AppData/Local/Programs/herdr-core/herdr-core hc 0.1.18 core + """.trimIndent() + val p = HcDetection.parse(out) + assertTrue(p.windows) + assertTrue(p.running) + assertNull(p.socket) + assertEquals("/c/Users/dev/AppData/Local/Programs/herdr-core/herdr-core", p.binary!!.path) + assertEquals("0.1.18", p.binary!!.version) + assertEquals( + """sh -c '"${'$'}1" server status --json /dev/null 2>&1 || { echo '\''herdr-core stopped'\'' >&2; exit 3; }; exec "${'$'}1" connect' hc '/c/Users/dev/AppData/Local/Programs/herdr-core/herdr-core'""", + HcDetection.windowsConnectCommand(p.binary!!), + ) + // herdr-core there, but no core answers `server status`. + assertFalse(HcDetection.parse("windows\n").running) + assertTrue(HcDetection.parse("windows\n").windows) + } + + @Test + fun theProbeScriptAsksHcOnWindows() { + val home = Files.createTempDirectory("hc-win").toFile() + try { + // Git Bash's uname, and a herdr-core whose core runs while `up` exists. + val bin = File(home, "bin").apply { mkdirs() } + File(bin, "uname").apply { writeText("#!/bin/sh\necho MINGW64_NT-10.0-26100\n"); setExecutable(true) } + val up = File(home, "up") + val exe = File(bin, "herdr-core").apply { + writeText( + "#!/bin/sh\n" + + "case \"\$1\" in --version) echo 'hc 0.1.18';; " + + "server) [ -f '${up.path}' ] || exit 5; echo '{\"holder\":{\"pid\":7},\"pid\":12660,\"version\":\"0.1.18\"}';; esac\n", + ) + setExecutable(true) + } + // The command line as SSH hands it to the login shell, from a file so no quoting is in between. + val command = File(home, "probe.sh").apply { writeText(HcDetection.probe + "\n") } + fun run(): String { + val p = ProcessBuilder("sh", command.path) + .apply { environment().apply { keys.removeAll { it.startsWith("HC_") }; put("HOME", home.path); put("PATH", "${bin.path}:/usr/bin:/bin") } } + .redirectErrorStream(true) + .start() + val out = p.inputStream.readBytes().toString(Charsets.UTF_8) + assertEquals(0, p.waitFor(), out) + return out + } + val stopped = HcDetection.parse(run()) + assertTrue(stopped.windows) + assertFalse(stopped.running) + up.writeText("") + val out = run() + val found = HcDetection.parse(out) + assertTrue(found.running, out) + // The path as sh writes it (on Windows itself, `/tmp/…` for `C:\…\Temp\…`). + assertTrue(out.lines().any { it.startsWith("core\t") && it.endsWith("/bin/${exe.name}\t12660") }, out) + assertEquals(true, found.binary?.path?.endsWith("/bin/${exe.name}"), out) + assertEquals("0.1.18", found.binary?.version, out) + } finally { + home.deleteRecursively() + } + } + /** [HcDetection.probe] in `sh` here, as SSH runs it there, with [home] as HOME and its own runtime and state folders. */ private fun probe(home: File): String { val p = ProcessBuilder("sh", "-c", HcDetection.probe) diff --git a/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt b/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt index c2a3449e..32b065db 100644 --- a/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt +++ b/core/src/test/kotlin/com/makefunstuff/herdr/core/PushTest.kt @@ -217,7 +217,7 @@ class HerdrPushTest { @Test fun bundledScriptIsTheOneInHost() { assertEquals(script.readText(), HerdrPush.script) - assertEquals("11", HerdrPush.version) + assertEquals("12", HerdrPush.version) } @Test diff --git a/host/herdr-push/README.md b/host/herdr-push/README.md index 5363322e..7b70f792 100644 --- a/host/herdr-push/README.md +++ b/host/herdr-push/README.md @@ -103,6 +103,8 @@ Anyone who sees the code within its five minutes could pair with it, as with any It touches one line in `~/.ssh/authorized_keys` while it waits (then the phone's key is there instead), and `pair-.json` and `pair.lock` in `~/.local/state/herdr-push/`. It needs neither the watcher running nor a phone subscribed. +On Windows (OpenSSH Server with Git Bash as its `DefaultShell`), `pair` is the one command that runs: `python herdr-push pair`. It reads the host keys and `sshd_config` in `C:\ProgramData\ssh`, writes the one-time key's command with forward-slash paths for Bash, and, for a user in Administrators while the stock `Match Group administrators` block is there, uses `administrators_authorized_keys`, which only a terminal opened as administrator can change. + It can't help when sshd reads keys from elsewhere (`AuthorizedKeysFile` in `/etc/ssh/sshd_config`; the phone then says the machine still refuses its key), or when the phone reaches the machine only through another one (a jump host): add those by hand. ## Your own push server diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index 54d5921d..356574bf 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# version: 11 +# version: 12 """herdr-push: tells your phone when a herdr agent needs you. It runs on the machine with herdr, watches its agents (`herdr api snapshot`, and the Herdr desktop @@ -42,11 +42,12 @@ that wasn't sealed with it. Python 3 alone, no packages. herdr-push pair [--host NAME[,NAME…]] [--port N] [--session NAME] [--name NAME] show a QR code a phone scans to add this machine (one-time, 5 minutes) +On Windows (OpenSSH with Git Bash as its shell) only `pair` runs: notifications need Linux or macOS. + See host/herdr-push/README.md in the herdr-android repo. """ import base64 -import fcntl import hashlib import hmac import json @@ -63,7 +64,12 @@ import urllib.parse import urllib.request import zlib -VERSION = "11" +try: + import fcntl +except ImportError: # Windows: only `pair` runs there + fcntl = None + +VERSION = "12" NAME = "herdr-push" HOME = os.path.expanduser("~") @@ -77,6 +83,9 @@ LOCK = os.path.join(STATE_DIR, "run.lock") LOG = os.path.join(STATE_DIR, NAME + ".log") # Touched by anything that knows you are here (a shell prompt, an editor hook): counts as typing in herdr. PRESENT = os.path.join(STATE_DIR, "present") +WINDOWS = os.name == "nt" +# Where sshd keeps its config and host keys: Win32-OpenSSH's under ProgramData. +SSHD_DIR = os.path.join(os.environ.get("PROGRAMDATA") or "C:\\ProgramData", "ssh") if WINDOWS else "/etc/ssh" INTERVAL = float(os.environ.get("HERDR_PUSH_INTERVAL", "4")) # ntfy and most push servers take 4 KB; the sealed text stays under this. @@ -1470,7 +1479,24 @@ _KEY_IN_LINE = re.compile(r"(?:^|\s)(" + "|".join(re.escape(t) for t in PAIR_KEY def authorized_keys_path(): - return os.environ.get("HERDR_PAIR_AUTHORIZED_KEYS") or os.path.join(HOME, ".ssh", "authorized_keys") + return os.environ.get("HERDR_PAIR_AUTHORIZED_KEYS") or (WINDOWS and windows_admin_keys()) or os.path.join(HOME, ".ssh", "authorized_keys") + + +def windows_admin_keys(): + """Win32-OpenSSH's own file for administrators' keys when sshd_config's stock `Match Group + administrators` block names it and this user is one; None otherwise.""" + try: + with open(os.path.join(SSHD_DIR, "sshd_config")) as f: + config = f.read() + except OSError: + return None + hit = re.search(r"^[ \t]*Match[ \t]+Group[ \t]+administrators[ \t]*\r?\n[ \t]+AuthorizedKeysFile[ \t]+(\S+)", config, re.I | re.M) + if not hit: + return None + groups = _out(["whoami", "/groups"], timeout=10) or "" + if "S-1-5-32-544" not in groups: # the local Administrators group + return None + return hit.group(1).replace("__PROGRAMDATA__", os.environ.get("PROGRAMDATA") or "C:\\ProgramData") def parse_public_key(line): @@ -1498,7 +1524,7 @@ def key_fingerprint(blob): def ssh_port(): """The port sshd listens on, as its config says (the first Port line), else 22.""" try: - with open("/etc/ssh/sshd_config") as f: + with open(os.path.join(SSHD_DIR, "sshd_config")) as f: for line in f: hit = re.match(r"\s*Port\s+(\d+)\s*$", line, re.I) if hit: @@ -1510,7 +1536,7 @@ def ssh_port(): def host_key_fingerprints(port): """This machine's SSH host keys' fingerprints, Ed25519 first: from /etc/ssh, else as sshd shows them.""" - where = os.environ.get("HERDR_PAIR_HOST_KEYS") or "/etc/ssh" + where = os.environ.get("HERDR_PAIR_HOST_KEYS") or SSHD_DIR blobs = [] for kind in ("ed25519", "ecdsa", "rsa"): try: @@ -1657,7 +1683,12 @@ def edit_authorized_keys(path, state_dir, change): keeping its mode) only when it changed. A one-time key whose time is over goes on the way.""" os.makedirs(state_dir, exist_ok=True) with open(os.path.join(state_dir, "pair.lock"), "a") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) + if fcntl: + fcntl.flock(lock, fcntl.LOCK_EX) + else: + import msvcrt + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) try: with open(path) as f: lines = f.read().splitlines() @@ -1736,7 +1767,11 @@ def cmd_pair(rest): " python3 herdr-push pair", file=sys.stderr) return 2 keys = authorized_keys_path() - for path in (script, sys.executable, STATE_DIR, keys): + python, state_dir = sys.executable, STATE_DIR + if WINDOWS: + # Git Bash runs the one-time key's command: Windows paths with forward slashes read the same there. + script, python, state_dir, keys = (p.replace("\\", "/") for p in (script, python, state_dir, keys)) + for path in (script, python, state_dir, keys): if any(c in path for c in '"\\\n'): print("herdr-push pair: the path %s can't go in authorized_keys (a quote or a backslash in it)." % path, file=sys.stderr) return 2 @@ -1755,7 +1790,7 @@ def cmd_pair(rest): import pwd user = pwd.getpwuid(os.getuid()).pw_name except (ImportError, KeyError): - user = os.environ.get("USER") or "me" + user = os.environ.get("USER") or os.environ.get("USERNAME") or "me" name = opts["name"] or socket.gethostname().split(".")[0] or addresses[0] try: seed, blob = one_time_key() @@ -1767,7 +1802,7 @@ def cmd_pair(rest): expires = int(time.time()) + opts["ttl"] state_path = pair_state_path(STATE_DIR, token) write_json(state_path, {"expires": expires, "used": False}, private=True) - command = " ".join(_sh_quote(p) for p in (sys.executable, script, "pair-accept", token, STATE_DIR, keys)) + command = " ".join(_sh_quote(p) for p in (python, script, "pair-accept", token, state_dir, keys)) version = openssh_version() options = [] if version is None or version >= (8, 2): @@ -1781,6 +1816,8 @@ def cmd_pair(rest): except OSError as e: _remove(state_path) print("herdr-push pair: can't add the one-time key to %s: %s" % (keys, e), file=sys.stderr) + if WINDOWS and keys == (windows_admin_keys() or "").replace("\\", "/"): + print("sshd reads administrators' keys from that file: run herdr-push pair in a terminal opened as administrator.", file=sys.stderr) return 1 link = pair_link(name, user, addresses, port, fingerprints, seed, expires, opts["session"]) @@ -1883,6 +1920,8 @@ def cmd_pair_accept(rest): def system_localtime(when): """`when` in the system's time zone, as sshd reads expiry-time, whatever TZ this shell has.""" + if not hasattr(time, "tzset"): # Windows: no TZ to set aside + return time.localtime(when) tz = os.environ.pop("TZ", None) try: time.tzset() @@ -2166,6 +2205,12 @@ LAUNCHD = """ def main(argv): cmd = argv[1] if len(argv) > 1 else "help" rest = argv[2:] + if WINDOWS and cmd not in ("pair", "pair-accept", "version", "_qr", "_seal", "help", "-h", "--help"): + print("herdr-push %s: Windows runs only `herdr-push pair`; notifications need Linux or macOS." % cmd, file=sys.stderr) + return 2 + if WINDOWS and hasattr(sys.stdout, "reconfigure"): + # A pipe there is in the ANSI code page, which has no block characters for the QR code. + sys.stdout.reconfigure(encoding="utf-8") if cmd == "run": return run() if cmd == "subscribe": From bdeef42762718b10641f970e8a153e7443892a0e Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 2/4] herdr-push pair on Windows: find Tailscale where its installer puts it Tailscale's installer adds its folder to the system PATH, but sshd and every shell already open keep the old PATH until restarted, so `pair` missed the Tailscale name and address and the code carried only the local network one. Fall back to %ProgramFiles%\Tailscale\tailscale.exe on Windows. Co-Authored-By: Claude Opus 5.5 --- host/herdr-push/herdr-push | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index 356574bf..a04db14d 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -1570,6 +1570,20 @@ def ssh_answers(port): return False +def tailscale_cli(): + """Tailscale's command line: the one on the PATH, else, on Windows, where its installer puts it. The + installer adds that folder to the PATH, but only programs started afterwards see it: sshd, and every + shell open at the time, keep the old one until restarted.""" + found = shutil.which("tailscale") + if found or not WINDOWS: + return found or "tailscale" + for root in (os.environ.get("ProgramFiles"), os.environ.get("ProgramW6432"), "C:\\Program Files"): + exe = os.path.join(root, "Tailscale", "tailscale.exe") if root else None + if exe and os.path.isfile(exe): + return exe + return "tailscale" + + def pair_addresses(): """Where a phone may reach this machine, best first: its Tailscale name and address, then the address of its default route (the local network).""" @@ -1579,7 +1593,7 @@ def pair_addresses(): if address and address not in found and not address.startswith("127."): found.append(address) - status = _out(["tailscale", "status", "--json"], timeout=5) + status = _out([tailscale_cli(), "status", "--json"], timeout=5) if status: try: me = json.loads(status).get("Self") or {} From c563f639e38282bd26114b13405aed7d6c7154ba Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 3/4] Pairing on Windows: python in place of python3 The pairing screen and the README say Windows' interpreter is usually python, and the README's Windows paragraph names what herdr-core gives there rather than a list of screens. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- .../kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bc000059..b72153d8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Without these secrets, and for local builds, the APK is signed with the normal d curl -fsSLO https://raw.githubusercontent.com/makefunstuff/herdr-android/HEAD/host/herdr-push/herdr-push python3 herdr-push pair ``` + (On Windows the interpreter is usually `python`, see below.) It shows a QR code with where the machine is (its Tailscale name and address, its address on the local network), its user, its SSH host key's fingerprint and a one-time code, valid five minutes. 2. In **Machines**, tap **Add machine**, then **Scan the code**. The phone's own code scanner reads it (Google Play services': the app asks for no camera permission). Without one, **Paste the link** takes the link printed under the code. 3. The app finds the machine at the first address that answers with that host key and shows what it found. **Pair and add** logs in with the one-time code, which can do nothing but hand over this phone's public key: it goes into `~/.ssh/authorized_keys` there, the code is spent, and the app pins the host key and opens the machine. @@ -81,7 +82,7 @@ How it works and what it touches: [`host/herdr-push/README.md`](host/herdr-push/ 2. Add it to `~/.ssh/authorized_keys` on the machine running herdr. 3. In **Machines**, tap **Add machine**, then **Type the address instead**, and enter `user@host[:port]`. The app pins the host key on first connect. -**A Windows machine** works with the Herdr desktop app's herdr-core (the Agents, chat and Terminal screens; classic herdr, notifications and the health screen need Linux or macOS). It needs OpenSSH Server running with [Git for Windows](https://gitforwindows.org)' Bash as its shell, once, in a PowerShell opened as administrator: +**A Windows machine** works with the Herdr desktop app's herdr-core: its agents and their terminals. Classic herdr, Shepherdr and App change need Linux or macOS. It needs OpenSSH Server running with [Git for Windows](https://gitforwindows.org)' Bash as its shell, once, in a PowerShell opened as administrator: ```powershell New-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -Value 'C:\Program Files\Git\bin\bash.exe' -PropertyType String -Force diff --git a/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt b/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt index f770ab77..05cebd02 100644 --- a/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt +++ b/app/src/main/kotlin/com/makefunstuff/herdr/ui/machines/PairMachine.kt @@ -237,7 +237,8 @@ private fun HowTo() { MonoBlock(PAIR_COMMAND) Text( "It shows a code for five minutes. Scanning it adds this phone's key there and checks the machine's " + - "host key, so there's nothing to copy or type. Where herdr-push isn't yet, or doesn't know pair:", + "host key, so there's nothing to copy or type. On Windows, use python in place of python3. " + + "Where herdr-push isn't yet, or doesn't know pair:", style = MaterialTheme.typography.bodyMedium, color = Herdr.colors.muted, ) From 6383eee96d0db85f1a3738f5a830b1e46555212b Mon Sep 17 00:00:00 2001 From: Manuel Puche <229537713+puchedev@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:09:20 -0500 Subject: [PATCH 4/4] herdr-push pair: name the host keys' folder this machine uses Co-Authored-By: Claude Opus 5.5 --- host/herdr-push/herdr-push | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/host/herdr-push/herdr-push b/host/herdr-push/herdr-push index a04db14d..47b82bbb 100755 --- a/host/herdr-push/herdr-push +++ b/host/herdr-push/herdr-push @@ -1792,8 +1792,9 @@ def cmd_pair(rest): port = opts["port"] or ssh_port() fingerprints = host_key_fingerprints(port) if not fingerprints: - print("Couldn't read this machine's SSH host keys (/etc/ssh/ssh_host_*_key.pub), and no SSH server answered " - "on port %d. Is sshd running? (--port N for another port)" % port, file=sys.stderr) + print("Couldn't read this machine's SSH host keys (%s), and no SSH server answered " + "on port %d. Is sshd running? (--port N for another port)" % (os.path.join(SSHD_DIR, "ssh_host_*_key.pub"), port), + file=sys.stderr) return 1 addresses = opts["host"] + [a for a in pair_addresses() if a not in opts["host"]] if not addresses: