Devices: fixes from review round 3

- Attempts carry a generation: one overtaken by a switch, a removal or a login
  change routes nothing back to the old headset and can't report connected.
- Removing the active headset or changing its user/port reroutes at once,
  before anything that can fail.
- One known_hosts file per headset (~/.ssh/frame-control-hosts/<id>):
  forgetting one headset's key can't drop another's, whoever else writes.
- learn() checks, under the config lock, that the block is still what the
  attempt started from before writing to it.
- A switch stops live video and drops captures from the previous headset.
- A probe shares its time between the addresses a name resolves to.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 21:48:39 +10:00
1 parent 18334b5989
commit cb182dbce5
6 files changed
+187 -122

No files matched your search

+12 -7
View File
@@ -24,8 +24,8 @@ work for each one.
- **Nothing to migrate by hand.** On first start, the app imports every - **Nothing to migrate by hand.** On first start, the app imports every
`# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with `# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
the block's HostName as its first address. It also copies the host key your the block's HostName as its first address. It also copies the host key your
`known_hosts` already trusts for that address into the app's own `known_hosts` already trusts for that address into the headset's own
`~/.ssh/frame-control_known_hosts`, so nobody is asked to trust it again. known_hosts file, `~/.ssh/frame-control-hosts/<id>`, so nobody is asked to trust it again.
- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS, - **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
`ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias. `ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
When it writes its block, the app picks the headset up by itself. If Set Up When it writes its block, the app picks the headset up by itself. If Set Up
@@ -33,7 +33,9 @@ work for each one.
at the top of its list. at the top of its list.
- **Use this headset** (or the switcher in the header, or the app's - **Use this headset** (or the switcher in the header, or the app's
**Frame → Headset** menu) moves the whole app to another headset; every panel **Frame → Headset** menu) moves the whole app to another headset; every panel
reloads from it. reloads from it. From that moment no command goes to the previous headset, even
if the new one never answers. It waits while an install is running, since an
install reads the SSH settings step by step.
- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick - **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
the box; either way it isn't imported again unless Set Up Connection changes it. the box; either way it isn't imported again unless Set Up Connection changes it.
- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the - A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
@@ -106,15 +108,18 @@ headset makes the connector start again.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName=<address> -o HostKeyAlias=frame-control-<id> `-o HostName=<address> -o HostKeyAlias=frame-control-<id>
-o UserKnownHostsFile=~/.ssh/frame-control_known_hosts -o User=… -o Port=…`. The -o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -o HashKnownHosts=no -o User=… -o Port=…`. The
alias's block in `~/.ssh/config` is also updated to the last address that alias's block in `~/.ssh/config` is also updated to the last address that
worked (and to the user and port you set), so Terminal's `ssh frame` and the worked (and to the user and port you set), so Terminal's `ssh frame` and the
scripts follow. scripts follow. Edits to `~/.ssh/config` take a lock file
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
write over a change someone else made since the app last read the file.
**Host keys are pinned per headset, not per address.** Your own `known_hosts` **Host keys are pinned per headset, not per address.** Your own `known_hosts`
is keyed by address, so a different device answering at a remembered IP (a DHCP is keyed by address, so a different device answering at a remembered IP (a DHCP
lease that moved) would look like a new host there. The app keys its own lease that moved) would look like a new host there. The app keeps one known_hosts
known_hosts by headset instead: a different device answering at one of its file per headset instead, so saving or forgetting one headset's key never touches
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does. After reinstalling SteamOS the the key it shows, as Set Up Connection does. After reinstalling SteamOS the
headset has a new key; **Forget identity** on the Devices tab lets the next headset has a new key; **Forget identity** on the Devices tab lets the next
+28 -8
View File
@@ -181,6 +181,14 @@ class ConfigRewrite(Base):
self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"]) self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"])
self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left
def test_learning_skips_a_block_someone_changed(self):
# The connector learned an address, but Set Up Connection moved the block meanwhile.
self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9",
expect={"hostname": "old.example", "user": None, "port": None}))
self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text())
self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9",
expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22}))
def test_zone_is_escaped_and_read_back(self): def test_zone_is_escaped_and_read_back(self):
fd.rewrite_block("frame", hostname="fe80::1%en0") fd.rewrite_block("frame", hostname="fe80::1%en0")
self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text()) self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text())
@@ -200,12 +208,21 @@ class Pins(Base):
self.assertFalse(fd.pinned("d1")) self.assertFalse(fd.pinned("d1"))
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"]))
self.assertTrue(fd.pinned("d1")) self.assertTrue(fd.pinned("d1"))
self.assertEqual(fd.known_hosts().read_text(), f"frame-control-d1 {KEY}\n") self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {KEY}\n")
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent
self.assertEqual(fd.known_hosts().read_text().count("\n"), 1) self.assertEqual(fd.known_hosts("d1").read_text().count("\n"), 1)
self.assertFalse(fd.seed_pin("d2", ["never-seen.example"])) self.assertFalse(fd.seed_pin("d2", ["never-seen.example"]))
self.assertTrue(fd.forget_pin("d1")) self.assertTrue(fd.forget_pin("d1"))
self.assertFalse(fd.pinned("d1")) self.assertFalse(fd.pinned("d1"))
self.assertFalse(fd.forget_pin("d1"))
def test_each_headset_has_its_own_file(self):
(self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n")
fd.seed_pin("da", ["a.local"])
fd.seed_pin("db", ["b.local"])
fd.forget_pin("da")
self.assertTrue(fd.pinned("db")) # forgetting one can't touch another
self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db"))
def test_hashed_and_non_default_port_entries(self): def test_hashed_and_non_default_port_entries(self):
kh = self.ssh / "known_hosts" kh = self.ssh / "known_hosts"
@@ -213,19 +230,22 @@ class Pins(Base):
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True) subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222)) self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts().read_text()) self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
def test_hashed_pins_are_found_and_forgotten(self): def test_hashed_pins_are_found_and_forgotten(self):
fd.known_hosts().write_text(f"frame-control-d4 {KEY}\n") target = fd.known_hosts("d4")
subprocess.run(["ssh-keygen", "-H", "-f", str(fd.known_hosts())], capture_output=True, check=True) target.parent.mkdir(parents=True, exist_ok=True)
self.assertNotIn("frame-control-d4", fd.known_hosts().read_text()) target.write_text(f"frame-control-d4 {KEY}\n")
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4")) self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4")) self.assertTrue(fd.forget_pin("d4"))
self.assertFalse(fd.pinned("d4")) self.assertFalse(fd.pinned("d4"))
self.assertFalse(fd.known_hosts().with_name("frame-control_known_hosts.old").exists())
def test_known_hosts_option_uses_the_override(self): def test_known_hosts_option_uses_the_override(self):
self.assertEqual(fd.known_hosts_opt(), str(self.ssh / "frame-control_known_hosts")) self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5"))
os.environ.pop("FRAME_CONTROL_SSH_DIR")
self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on
class Registry(Base): class Registry(Base):
+25 -2
View File
@@ -118,6 +118,10 @@ class Connecting(unittest.TestCase):
explain=explain) explain=explain)
self.addCleanup(self.link.stop) self.addCleanup(self.link.stop)
def pin(self, device_id):
fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True)
fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n")
def hosts(self, mapping): def hosts(self, mapping):
os.environ["FAKESSH_HOSTS"] = json.dumps(mapping) os.environ["FAKESSH_HOSTS"] = json.dumps(mapping)
@@ -199,7 +203,7 @@ class Connecting(unittest.TestCase):
def test_pinned_identity_is_checked_strictly(self): def test_pinned_identity_is_checked_strictly(self):
d = self.device("localhost") d = self.device("localhost")
(self.dir / "ssh" / "frame-control_known_hosts").write_text(f"frame-control-{d['id']} ssh-ed25519 AAAA\n") self.pin(d["id"])
self.hosts({"localhost": "ok"}) self.hosts({"localhost": "ok"})
self.link.connect(["start"]) self.link.connect(["start"])
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1] master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
@@ -235,7 +239,7 @@ class Connecting(unittest.TestCase):
def test_test_now_checks_every_address_without_touching_the_connection(self): def test_test_now_checks_every_address_without_touching_the_connection(self):
d = self.device("127.0.0.1", "localhost", "nothing.invalid") d = self.device("127.0.0.1", "localhost", "nothing.invalid")
(self.dir / "ssh" / "frame-control_known_hosts").write_text(f"frame-control-{d['id']} ssh-ed25519 AAAA\n") self.pin(d["id"])
self.hosts({"127.0.0.1": "wrong", "localhost": "ok"}) self.hosts({"127.0.0.1": "wrong", "localhost": "ok"})
self.link.test(d["id"]) self.link.test(d["id"])
rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]} rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]}
@@ -262,6 +266,25 @@ class Connecting(unittest.TestCase):
self.assertIn("HostName=nothing.invalid", opts) self.assertIn("HostName=nothing.invalid", opts)
self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts) self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts)
def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
other = self.reg.add_device("frame-other", port=self.port)
self.reg.add_address(other["id"], "nothing.invalid")
pick = fl.Link.pick
def switch_then_pick(*args):
if not getattr(self, "switched", False):
self.switched = True
self.link.use(other["id"]) # the user switches while A is being found
return pick(*args)
with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)):
self.link.connect(["start"])
self.assertEqual(self.routes[-1][0], "frame-other")
self.assertEqual(self.link.snapshot()["phase"], "connecting")
self.assertFalse(self.link.alive())
self.assertIsNone(self.link.master)
def test_no_switching_while_something_is_installing(self): def test_no_switching_while_something_is_installing(self):
d = self.device("localhost") d = self.device("localhost")
other = self.reg.add_device("frame-other") other = self.reg.add_device("frame-other")
+56 -76
View File
@@ -22,8 +22,9 @@ scripts go on working, and the connector rewrites the block's HostName to the
last address that worked, so they follow it. last address that worked, so they follow it.
Host keys are pinned per headset, not per address: ssh gets Host keys are pinned per headset, not per address: ssh gets
`-o HostKeyAlias=frame-control-<id>` and a known_hosts file of our own, so a `-o HostKeyAlias=frame-control-<id>` and a known_hosts file of the headset's own
different device answering at a remembered IP is caught. (~/.ssh/frame-control-hosts/<id>), so a different device answering at a
remembered IP is caught.
Python stdlib only. Python stdlib only.
""" """
@@ -66,14 +67,21 @@ def ssh_config():
return ssh_dir() / "config" return ssh_dir() / "config"
def known_hosts(): PIN_DIR = "frame-control-hosts"
return ssh_dir() / "frame-control_known_hosts"
def known_hosts_opt(): def known_hosts(device_id):
"""How ssh is told about our known_hosts file. `~` rather than the full path when it's """The headset's own known_hosts file: one per headset, so saving or forgetting one
the usual place, so a home folder with a space in its name can't split the option.""" headset's key (by ssh or by the app) can never touch another's."""
return "~/.ssh/frame-control_known_hosts" if not os.environ.get("FRAME_CONTROL_SSH_DIR") else str(known_hosts()) return ssh_dir() / PIN_DIR / device_id
def known_hosts_opt(device_id):
"""How ssh is told about it. `~` rather than the full path when it's the usual
place, so a home folder with a space in its name can't split the option."""
if os.environ.get("FRAME_CONTROL_SSH_DIR"):
return str(known_hosts(device_id))
return f"~/.ssh/{PIN_DIR}/{device_id}"
def host_key_alias(device_id): def host_key_alias(device_id):
@@ -262,11 +270,18 @@ def _edit_config(path, change):
raise OSError(f"{path} kept changing while Frame Control tried to update it") raise OSError(f"{path} kept changing while Frame Control tried to update it")
def rewrite_block(alias, path=None, hostname=None, user=None, port=None): def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the """Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
file alone. -> True if the file changed. Does nothing if there's no such block.""" file alone. -> True if the file changed. Does nothing if there's no such block, or
return _edit_config(Path(path or ssh_config()), if `expect` ({"hostname", "user", "port"}; None values match anything) no longer
lambda lines: _rewritten(lines, alias, hostname, user, port)) describes the block, checked under the lock: someone else changed it meanwhile."""
def change(lines):
if expect:
block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
if not block or any(v is not None and block[k] != v for k, v in expect.items()):
return None
return _rewritten(lines, alias, hostname, user, port)
return _edit_config(Path(path or ssh_config()), change)
def _rewritten(lines, alias, hostname, user, port): def _rewritten(lines, alias, hostname, user, port):
@@ -308,13 +323,6 @@ def remove_block(alias, path=None):
# ---- pinned host keys ------------------------------------------------------------- # ---- pinned host keys -------------------------------------------------------------
def _pin_lines(path=None):
try:
return Path(path or known_hosts()).read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return []
def _keygen(*args): def _keygen(*args):
try: try:
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
@@ -323,29 +331,27 @@ def _keygen(*args):
return None return None
def _pin_lock(target): def pinned(device_id):
return file_lock(target.with_name(target.name + ".lock")) """Whether a key is saved for the headset. Entries are plain text (ssh gets
HashKnownHosts=no), but ask ssh-keygen too in case one was hashed."""
target = known_hosts(device_id)
def pinned(device_id, path=None): try:
"""Whether a key is saved for the device. The app's own entries are plain text (it lines = target.read_text(encoding="utf-8").splitlines()
passes HashKnownHosts=no), but ask ssh-keygen too in case one was hashed.""" except (OSError, UnicodeDecodeError):
return False
name = host_key_alias(device_id) name = host_key_alias(device_id)
target = Path(path or known_hosts()) if any(line.split(None, 1)[0].split(",").count(name) for line in lines
if any(line.split(None, 1)[0].split(",").count(name) for line in _pin_lines(target)
if line.strip() and not line.startswith("#")): if line.strip() and not line.startswith("#")):
return True return True
if not target.is_file():
return False
r = _keygen("-F", name, "-f", str(target)) r = _keygen("-F", name, "-f", str(target))
return bool(r and r.returncode == 0 and r.stdout.strip()) return bool(r and r.returncode == 0 and r.stdout.strip())
def seed_pin(device_id, hosts, port=22, sources=None, path=None): def seed_pin(device_id, hosts, port=22, sources=None):
"""Copy the host keys ssh already trusts for one of `hosts` into our file under the """Copy the host keys ssh already trusts for one of `hosts` into the headset's file
device's alias, so moving to per-device pinning asks nobody to trust anything again. under its alias, so moving to per-headset pinning asks nobody to trust anything again.
-> True if a key was pinned.""" -> True if a key is pinned."""
if pinned(device_id, path): if pinned(device_id):
return True return True
sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"] sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"]
name = host_key_alias(device_id) name = host_key_alias(device_id)
@@ -355,56 +361,30 @@ def seed_pin(device_id, hosts, port=22, sources=None, path=None):
for src in sources: for src in sources:
if not Path(src).is_file(): if not Path(src).is_file():
continue continue
try: r = _keygen("-F", wanted, "-f", str(src))
out = subprocess.run(["ssh-keygen", "-F", wanted, "-f", str(src)], capture_output=True, for line in (r.stdout if r else "").splitlines():
stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
continue
for line in out.splitlines():
f = line.split() f = line.split()
if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"): if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
keys.append(f"{name} {f[1]} {f[2]}") keys.append(f"{name} {f[1]} {f[2]}")
if keys: if keys:
target = Path(path or known_hosts()) target = known_hosts(device_id)
with _pin_lock(target): target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
with open(target, "a", encoding="utf-8") as fh: fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
fh.write("\n".join(dict.fromkeys(keys)) + "\n") with os.fdopen(fd_, "w", encoding="utf-8") as fh:
if not frame_host.WINDOWS: fh.write("\n".join(dict.fromkeys(keys)) + "\n")
target.chmod(0o600) os.replace(tmp, target) # whole file at once: ssh never sees half of it
return True return True
return False return False
def forget_pin(device_id, path=None): def forget_pin(device_id):
"""Drop a device's pinned keys, e.g. after SteamOS was reinstalled. The next connection """Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection
trusts whatever key the headset shows, as a first connection does.""" trusts whatever key the headset shows, as a first connection does."""
target = Path(path or known_hosts()) try:
name = host_key_alias(device_id) known_hosts(device_id).unlink()
with _pin_lock(target): return True
# ssh itself may append a first-seen key meanwhile (accept-new): swap the file except FileNotFoundError:
# only if it still holds what was read, else read it again. return False
removed = False
for _ in range(5):
text = "\n".join(_pin_lines(target))
lines = text.splitlines()
kept = [line for line in lines if not (line.strip() and name in line.split(None, 1)[0].split(","))]
if kept == lines:
break
fd_, tmp = tempfile.mkstemp(prefix=target.name + ".", dir=str(target.parent))
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write("".join(line + "\n" for line in kept))
if "\n".join(_pin_lines(target)) == text:
os.replace(tmp, target)
removed = True
break
os.unlink(tmp)
if target.is_file() and pinned(device_id, target): # a hashed entry: ssh-keygen finds it
r = _keygen("-R", name, "-f", str(target))
removed = removed or bool(r and r.returncode == 0)
old = target.with_name(target.name + ".old") # ssh-keygen -R leaves a backup
if old.exists():
old.unlink()
return removed
# ---- address order -------------------------------------------------------------------- # ---- address order --------------------------------------------------------------------
+60 -29
View File
@@ -98,14 +98,17 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
except (socket.gaierror, UnicodeError, OSError) as e: except (socket.gaierror, UnicodeError, OSError) as e:
return {"state": "unresolved", "detail": "Can't find this name on the network", "error": str(e)} return {"state": "unresolved", "detail": "Can't find this name on the network", "error": str(e)}
last = None last = None
for family, kind, proto, _, addr in infos[:4]: infos = infos[:4]
for n, (family, kind, proto, _, addr) in enumerate(infos):
ip = addr[0] ip = addr[0]
left = deadline - now() left = deadline - now()
if left <= 0: if left <= 0:
break break
update(state="trying", detail=f"Trying {ip}", ip=ip) update(state="trying", detail=f"Trying {ip}", ip=ip)
s = socket.socket(family, kind, proto) s = socket.socket(family, kind, proto)
s.settimeout(left) # Share the time out, so one address that never answers (a dead IPv6 route,
# say) leaves the others their turn.
s.settimeout(left / (len(infos) - n))
t0 = time.monotonic() t0 = time.monotonic()
try: try:
s.connect(addr) s.connect(addr)
@@ -154,7 +157,10 @@ class Link:
self.last_attempt = 0 self.last_attempt = 0
self.config_mtime = None self.config_mtime = None
self.thread = None self.thread = None
self.attempt_gen = 0
self.pending = None # an ssh handshake still running self.pending = None # an ssh handshake still running
self.gen = 0 # bumped when the headset or its login changes: older attempts are void
self.route_lock = threading.Lock()
self.routed = None # the device id every ssh command points at self.routed = None # the device id every ssh command points at
# ---- publishing ---- # ---- publishing ----
@@ -227,7 +233,7 @@ class Link:
self.close_master() self.close_master()
def alive(self): def alive(self):
if self.state["phase"] != "connected": if self.state["phase"] != "connected" or self.kicks:
return False return False
if not self.control: if not self.control:
return True return True
@@ -248,12 +254,20 @@ class Link:
self.state["phase"] != "connecting"), wait) self.state["phase"] != "connecting"), wait)
def use(self, device_id): def use(self, device_id):
"""Switch to another headset. Commands go to it from now on (never the last one), """Switch to another headset."""
and wait in ensure() for the connector to reach it."""
self.reg.set_active(device_id) self.reg.set_active(device_id)
self.override = None self.override = None
self.invalidate()
def invalidate(self):
"""The headset in use, or how to log in to it, changed: from now on commands go to
the one now selected (never the last one), any attempt still running is void,
and ensure() waits for the connector to reach it."""
device = self.active_device() device = self.active_device()
self.apply(device["alias"], self.first_route(device)) with self.route_lock:
self.gen += 1
self.apply(device["alias"], self.first_route(device))
self.routed = None # the next attempt routes again
with self.cond: with self.cond:
self.state["phase"] = "connecting" self.state["phase"] = "connecting"
self.kicks.append("switch") self.kicks.append("switch")
@@ -301,7 +315,7 @@ class Link:
return [] return []
return ["-o", f"HostName={frame_devices.ssh_host(host)}", return ["-o", f"HostName={frame_devices.ssh_host(host)}",
"-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}", "-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}",
"-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt()}", "-o", "HashKnownHosts=no", "-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt(device['id'])}", "-o", "HashKnownHosts=no",
"-o", f"User={device['user']}", "-o", f"Port={device['port']}"] "-o", f"User={device['user']}", "-o", f"Port={device['port']}"]
def public_device(self, d): def public_device(self, d):
@@ -361,7 +375,7 @@ class Link:
self.devices_changed() self.devices_changed()
after = self.active_device() after = self.active_device()
if (before.get("user"), before.get("port")) != (after.get("user"), after.get("port")): if (before.get("user"), before.get("port")) != (after.get("user"), after.get("port")):
self.kick("switch") # Set Up Connection changed the active headset's login self.invalidate() # Set Up Connection changed the active headset's login
def refresh_network(self): def refresh_network(self):
net = frame_network.current_network(self.last_fp) net = frame_network.current_network(self.last_fp)
@@ -374,13 +388,15 @@ class Link:
why = self.describe(reasons) why = self.describe(reasons)
self.last_attempt = now() self.last_attempt = now()
self.close_master() self.close_master()
device = self.active_device() with self.route_lock:
if self.route_key(device) != self.routed: gen = self.attempt_gen = self.gen
# Another headset, or a new user or port: nothing may go on using the old device = self.active_device()
# route, even if this attempt fails. if self.route_key(device) != self.routed:
self.alias, self.opts = device["alias"], self.first_route(device) # Another headset, or a new user or port: nothing may go on using the old
self.apply(self.alias, self.opts) # route, even if this attempt fails.
self.routed = self.route_key(device) self.alias, self.opts = device["alias"], self.first_route(device)
self.apply(self.alias, self.opts)
self.routed = self.route_key(device)
with self.cond: with self.cond:
self.state.update(phase="connecting", reason=why, device=self.public_device(device), via=None, self.state.update(phase="connecting", reason=why, device=self.public_device(device), via=None,
error=None, retry_at=None, attempt=self.state["attempt"] + 1, started=now(), error=None, retry_at=None, attempt=self.state["attempt"] + 1, started=now(),
@@ -393,6 +409,17 @@ class Link:
try: try:
ok = self.attempt(device) ok = self.attempt(device)
finally: finally:
with self.cond:
if gen != self.gen:
# The headset changed meanwhile: this attempt's result is about the
# old one. Leave "connecting"; the queued switch starts the next.
self.state["phase"] = "connecting"
self.version += 1
self.cond.notify_all()
ok = None
if ok is None:
self.close_master()
return
with self.cond: with self.cond:
self.state["finished"] = now() self.state["finished"] = now()
if ok: if ok:
@@ -574,16 +601,16 @@ class Link:
return return
if self.route_key(now_dev) != self.route_key(device): if self.route_key(now_dev) != self.route_key(device):
return return
# Terminal's `ssh ALIAS` and the helper scripts use ~/.ssh/config: point it here too. # Terminal's `ssh ALIAS` and the helper scripts use ~/.ssh/config: point it here too,
# unless the block changed since this attempt began (checked under the file lock).
login = device.get("config_login") or [None, None]
expect = {"hostname": device.get("config_host"), "user": login[0], "port": login[1]}
try: try:
block = next((b for b in frame_devices.parse_blocks(frame_devices.read_config()) if frame_devices.rewrite_block(device["alias"], hostname=host, user=device["user"],
if b["alias"] == device["alias"]), None) port=device["port"], expect=expect):
moved = block and block["hostname"] != device.get("config_host") and device.get("config_host")
if not moved and frame_devices.rewrite_block(device["alias"], hostname=host, user=device["user"],
port=device["port"]):
self.config_mtime = frame_devices.ssh_config().stat().st_mtime self.config_mtime = frame_devices.ssh_config().stat().st_mtime
if block and not moved:
self.reg.set_config_host(device["id"], host) self.reg.set_config_host(device["id"], host)
self.reg.sync_from_config() # records the login it now holds
except OSError: except OSError:
pass # not fatal: the app itself doesn't need the file pass # not fatal: the app itself doesn't need the file
self.devices_changed() self.devices_changed()
@@ -622,8 +649,11 @@ class Link:
-> "ok", "next" (try another address) or "stop".""" -> "ok", "next" (try another address) or "stop"."""
opts = self.host_opts(device, a["host"]) opts = self.host_opts(device, a["host"])
alias = device["alias"] alias = device["alias"]
self.alias, self.opts = alias, opts with self.route_lock:
self.apply(alias, opts) if self.attempt_gen != self.gen:
return "stop" # the headset changed: don't route anything back to this one
self.alias, self.opts = alias, opts
self.apply(alias, opts)
target = f"{a['host']}" + (f" ({found['ip']})" if found.get("ip") and found["ip"] != a["host"] else "") target = f"{a['host']}" + (f" ({found['ip']})" if found.get("ip") and found["ip"] != a["host"] else "")
self.stage("ssh", "active", f"Opening SSH to {target}") self.stage("ssh", "active", f"Opening SSH to {target}")
if self.control and self.check(opts, alias): if self.control and self.check(opts, alias):
@@ -850,16 +880,20 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
link.use(did) link.use(did)
msg = f"Switched to {d['name']}" msg = f"Switched to {d['name']}"
elif action == "update": elif action == "update":
before = reg.get(did)
d = reg.update_device(did, name=body.get("name"), user=body.get("user"), port=body.get("port")) d = reg.update_device(did, name=body.get("name"), user=body.get("user"), port=body.get("port"))
if is_active and (d["user"], d["port"]) != (before["user"], before["port"]):
link.invalidate() # before anything else can fail: the old login mustn't stay in use
try: try:
frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"]) frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"])
except OSError as e: except OSError as e:
raise frame_devices.DeviceError(f"Saved, but couldn't update ~/.ssh/config: {e}") raise frame_devices.DeviceError(f"Saved, but couldn't update ~/.ssh/config: {e}")
if is_active:
link.kick("switch")
msg = f"Saved {d['name']}" msg = f"Saved {d['name']}"
elif action == "remove": elif action == "remove":
d = reg.remove_device(did) d = reg.remove_device(did)
if is_active:
link.override = None
link.invalidate()
frame_devices.forget_pin(did) frame_devices.forget_pin(did)
removed = False removed = False
if body.get("config"): if body.get("config"):
@@ -867,9 +901,6 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
removed = frame_devices.remove_block(d["alias"]) removed = frame_devices.remove_block(d["alias"])
except OSError as e: except OSError as e:
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}") raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
if is_active:
link.override = None
link.kick("switch")
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "") msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
elif action == "address-add": elif action == "address-add":
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "") a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
+6
View File
@@ -2533,6 +2533,12 @@ function onConnection(s) {
online = null; online = null;
devGen++; // answers still on their way are about the other headset devGen++; // answers still on their way are about the other headset
refreshing = null; // and the next status check must be a new one refreshing = null; // and the next status check must be a new one
if (live) toggleLive(false); // the other headset's video or captures
viewGen++; // a capture still on its way is the other headset's too
lastImg = null; lastShot = null;
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
$("saveBtn").disabled = true;
link.reload = true; // everything on the page was the other headset's link.reload = true; // everything on the page was the other headset's
$("battChip").hidden = true; $("battChip").hidden = true;
} }