mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
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:
1 parent
18334b5989
commit
cb182dbce5
6 files changed
+176
-111
No files matched your search
+12
-7
@@ -24,8 +24,8 @@ work for each one.
|
||||
- **Nothing to migrate by hand.** On first start, the app imports every
|
||||
`# >>> 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
|
||||
`known_hosts` already trusts for that address into the app's own
|
||||
`~/.ssh/frame-control_known_hosts`, so nobody is asked to trust it again.
|
||||
`known_hosts` already trusts for that address into the headset's own
|
||||
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,
|
||||
`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
|
||||
@@ -33,7 +33,9 @@ work for each one.
|
||||
at the top of its list.
|
||||
- **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
|
||||
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
|
||||
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
|
||||
@@ -106,15 +108,18 @@ headset makes the connector start again.
|
||||
|
||||
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
|
||||
`-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
|
||||
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`
|
||||
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
|
||||
known_hosts by headset instead: a different device answering at one of its
|
||||
lease that moved) would look like a new host there. The app keeps one known_hosts
|
||||
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
|
||||
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
|
||||
|
||||
+28
-8
@@ -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([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):
|
||||
fd.rewrite_block("frame", hostname="fe80::1%en0")
|
||||
self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text())
|
||||
@@ -200,12 +208,21 @@ class Pins(Base):
|
||||
self.assertFalse(fd.pinned("d1"))
|
||||
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"]))
|
||||
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.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.assertTrue(fd.forget_pin("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):
|
||||
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)
|
||||
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
||||
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):
|
||||
fd.known_hosts().write_text(f"frame-control-d4 {KEY}\n")
|
||||
subprocess.run(["ssh-keygen", "-H", "-f", str(fd.known_hosts())], capture_output=True, check=True)
|
||||
self.assertNotIn("frame-control-d4", fd.known_hosts().read_text())
|
||||
target = fd.known_hosts("d4")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
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.forget_pin("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):
|
||||
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):
|
||||
|
||||
+25
-2
@@ -118,6 +118,10 @@ class Connecting(unittest.TestCase):
|
||||
explain=explain)
|
||||
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):
|
||||
os.environ["FAKESSH_HOSTS"] = json.dumps(mapping)
|
||||
|
||||
@@ -199,7 +203,7 @@ class Connecting(unittest.TestCase):
|
||||
|
||||
def test_pinned_identity_is_checked_strictly(self):
|
||||
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.link.connect(["start"])
|
||||
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):
|
||||
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.link.test(d["id"])
|
||||
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(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):
|
||||
d = self.device("localhost")
|
||||
other = self.reg.add_device("frame-other")
|
||||
|
||||
+55
-75
@@ -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.
|
||||
|
||||
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
|
||||
different device answering at a remembered IP is caught.
|
||||
`-o HostKeyAlias=frame-control-<id>` and a known_hosts file of the headset's own
|
||||
(~/.ssh/frame-control-hosts/<id>), so a different device answering at a
|
||||
remembered IP is caught.
|
||||
|
||||
Python stdlib only.
|
||||
"""
|
||||
@@ -66,14 +67,21 @@ def ssh_config():
|
||||
return ssh_dir() / "config"
|
||||
|
||||
|
||||
def known_hosts():
|
||||
return ssh_dir() / "frame-control_known_hosts"
|
||||
PIN_DIR = "frame-control-hosts"
|
||||
|
||||
|
||||
def known_hosts_opt():
|
||||
"""How ssh is told about our known_hosts file. `~` 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."""
|
||||
return "~/.ssh/frame-control_known_hosts" if not os.environ.get("FRAME_CONTROL_SSH_DIR") else str(known_hosts())
|
||||
def known_hosts(device_id):
|
||||
"""The headset's own known_hosts file: one per headset, so saving or forgetting one
|
||||
headset's key (by ssh or by the app) can never touch another's."""
|
||||
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):
|
||||
@@ -262,11 +270,18 @@ def _edit_config(path, change):
|
||||
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
|
||||
file alone. -> True if the file changed. Does nothing if there's no such block."""
|
||||
return _edit_config(Path(path or ssh_config()),
|
||||
lambda lines: _rewritten(lines, alias, hostname, user, port))
|
||||
file alone. -> True if the file changed. Does nothing if there's no such block, or
|
||||
if `expect` ({"hostname", "user", "port"}; None values match anything) no longer
|
||||
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):
|
||||
@@ -308,13 +323,6 @@ def remove_block(alias, path=None):
|
||||
|
||||
# ---- 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):
|
||||
try:
|
||||
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
@@ -323,29 +331,27 @@ def _keygen(*args):
|
||||
return None
|
||||
|
||||
|
||||
def _pin_lock(target):
|
||||
return file_lock(target.with_name(target.name + ".lock"))
|
||||
|
||||
|
||||
def pinned(device_id, path=None):
|
||||
"""Whether a key is saved for the device. The app's own entries are plain text (it
|
||||
passes HashKnownHosts=no), but ask ssh-keygen too in case one was hashed."""
|
||||
def pinned(device_id):
|
||||
"""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)
|
||||
try:
|
||||
lines = target.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return False
|
||||
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 _pin_lines(target)
|
||||
if any(line.split(None, 1)[0].split(",").count(name) for line in lines
|
||||
if line.strip() and not line.startswith("#")):
|
||||
return True
|
||||
if not target.is_file():
|
||||
return False
|
||||
r = _keygen("-F", name, "-f", str(target))
|
||||
return bool(r and r.returncode == 0 and r.stdout.strip())
|
||||
|
||||
|
||||
def seed_pin(device_id, hosts, port=22, sources=None, path=None):
|
||||
"""Copy the host keys ssh already trusts for one of `hosts` into our file under the
|
||||
device's alias, so moving to per-device pinning asks nobody to trust anything again.
|
||||
-> True if a key was pinned."""
|
||||
if pinned(device_id, path):
|
||||
def seed_pin(device_id, hosts, port=22, sources=None):
|
||||
"""Copy the host keys ssh already trusts for one of `hosts` into the headset's file
|
||||
under its alias, so moving to per-headset pinning asks nobody to trust anything again.
|
||||
-> True if a key is pinned."""
|
||||
if pinned(device_id):
|
||||
return True
|
||||
sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"]
|
||||
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:
|
||||
if not Path(src).is_file():
|
||||
continue
|
||||
try:
|
||||
out = subprocess.run(["ssh-keygen", "-F", wanted, "-f", str(src)], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
for line in out.splitlines():
|
||||
r = _keygen("-F", wanted, "-f", str(src))
|
||||
for line in (r.stdout if r else "").splitlines():
|
||||
f = line.split()
|
||||
if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
|
||||
keys.append(f"{name} {f[1]} {f[2]}")
|
||||
if keys:
|
||||
target = Path(path or known_hosts())
|
||||
with _pin_lock(target):
|
||||
with open(target, "a", encoding="utf-8") as fh:
|
||||
target = known_hosts(device_id)
|
||||
target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
|
||||
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
|
||||
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(dict.fromkeys(keys)) + "\n")
|
||||
if not frame_host.WINDOWS:
|
||||
target.chmod(0o600)
|
||||
os.replace(tmp, target) # whole file at once: ssh never sees half of it
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def forget_pin(device_id, path=None):
|
||||
"""Drop a device's pinned keys, e.g. after SteamOS was reinstalled. The next connection
|
||||
def forget_pin(device_id):
|
||||
"""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."""
|
||||
target = Path(path or known_hosts())
|
||||
name = host_key_alias(device_id)
|
||||
with _pin_lock(target):
|
||||
# ssh itself may append a first-seen key meanwhile (accept-new): swap the file
|
||||
# only if it still holds what was read, else read it again.
|
||||
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
|
||||
try:
|
||||
known_hosts(device_id).unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
# ---- address order --------------------------------------------------------------------
|
||||
|
||||
+50
-19
@@ -98,14 +98,17 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
|
||||
except (socket.gaierror, UnicodeError, OSError) as e:
|
||||
return {"state": "unresolved", "detail": "Can't find this name on the network", "error": str(e)}
|
||||
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]
|
||||
left = deadline - now()
|
||||
if left <= 0:
|
||||
break
|
||||
update(state="trying", detail=f"Trying {ip}", ip=ip)
|
||||
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()
|
||||
try:
|
||||
s.connect(addr)
|
||||
@@ -154,7 +157,10 @@ class Link:
|
||||
self.last_attempt = 0
|
||||
self.config_mtime = None
|
||||
self.thread = None
|
||||
self.attempt_gen = 0
|
||||
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
|
||||
|
||||
# ---- publishing ----
|
||||
@@ -227,7 +233,7 @@ class Link:
|
||||
self.close_master()
|
||||
|
||||
def alive(self):
|
||||
if self.state["phase"] != "connected":
|
||||
if self.state["phase"] != "connected" or self.kicks:
|
||||
return False
|
||||
if not self.control:
|
||||
return True
|
||||
@@ -248,12 +254,20 @@ class Link:
|
||||
self.state["phase"] != "connecting"), wait)
|
||||
|
||||
def use(self, device_id):
|
||||
"""Switch to another headset. Commands go to it from now on (never the last one),
|
||||
and wait in ensure() for the connector to reach it."""
|
||||
"""Switch to another headset."""
|
||||
self.reg.set_active(device_id)
|
||||
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()
|
||||
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:
|
||||
self.state["phase"] = "connecting"
|
||||
self.kicks.append("switch")
|
||||
@@ -301,7 +315,7 @@ class Link:
|
||||
return []
|
||||
return ["-o", f"HostName={frame_devices.ssh_host(host)}",
|
||||
"-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']}"]
|
||||
|
||||
def public_device(self, d):
|
||||
@@ -361,7 +375,7 @@ class Link:
|
||||
self.devices_changed()
|
||||
after = self.active_device()
|
||||
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):
|
||||
net = frame_network.current_network(self.last_fp)
|
||||
@@ -374,6 +388,8 @@ class Link:
|
||||
why = self.describe(reasons)
|
||||
self.last_attempt = now()
|
||||
self.close_master()
|
||||
with self.route_lock:
|
||||
gen = self.attempt_gen = self.gen
|
||||
device = self.active_device()
|
||||
if self.route_key(device) != self.routed:
|
||||
# Another headset, or a new user or port: nothing may go on using the old
|
||||
@@ -393,6 +409,17 @@ class Link:
|
||||
try:
|
||||
ok = self.attempt(device)
|
||||
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:
|
||||
self.state["finished"] = now()
|
||||
if ok:
|
||||
@@ -574,16 +601,16 @@ class Link:
|
||||
return
|
||||
if self.route_key(now_dev) != self.route_key(device):
|
||||
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:
|
||||
block = next((b for b in frame_devices.parse_blocks(frame_devices.read_config())
|
||||
if b["alias"] == device["alias"]), None)
|
||||
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"]):
|
||||
if frame_devices.rewrite_block(device["alias"], hostname=host, user=device["user"],
|
||||
port=device["port"], expect=expect):
|
||||
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.sync_from_config() # records the login it now holds
|
||||
except OSError:
|
||||
pass # not fatal: the app itself doesn't need the file
|
||||
self.devices_changed()
|
||||
@@ -622,6 +649,9 @@ class Link:
|
||||
-> "ok", "next" (try another address) or "stop"."""
|
||||
opts = self.host_opts(device, a["host"])
|
||||
alias = device["alias"]
|
||||
with self.route_lock:
|
||||
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 "")
|
||||
@@ -850,16 +880,20 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
||||
link.use(did)
|
||||
msg = f"Switched to {d['name']}"
|
||||
elif action == "update":
|
||||
before = reg.get(did)
|
||||
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:
|
||||
frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"])
|
||||
except OSError as 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']}"
|
||||
elif action == "remove":
|
||||
d = reg.remove_device(did)
|
||||
if is_active:
|
||||
link.override = None
|
||||
link.invalidate()
|
||||
frame_devices.forget_pin(did)
|
||||
removed = False
|
||||
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"])
|
||||
except OSError as 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 "")
|
||||
elif action == "address-add":
|
||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
|
||||
|
||||
@@ -2533,6 +2533,12 @@ function onConnection(s) {
|
||||
online = null;
|
||||
devGen++; // answers still on their way are about the other headset
|
||||
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
|
||||
$("battChip").hidden = true;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user