Windows: fix ssh config ACL, link-local IPv6, and setup under python -I

- ~/.ssh/config writes swapped in a temp file that inherited the .ssh folder's
  ACL; Windows' OpenSSH refuses one granting another account (even a deleted
  one) more than read: "Bad owner or permissions". Writes now give the file an
  owner-only ACL (frame_host.make_private), and the server repairs a refused
  config once per run and retries.
- frame_link.probe named a link-local IPv6 zone with if_indextoname, which on
  Windows is "ethernet_32769"; Windows' ssh can't resolve that, so a headset
  found at fe80:: showed as "can't find the Frame". Use the zone number there.
- frame_connect.py imports frame_host (since #60), but the app runs it with
  python -I, which leaves its folder off sys.path: Set Up Connection exited
  with ModuleNotFoundError. Add the folder, as server.py does.

Verified on a Windows 11 VM against OpenSSH_for_Windows 9.5p2: the old write
reproduces the reported error with an orphan SID's Modify ACE; the new write,
repair and server retry all leave a config ssh accepts; ssh to %ethernet_32769
fails to resolve while %5 connects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-10-05 22:43:51 +11:00
1 parent a4031db052
commit 07f44f9082
6 files changed
+264 -6

No files matched your search

+26
View File
@@ -339,6 +339,8 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
raise Failure(f"Timed out talking to {FRAME}")
if r.returncode != 0:
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
if r.returncode == 255 and repair_ssh_config(err):
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
if r.returncode == 255 and LINK and unreachable(err):
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
@@ -347,6 +349,30 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
return r.stdout
_config_repaired = False
def repair_ssh_config(err):
"""Windows' OpenSSH refused ~/.ssh/config for its ACL: give the file a private one,
once per run. -> True if it did, so the command is worth retrying."""
global _config_repaired
if _config_repaired or not frame_host.WINDOWS or frame_host.BAD_PERMISSIONS not in err:
return False
# ssh doubles the backslashes: "C:\\Users\\me/.ssh/config"
named = re.sub(r"[\\/]+", "/", err.split(frame_host.BAD_PERMISSIONS, 1)[1].splitlines()[0].strip())
config = frame_devices.ssh_config()
if not named.lower().endswith("/" + config.name.lower()):
return False # a key or another file: not ours to rewrite
_config_repaired = True
try:
if frame_devices.repair_permissions(config):
print(f"Gave {config} a private ACL: ssh refused it ({named})", file=sys.stderr)
return True
except OSError as e:
print(f"Couldn't repair {config}'s permissions: {e}", file=sys.stderr)
return False
def strip_ansi(s):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)