diff --git a/README.md b/README.md index 57bee462..15e77683 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,6 +82,16 @@ 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: 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 +``` + +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). + +For notifications, the PC has to be awake and signed in: the watcher runs in your signed-in session, and herdr-push needs a Python 3 there (the app finds `python3`, `python` or `py -3`, and skips the Microsoft Store's stand-in). See [Notifications while the app is closed](#notifications-while-the-app-is-closed). + 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 @@ -209,6 +220,8 @@ Two ways in, both on every machine the app knows: Each state is shown once, however it arrives, and comes down again when it is over (answered on the desktop, the agent back at work). The switch next to a machine in Settings leaves it out. +**On a Windows PC** the app sets herdr-push up the same way, with no administrator rights: a `Run` entry under your user starts it when you sign in, and a scheduled task of yours starts it right away in your desktop session. It reads the Herdr desktop app's herdr-core (where one runs), RAM, CPU, drives and the biggest processes, and tells the phone how long ago you last used the keyboard or mouse and whether the session is locked. The PC has to be awake and signed in: with nobody signed in, herdr-push's install says so (and its `status` shows `interactive: false`), and it starts with the next sign-in. The phone needs ntfy (or another UnifiedPush app), as for any machine. Classic herdr, Shepherdr and App change are not there. + ### What reaches the phone **Settings → Notifications → What reaches the phone** has a switch for each kind: **Needs you** (an agent asks, a machine runs low), **Finished** (an agent ended its turn), **Went wrong** (a review or a check failed) and **Limits** (an AI provider at 80% or 95% of a limit or out of it; see Usage and limits). Finished is off unless you turn it on: a ping for every finished turn teaches you to ignore the ones that matter. What is switched off isn't shown, and turning it on later starts from then. 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, ) 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..b6aa1b16 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("13", HerdrPush.version) } @Test diff --git a/host/herdr-push/README.md b/host/herdr-push/README.md index 5363322e..427a75fc 100644 --- a/host/herdr-push/README.md +++ b/host/herdr-push/README.md @@ -36,6 +36,21 @@ A machine that is offline is set up the next time the app connects to it. Machin The app also **looks at its machines itself** while it is closed (every 15 minutes by default; Settings → Notifications → The app also looks), with an exact alarm. That covers a machine without python3, or a push that got lost. Pushes arrive within seconds; the app's own looks only as often as you set. +## On Windows + +herdr-push runs on a Windows PC with Git Bash under OpenSSH Server (see the README's "A Windows machine"), with Python 3.6 or newer. The app installs it as it does elsewhere; by hand, `python herdr-push install` does the same. + +It starts without administrator rights, in two ways: + +- A `Run` value under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, `"" "