mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +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
+187
-122
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
|
- **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
@@ -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
@@ -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
@@ -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
@@ -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 "")
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user