diff --git a/tests/test_link.py b/tests/test_link.py index b43a2eb..116b46b 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -344,6 +344,15 @@ class Connecting(unittest.TestCase): self.assertEqual(self.link.snapshot()["phase"], "connected") self.assertEqual(self.link.named_route(), ("frame-bare", pinned)) # terminals go there too + def test_a_set_up_headset_behind_a_jump_host_is_left_to_ssh(self): + d = self.device("10.99.99.98", "10.99.99.99") # neither answers directly + self.hosts({"10.99.99.98": "wrong", "10.99.99.99": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)): + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["via"]["host"]), ("connected", "10.99.99.99"), s["error"]) + self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", self.routes[-1][1]) # still pinned per headset + def test_a_bare_alias_behind_a_jump_host_is_left_to_ssh(self): self.link.override = "frame-jump" self.hosts({"10.99.99.99": "ok"}) # ssh's ProxyJump would get there diff --git a/ui/frame_link.py b/ui/frame_link.py index 664dd42..f13c86a 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -584,6 +584,31 @@ class Link: ranked = [(a, "from ~/.ssh/config")] else: ranked = frame_devices.order_addresses(device["addresses"], net.get("id"), bool(ts.get("up"))) + if ssh_g(device["alias"])[3]: + # ~/.ssh/config sends this alias through a jump host: a direct probe says + # nothing, so let ssh (through the jump host) try each address in turn. + with self.cond: + self.state["probes"] = [dict(a, why=why, state="waiting", detail="Through a jump host", ip=None, + rtt_ms=None, label=a.get("label") or "") for a, why in ranked] + self.stage("find", "done", f"{device['alias']} goes through a jump host; ssh finds it") + for i, (a, why) in enumerate(ranked): + if i: + for sid in ("ssh", "identity", "login"): + self.stage(sid, "pending", "") + outcome = self.handshake(device, a, {"ip": None, "rtt_ms": None}, device.get("user")) + if outcome == "ok": + self.probe_update(i, state="answered", detail="Reached through the jump host") + self.publish(via={"host": a["host"], "kind": a["kind"], "ip": None, "rtt_ms": None, + "why": "through a jump host", "network": net.get("id"), + "network_name": net["name"]}) + self.learn(device, a["host"], net, None) + return True + with self.cond: + why_not = (self.state["error"] or {}).get("message") or "SSH failed" + self.probe_update(i, state="sshfailed", detail=why_not) + if outcome != "next": + return False + return False with self.cond: self.state["probes"] = [{"host": a["host"], "kind": a["kind"], "label": a.get("label") or "", "why": why, "state": "waiting", "detail": "Waiting", "ip": None, diff --git a/ui/index.html b/ui/index.html index 23dace1..5492f8a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2677,7 +2677,11 @@ async function useDevice(id) { const d = dv.list.find(x => x.id === id); if (!d || d.active) return; devGen++; // from here on, answers about the last headset are dropped - await act(`Switch to ${d.name}`, () => api("/api/devices", { action: "use", id })); + const res = await act(`Switch to ${d.name}`, () => api("/api/devices", { action: "use", id })); + if (!res) { // refused (an install is running): show the headset still in use + const current = dv.list.find(x => x.active); + if (current) $("devSel").value = current.id; + } } async function devAction(label, body, btn) { const res = await act(label, () => api("/api/devices", body), btn);