mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +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
+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.
|
||||
|
||||
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:
|
||||
fh.write("\n".join(dict.fromkeys(keys)) + "\n")
|
||||
if not frame_host.WINDOWS:
|
||||
target.chmod(0o600)
|
||||
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")
|
||||
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 --------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user