Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -81,6 +82,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: 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).

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ data class HcProbe(
val coreExes: List<String> = emptyList(),
/** The other sessions whose core runs , by name. */
val others: List<HcSession> = 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
Expand Down Expand Up @@ -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`, `core<TAB>exe<TAB>pid` and `bin<TAB>exe<TAB>--version<TAB>core`
* 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 2>/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 2>/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; " +
Expand Down Expand Up @@ -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)
}

/**
* `<binary> 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 >/dev/null 2>&1 || { echo 'herdr-core stopped' >&2; exit 3; }; exec \"\$1\" connect")} hc ${quote(binary.path)}"

/**
* `<binary> 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
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions core/src/test/kotlin/com/makefunstuff/herdr/core/HerdrCoreTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 >/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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ class HerdrPushTest {
@Test
fun bundledScriptIsTheOneInHost() {
assertEquals(script.readText(), HerdrPush.script)
assertEquals("11", HerdrPush.version)
assertEquals("12", HerdrPush.version)
}

@Test
Expand Down
2 changes: 2 additions & 0 deletions host/herdr-push/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<token>.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
Expand Down
Loading
Loading