riptide-transfer 1.3.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,887 @@
1
+ """Create / Join session protocol.
2
+
3
+ One TLS connection, started by the Join side (the client) dialling
4
+ the Create side (the host). After that one connection is live,
5
+ file direction is independent of connection direction:
6
+
7
+ - either side can Send (push a local file to the other side)
8
+ - either side can Download (request a file from the other side)
9
+
10
+ No pairing code, no pairing listener, no SSH. Authentication is
11
+ fingerprint-based, like SSH host keys:
12
+ - the client checks the host certificate fingerprint, and
13
+ presses Allow on first connect, or aborts if it changed;
14
+ - since v1.1, the host also checks the client certificate
15
+ fingerprint, through a signed hello challenge, and presses
16
+ Allow on first connect, shows Known for a matching fingerprint,
17
+ or warns on a changed fingerprint. An empty client fingerprint
18
+ aborts, it never falls back to device-name-only approval.
19
+
20
+ Framing: 4-byte big-endian length + JSON header, then, for a
21
+ file message, exactly size bytes. A file is written to a .part
22
+ file in the receiver's cwd and renamed only after its SHA-256
23
+ matches the header. A mismatch is reported, not renamed.
24
+
25
+ Only one file transfer runs at a time on a session (a send lock
26
+ makes header + bytes atomic). A send does not block waiting for
27
+ its ack: the ack arrives as a normal message and is delivered
28
+ through on_ack, so the reader loop can never deadlock waiting
29
+ for a message it is supposed to read itself.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import base64
34
+ import hashlib
35
+ import inspect
36
+ import ipaddress
37
+ import json
38
+ import os
39
+ import select
40
+ import socket
41
+ import ssl
42
+ import struct
43
+ import threading
44
+ import time
45
+ from pathlib import Path
46
+
47
+ from .config import add_trusted_client, load_config
48
+ from .identity import (
49
+ cert_der_from_pem,
50
+ cert_fingerprint,
51
+ ensure_identity,
52
+ sign_nonce,
53
+ verify_nonce_signature,
54
+ )
55
+
56
+
57
+ def parse_host_input(text: str) -> str:
58
+ """Parse a Create-server address into a bare host.
59
+
60
+ Accepts, all with no port typed by the user:
61
+ - a hostname: metalvoid.ddns.net
62
+ - an IPv4 address: 192.168.1.10
63
+ - an IPv6 address: 2001:db8::1, or [2001:db8::1]
64
+ - user@host: user@metalvoid.ddns.net,
65
+ user@192.168.1.10, user@[2001:db8::1]
66
+ - host:port, if a port was typed anyway, is stripped for plain
67
+ hostnames / IPv4; a bare IPv6 address is never split on ':'.
68
+ Raises ValueError for an empty or unusable input.
69
+ """
70
+ text = (text or "").strip()
71
+ if not text:
72
+ raise ValueError("Enter a host first")
73
+ if "@" in text:
74
+ text = text.rsplit("@", 1)[1].strip()
75
+ if not text:
76
+ raise ValueError("Enter a host first")
77
+ if text.startswith("["):
78
+ # [ipv6] or [ipv6]:port
79
+ end = text.find("]")
80
+ if end == -1:
81
+ raise ValueError("Bad IPv6 address: missing ]")
82
+ host = text[1:end].strip()
83
+ if not host:
84
+ raise ValueError("Enter a host first")
85
+ return host
86
+ # Bare IPv6: more than one ':' and a valid IPv6 parse.
87
+ if text.count(":") > 1:
88
+ try:
89
+ ipaddress.IPv6Address(text)
90
+ return text
91
+ except Exception:
92
+ raise ValueError("Bad IPv6 address")
93
+ # Single ':' for hostname/IPv4 with a port: strip the port.
94
+ if text.count(":") == 1:
95
+ host, _port = text.split(":", 1)
96
+ host = host.strip()
97
+ if not host:
98
+ raise ValueError("Enter a host first")
99
+ return host
100
+ return text
101
+
102
+
103
+ def _set_keepalive(sock) -> None:
104
+ """Blocking socket + TCP keepalive, so an idle session does not
105
+ time out. socket.create_connection leaves a 30-second timeout
106
+ on the client socket, so recv() timed out after 30 seconds of
107
+ no file transfer and the reader loop ended the session.
108
+ Blocking mode + keepalive removes that."""
109
+ try:
110
+ sock.settimeout(None)
111
+ except Exception:
112
+ pass
113
+ try:
114
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
115
+ except Exception:
116
+ pass
117
+ # Linux TCP keepalive tunables; harmless if unavailable (Windows).
118
+ for opt, value in (("TCP_KEEPIDLE", 10), ("TCP_KEEPINTVL", 10), ("TCP_KEEPCNT", 3)):
119
+ try:
120
+ sock.setsockopt(socket.IPPROTO_TCP, getattr(socket, opt), value)
121
+ except Exception:
122
+ pass
123
+
124
+
125
+ def _recv_exact(sock, n: int) -> bytes:
126
+ data = b""
127
+ while len(data) < n:
128
+ chunk = sock.recv(n - len(data))
129
+ if not chunk:
130
+ raise ConnectionError("session closed")
131
+ data += chunk
132
+ return data
133
+
134
+
135
+ def _send_header(sock, header: dict) -> None:
136
+ raw = json.dumps(header).encode()
137
+ sock.sendall(struct.pack(">I", len(raw)) + raw)
138
+
139
+
140
+ def unique_final_path(cwd: Path, name: str) -> Path | None:
141
+ """Pick a final path that does not overwrite an existing file.
142
+
143
+ Since v1.2, a received file never silently overwrites. If
144
+ ``name`` already exists in ``cwd``, try ``name (1).ext``,
145
+ then ``name (2).ext``, and so on, up to 1000. The extension
146
+ is the last suffix, so ``archive.tar.gz`` becomes
147
+ ``archive.tar (1).gz``, and a dotfile ``.bashrc`` becomes
148
+ ``.bashrc (1)``. An incoming name that already ends in
149
+ `` (1)`` is treated literally, so it becomes `` (1) (1)``
150
+ if that name is also taken, which keeps the rule simple
151
+ and predictable. A symlink counts as existing, so a symlink
152
+ is never replaced. Returns None after 1000 taken names.
153
+ Empty, dot, and dot-dot names return the original path,
154
+ so the caller fails as before instead of inventing a name.
155
+ """
156
+ cwd = Path(cwd)
157
+ if not name or name in (".", ".."):
158
+ return cwd / name
159
+ base = Path(name).name
160
+ stem = Path(base).stem
161
+ suffix = Path(base).suffix
162
+ candidate = cwd / base
163
+ if not (candidate.exists() or candidate.is_symlink()):
164
+ return candidate
165
+ for i in range(1, 1001):
166
+ candidate = cwd / f"{stem} ({i}){suffix}"
167
+ if not (candidate.exists() or candidate.is_symlink()):
168
+ return candidate
169
+ return None
170
+
171
+
172
+ class Session:
173
+ def __init__(self, sock, cwd: Path, device: str = "", peer_device: str = "",
174
+ peer_host: str = "",
175
+ on_file_received=None, on_ack=None, on_error=None,
176
+ on_progress=None,
177
+ on_download_ask=None, on_download_denied=None,
178
+ on_disconnect=None):
179
+ self.sock = sock
180
+ self.cwd = Path(cwd)
181
+ self.device = device
182
+ self.peer_device = peer_device
183
+ self.peer_host = peer_host
184
+ self.peer_fingerprint = ""
185
+ # Host side only, set by HostListener: known, new, or changed.
186
+ self.client_trust_status = ""
187
+ self.client_old_fingerprint = ""
188
+ self.on_file_received = on_file_received
189
+ self.on_ack = on_ack
190
+ self.on_error = on_error
191
+ self.on_progress = on_progress
192
+ self.on_download_ask = on_download_ask
193
+ self.on_download_denied = on_download_denied
194
+ # Called exactly once when the session ends, cleanly or not:
195
+ # the peer sent bye, or the reader hit an error. The UI uses
196
+ # it to return to the Create/Join screen. It is separate
197
+ # from on_error because on_error also fires for non-fatal
198
+ # events, like a failed file receive, which must not end
199
+ # the session.
200
+ self.on_disconnect = on_disconnect
201
+ # Set when the reader has exited, so a waiter (the Create
202
+ # screen worker) can tell the session is over without
203
+ # polling.
204
+ self._ended = threading.Event()
205
+ self._send_lock = threading.Lock()
206
+ self._running = False
207
+ self._thread: threading.Thread | None = None
208
+ self._heartbeat_thread: threading.Thread | None = None
209
+
210
+ def wait_ended(self, timeout: float | None = None) -> bool:
211
+ """Block until the reader has exited, i.e. the session is over."""
212
+ return self._ended.wait(timeout)
213
+
214
+ def _notify_disconnect(self, message: str) -> None:
215
+ # One call for both the message and the lifecycle: the UI
216
+ # learns the session ended, and the session screen can
217
+ # return to the Create/Join screen.
218
+ if self.on_error is not None:
219
+ self.on_error(message)
220
+ if self.on_disconnect is not None:
221
+ self.on_disconnect()
222
+
223
+ # -- sending -------------------------------------------------
224
+ def send_file(self, path: Path, on_progress=None) -> bool:
225
+ path = Path(path)
226
+ if not path.is_file():
227
+ return False
228
+ size = path.stat().st_size
229
+ digest = hashlib.sha256()
230
+ with open(path, "rb") as handle:
231
+ for chunk in iter(lambda: handle.read(1024 * 256), b""):
232
+ digest.update(chunk)
233
+ header = {"type": "file", "name": path.name, "size": size, "sha256": digest.hexdigest()}
234
+ with self._send_lock:
235
+ try:
236
+ _send_header(self.sock, header)
237
+ sent = 0
238
+ last_report = 0.0
239
+ with open(path, "rb") as handle:
240
+ while True:
241
+ chunk = handle.read(1024 * 128)
242
+ if not chunk:
243
+ break
244
+ self.sock.sendall(chunk)
245
+ sent += len(chunk)
246
+ # Sender progress was the bug: it reported every
247
+ # 128KB chunk, while the receiver reports at most
248
+ # every 0.1s. The UI computes speed from the time
249
+ # between UI callbacks, and call_from_thread lets
250
+ # the sender run ahead and queue a burst of
251
+ # callbacks that the UI then processes almost
252
+ # instantly, so bytes / tiny time = 4-5x speed.
253
+ # Throttle the sender exactly like the receiver:
254
+ # at most every 0.1s, plus always on the final
255
+ # chunk, so the UI time between reports is real
256
+ # network time, not queue-processing time.
257
+ now = time.monotonic()
258
+ if on_progress is not None and (now - last_report >= 0.1 or sent >= size):
259
+ last_report = now
260
+ on_progress(path.name, sent, size)
261
+ except Exception as exc:
262
+ if self.on_error is not None:
263
+ self.on_error(f"send failed: {exc}")
264
+ return False
265
+ return True
266
+
267
+
268
+ def request_download(self, name: str, request_id: str = "") -> bool:
269
+ """Ask the peer for approval to download a file.
270
+
271
+ ``name`` may be absolute (a path on the peer) or relative
272
+ to the peer's cwd. Nothing is sent until the peer allows
273
+ it; the file then arrives as a normal file message."""
274
+ with self._send_lock:
275
+ try:
276
+ _send_header(self.sock, {"type": "download_ask", "name": name, "id": request_id})
277
+ except Exception as exc:
278
+ if self.on_error is not None:
279
+ self.on_error(f"download request failed: {exc}")
280
+ return False
281
+ return True
282
+
283
+ def respond_download(self, name: str, request_id: str, allow: bool, on_progress=None) -> bool:
284
+ """Peer side: answer a download_ask. On allow, send the
285
+ file (absolute path, or relative to this side's cwd).
286
+ On deny, tell the requester, and send nothing.
287
+ on_progress, when given, is passed to send_file so the
288
+ approver side gets progress like a normal send. Without
289
+ it, the approver saw no progress bar at all."""
290
+ if not allow:
291
+ with self._send_lock:
292
+ try:
293
+ _send_header(self.sock, {"type": "download_denied", "name": name, "id": request_id})
294
+ except Exception:
295
+ pass
296
+ return True
297
+ path = Path(name).expanduser()
298
+ if not path.is_absolute():
299
+ path = self.cwd / path
300
+ # send_file only sends files, and its header carries only
301
+ # the basename, so the receiver saves it in its own cwd.
302
+ return self.send_file(path, on_progress=on_progress)
303
+
304
+ def send_ack(self, name: str, ok: bool, reason: str = "") -> None:
305
+ with self._send_lock:
306
+ try:
307
+ _send_header(self.sock, {"type": "ack", "name": name, "ok": ok, "reason": reason})
308
+ except Exception:
309
+ pass
310
+
311
+
312
+
313
+ # -- receiving ------------------------------------------------
314
+ def start(self) -> None:
315
+ if self._running:
316
+ return
317
+ _set_keepalive(self.sock)
318
+ self._running = True
319
+ self._thread = threading.Thread(target=self._reader, daemon=True)
320
+ self._thread.start()
321
+ self._heartbeat_thread = threading.Thread(target=self._heartbeat, daemon=True)
322
+ self._heartbeat_thread.start()
323
+
324
+ def _heartbeat(self) -> None:
325
+ """Ping every 10 seconds while idle, so NAT/firewall idle
326
+ timeouts do not close a session with no file transferring.
327
+ Ping/pong are control messages, not file bytes."""
328
+ while self._running:
329
+ time.sleep(10)
330
+ if not self._running:
331
+ break
332
+ try:
333
+ with self._send_lock:
334
+ _send_header(self.sock, {"type": "ping"})
335
+ except Exception:
336
+ break
337
+
338
+ def _reader(self) -> None:
339
+ try:
340
+ while self._running:
341
+ raw_len = _recv_exact(self.sock, 4)
342
+ (length,) = struct.unpack(">I", raw_len)
343
+ if length <= 0 or length > 1024 * 1024:
344
+ raise ConnectionError("bad header length")
345
+ header = json.loads(_recv_exact(self.sock, length).decode())
346
+ kind = header.get("type")
347
+ if kind == "file":
348
+ self._receive_file(header)
349
+ elif kind == "download_ask":
350
+ if self.on_download_ask is not None:
351
+ self.on_download_ask(str(header.get("name", "")), str(header.get("id", "")))
352
+ elif kind == "download_denied":
353
+ if self.on_download_denied is not None:
354
+ self.on_download_denied(str(header.get("name", "")), str(header.get("id", "")))
355
+ elif kind == "ack":
356
+ if self.on_ack is not None:
357
+ self.on_ack(header.get("name", ""), bool(header.get("ok")), header.get("reason", ""))
358
+ elif kind == "ping":
359
+ # Reply, so both directions see traffic. No UI update.
360
+ try:
361
+ with self._send_lock:
362
+ _send_header(self.sock, {"type": "pong"})
363
+ except Exception:
364
+ pass
365
+ elif kind == "pong":
366
+ pass
367
+ elif kind == "bye":
368
+ # The peer disconnected cleanly. Tell the UI,
369
+ # exactly like a failed read does, otherwise
370
+ # the session dies silently and the screen
371
+ # keeps showing connected.
372
+ self._notify_disconnect("peer disconnected")
373
+ break
374
+ elif kind == "error":
375
+ if self.on_error is not None:
376
+ self.on_error(str(header.get("message", "")))
377
+ except Exception as exc:
378
+ if self._running:
379
+ self._notify_disconnect(f"session ended: {exc}")
380
+ finally:
381
+ self._running = False
382
+ self._ended.set()
383
+
384
+ def _receive_file(self, header: dict) -> None:
385
+ name = Path(str(header.get("name", ""))).name
386
+ size = int(header.get("size", 0))
387
+ expected = str(header.get("sha256", ""))
388
+ part = self.cwd / f"{name}.part"
389
+ final = self.cwd / name
390
+ digest = hashlib.sha256()
391
+ remaining = size
392
+ received = 0
393
+ last_report = 0.0
394
+ try:
395
+ with open(part, "wb") as dest:
396
+ while remaining > 0:
397
+ chunk = self.sock.recv(min(1024 * 128, remaining))
398
+ if not chunk:
399
+ raise ConnectionError("session closed during file")
400
+ dest.write(chunk)
401
+ digest.update(chunk)
402
+ remaining -= len(chunk)
403
+ received += len(chunk)
404
+ # Receiver progress: report at most every 0.1s, plus
405
+ # always on the final chunk, so the UI stays live
406
+ # without flooding it on every 128KB chunk.
407
+ now = time.monotonic()
408
+ if self.on_progress is not None and (now - last_report >= 0.1 or remaining == 0):
409
+ last_report = now
410
+ try:
411
+ self.on_progress(name, received, size)
412
+ except Exception:
413
+ pass
414
+ except Exception as exc:
415
+ if self.on_error is not None:
416
+ self.on_error(f"receive failed: {exc}")
417
+ if self.on_file_received is not None:
418
+ self.on_file_received(name, False, "incomplete")
419
+ return
420
+ ok = digest.hexdigest() == expected
421
+ saved_name = name
422
+ rename_reason = ""
423
+ if ok:
424
+ # Never silently overwrite. Pick a free name, re-check
425
+ # just before the rename, and if a file appeared in
426
+ # that window, pick the next free name. The old
427
+ # fallback that unlinked the target and replaced it
428
+ # is removed on purpose, it was the overwrite bug.
429
+ if not name or name in (".", ".."):
430
+ try:
431
+ part.replace(final)
432
+ saved_name = name
433
+ except Exception as exc:
434
+ ok = False
435
+ rename_reason = f"rename failed: {exc}"
436
+ if self.on_error is not None:
437
+ self.on_error(rename_reason)
438
+ else:
439
+ for _attempt in range(1001):
440
+ unique = unique_final_path(self.cwd, name)
441
+ if unique is None:
442
+ ok = False
443
+ rename_reason = "too many duplicates"
444
+ break
445
+ if unique.exists() or unique.is_symlink():
446
+ continue
447
+ try:
448
+ part.replace(unique)
449
+ saved_name = unique.name
450
+ break
451
+ except Exception as exc:
452
+ # If the target appeared between check and
453
+ # rename, try the next free name. Any other
454
+ # error is a real rename failure.
455
+ if unique.exists() or unique.is_symlink():
456
+ continue
457
+ ok = False
458
+ rename_reason = f"rename failed: {exc}"
459
+ if self.on_error is not None:
460
+ self.on_error(rename_reason)
461
+ break
462
+ else:
463
+ ok = False
464
+ rename_reason = "too many duplicates"
465
+ # On a rename failure, the .part file is kept as in
466
+ # v1.1. Section 5 changes .part cleanup on failed
467
+ # transfers, it is not bundled into this section.
468
+ if self.on_file_received is not None:
469
+ if ok:
470
+ # On rename, tell the UI the original name, so
471
+ # it can show "saved as X, original Y already
472
+ # existed" instead of just the saved name.
473
+ if saved_name != name:
474
+ self.on_file_received(saved_name, True, f"renamed from {name}")
475
+ else:
476
+ self.on_file_received(saved_name, True, "")
477
+ elif digest.hexdigest() != expected:
478
+ self.on_file_received(name, False, "SHA-256 mismatch, kept as .part")
479
+ else:
480
+ self.on_file_received(name, False, rename_reason or "rename failed")
481
+ # Ack is a normal message, not a file, so it cannot interleave
482
+ # with file bytes: file bytes are already fully read here.
483
+ # On success, the ack carries the actual saved name, so
484
+ # the sender learns that the file was renamed to (1), (2), etc.
485
+ if ok:
486
+ self.send_ack(saved_name, True, "")
487
+ elif digest.hexdigest() != expected:
488
+ self.send_ack(name, False, "SHA-256 mismatch")
489
+ else:
490
+ self.send_ack(name, False, rename_reason or "rename failed")
491
+
492
+
493
+ def close(self) -> None:
494
+ self._running = False
495
+ try:
496
+ with self._send_lock:
497
+ _send_header(self.sock, {"type": "bye"})
498
+ except Exception:
499
+ pass
500
+ try:
501
+ self.sock.close()
502
+ except Exception:
503
+ pass
504
+
505
+
506
+ def _read_header(sock) -> dict:
507
+ raw_len = _recv_exact(sock, 4)
508
+ (length,) = struct.unpack(">I", raw_len)
509
+ if length <= 0 or length > 1024 * 1024:
510
+ raise ConnectionError("bad header length")
511
+ return json.loads(_recv_exact(sock, length).decode())
512
+
513
+
514
+
515
+
516
+
517
+
518
+ def _server_context(key_path: Path, cert_path: Path) -> ssl.SSLContext:
519
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
520
+ ctx.load_cert_chain(str(cert_path), str(key_path))
521
+ # CERT_NONE here, because a self-signed client cert would fail
522
+ # Python's built-in verification even with CERT_OPTIONAL, as
523
+ # verified in the v1.1 spike. Client authentication does not
524
+ # rely on TLS client certificates at all. It relies on the
525
+ # signed hello challenge in HostListener.wait_for_client:
526
+ # the client presents its certificate in hello, signs a
527
+ # host-chosen nonce with its private key, and the host
528
+ # verifies that signature against the presented certificate.
529
+ ctx.verify_mode = ssl.CERT_NONE
530
+ ctx.check_hostname = False
531
+ return ctx
532
+
533
+
534
+ def _client_context(key_path: Path, cert_path: Path) -> ssl.SSLContext:
535
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
536
+ ctx.load_cert_chain(str(cert_path), str(key_path))
537
+ ctx.check_hostname = False
538
+ ctx.verify_mode = ssl.CERT_NONE
539
+ return ctx
540
+
541
+
542
+ def _approve_arity(approve) -> int:
543
+ """How many positional arguments an approve callback accepts.
544
+
545
+ Old callbacks take (device, fingerprint). New callbacks take
546
+ (device, fingerprint, status, old_fingerprint). Callbacks
547
+ with *args accept any count, so treat them as new.
548
+ """
549
+ try:
550
+ params = inspect.signature(approve).parameters.values()
551
+ except Exception:
552
+ return 2
553
+ for param in params:
554
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
555
+ return 4
556
+ count = sum(1 for p in params if p.kind in (
557
+ inspect.Parameter.POSITIONAL_ONLY,
558
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
559
+ ))
560
+ return count
561
+
562
+
563
+ def _call_approve(approve, device: str, fingerprint: str, status: str, old_fingerprint: str) -> bool:
564
+ arity = _approve_arity(approve)
565
+ if arity >= 4:
566
+ return bool(approve(device, fingerprint, status, old_fingerprint))
567
+ if arity == 3:
568
+ return bool(approve(device, fingerprint, status))
569
+ return bool(approve(device, fingerprint))
570
+
571
+
572
+ class HostListener:
573
+ """Host side: listen for one client, approve it in the UI, return a Session.
574
+
575
+ approve is called in the listener thread; the UI must answer
576
+ it (in tests it can auto-approve). Old callbacks take
577
+ (device, fingerprint). New callbacks take
578
+ (device, fingerprint, status, old_fingerprint), where status
579
+ is known, new, or changed. A changed fingerprint is only
580
+ passed to a new callback. An old callback never sees a
581
+ changed fingerprint, because that case aborts before approval,
582
+ so a plain Allow can never silently replace a saved fingerprint.
583
+ """
584
+
585
+ def __init__(self, port: int, cwd: Path, device: str, home: Path | None = None,
586
+ approve=None):
587
+ self.port = port
588
+ self.cwd = Path(cwd)
589
+ self.device = device
590
+ self.home = home
591
+ self.approve = approve
592
+ self._server_socks: list[socket.socket] = []
593
+
594
+ def _abort(self, tls, message: str):
595
+ try:
596
+ _send_header(tls, {"type": "error", "message": message})
597
+ except Exception:
598
+ pass
599
+ try:
600
+ tls.close()
601
+ except Exception:
602
+ pass
603
+ raise ConnectionError(message)
604
+
605
+ def _create_listener_sockets(self, port: int) -> list[socket.socket]:
606
+ """Listening sockets covering IPv4 and IPv6 on `port`.
607
+
608
+ Preferred: one dual-stack AF_INET6 socket (IPV6_V6ONLY=0)
609
+ that accepts both families on the same port, so an IPv6
610
+ client dialling the host's global address reaches us. If
611
+ the host refuses a dual-stack socket but still has IPv6,
612
+ one socket per family instead. If the host has no IPv6 at
613
+ all, a single IPv4 socket, exactly as before.
614
+ """
615
+ try:
616
+ server = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
617
+ try:
618
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
619
+ server.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
620
+ server.bind(("::", port))
621
+ server.listen(1)
622
+ except Exception:
623
+ server.close()
624
+ raise
625
+ return [server]
626
+ except Exception:
627
+ pass
628
+ sockets: list[socket.socket] = []
629
+ last_exc: Exception | None = None
630
+ for family, addr in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
631
+ try:
632
+ sock = socket.socket(family, socket.SOCK_STREAM)
633
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
634
+ if family == socket.AF_INET6:
635
+ sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
636
+ sock.bind((addr, port))
637
+ sock.listen(1)
638
+ sockets.append(sock)
639
+ except Exception as exc: # noqa: BLE001 - retried per family, raised below
640
+ last_exc = exc
641
+ if not sockets:
642
+ raise OSError(f"could not bind a listener on port {port}") from last_exc
643
+ return sockets
644
+
645
+ def _accept_one(self, servers: list[socket.socket], timeout: float | None) -> tuple:
646
+ """Accept one connection from any of `servers`, honouring `timeout`.
647
+
648
+ select() waits on all of them at once. stop() shutting the
649
+ sockets down wakes it (select raises, or accept raises on a
650
+ stopped socket), so leaving the Create screen still releases
651
+ the port at once, as it did for the single-socket accept().
652
+ """
653
+ deadline = None if timeout is None else time.monotonic() + timeout
654
+ candidates = list(servers)
655
+ while candidates:
656
+ remaining = None
657
+ if deadline is not None:
658
+ remaining = deadline - time.monotonic()
659
+ if remaining <= 0:
660
+ raise socket.timeout("timed out")
661
+ readable, _, _ = select.select(candidates, [], [], remaining)
662
+ if not readable:
663
+ raise socket.timeout("timed out")
664
+ for server in readable:
665
+ try:
666
+ return server.accept()
667
+ except OSError:
668
+ # A stopped socket: drop it and keep waiting on
669
+ # the rest; if none are left, the listener is gone.
670
+ candidates.remove(server)
671
+ raise OSError("listener stopped")
672
+
673
+ def wait_for_client(self, timeout: float | None = None) -> Session:
674
+ key_path, cert_path = ensure_identity(self.home)
675
+ ctx = _server_context(key_path, cert_path)
676
+ servers = self._create_listener_sockets(self.port)
677
+ self._server_socks = servers
678
+ try:
679
+ raw, _addr = self._accept_one(servers, timeout)
680
+ finally:
681
+ for server in servers:
682
+ try:
683
+ server.close()
684
+ except Exception:
685
+ pass
686
+ self._server_socks = []
687
+ try:
688
+ tls = ctx.wrap_socket(raw, server_side=True)
689
+ except Exception as exc:
690
+ raise ConnectionError(f"TLS failed: {exc}") from exc
691
+ _set_keepalive(tls)
692
+ # Signed hello challenge. The client must present its
693
+ # certificate in hello, then sign a host-chosen nonce.
694
+ # A client that presents no certificate, or a bad
695
+ # signature, aborts here, before any approval prompt.
696
+ # This is a breaking change from v1.0 on purpose: a v1.0
697
+ # client cannot be authenticated, so a v1.1 host must
698
+ # not accept it on device name alone.
699
+ try:
700
+ hello = _read_header(tls)
701
+ except Exception as exc:
702
+ try:
703
+ tls.close()
704
+ except Exception:
705
+ pass
706
+ raise ConnectionError(f"hello failed: {exc}") from exc
707
+ client_device = str(hello.get("device", "")) or "client"
708
+ # On a dual-stack socket an IPv4 peer arrives as an
709
+ # IPv4-mapped IPv6 address (::ffff:192.168.1.6); unwrap it
710
+ # so the saved peer address stays a plain IPv4 address.
711
+ peer_ip = _addr[0]
712
+ if peer_ip.startswith("::ffff:"):
713
+ peer_ip = peer_ip[7:]
714
+ dialled_host = str(hello.get("host", "")) or peer_ip
715
+ cert_b64 = str(hello.get("cert", "") or "")
716
+ if not cert_b64:
717
+ self._abort(tls, "client fingerprint missing, cannot authenticate")
718
+ try:
719
+ cert_der = base64.b64decode(cert_b64, validate=True)
720
+ if not cert_der:
721
+ raise ValueError("empty certificate")
722
+ peer_fp = cert_fingerprint(cert_der)
723
+ except Exception:
724
+ self._abort(tls, "client certificate invalid")
725
+ nonce = os.urandom(32)
726
+ try:
727
+ _send_header(tls, {"type": "hello_challenge", "nonce": base64.b64encode(nonce).decode()})
728
+ proof = _read_header(tls)
729
+ except Exception as exc:
730
+ try:
731
+ tls.close()
732
+ except Exception:
733
+ pass
734
+ raise ConnectionError(f"hello challenge failed: {exc}") from exc
735
+ if proof.get("type") != "hello_proof":
736
+ self._abort(tls, "client signature verification failed")
737
+ try:
738
+ signature = base64.b64decode(str(proof.get("signature", "") or ""), validate=True)
739
+ except Exception:
740
+ self._abort(tls, "client signature verification failed")
741
+ if not verify_nonce_signature(cert_der, nonce, signature):
742
+ self._abort(tls, "client signature verification failed")
743
+ # Trust status, from trusted clients saved by this host.
744
+ try:
745
+ config = load_config(self.home)
746
+ except Exception:
747
+ config = None
748
+ status = "new"
749
+ old_fp = ""
750
+ if config is not None:
751
+ if config.trusted_client_for_fingerprint(peer_fp) is not None:
752
+ status = "known"
753
+ else:
754
+ by_device = config.trusted_client_for_device(client_device)
755
+ if by_device is not None:
756
+ status = "changed"
757
+ old_fp = by_device.fingerprint
758
+ if status == "changed" and (self.approve is None or _approve_arity(self.approve) < 3):
759
+ self._abort(tls, "fingerprint changed, session aborted")
760
+ if self.approve is not None and not _call_approve(self.approve, client_device, peer_fp, status, old_fp):
761
+ try:
762
+ tls.close()
763
+ except Exception:
764
+ pass
765
+ raise ConnectionError("host denied the session")
766
+ # Save the trusted client only after approval. Known
767
+ # needs no save. New and changed, where the user pressed
768
+ # Accept after seeing the warning, are saved here, so a
769
+ # later connection is known, or the replaced fingerprint
770
+ # is the new trusted one.
771
+ if config is not None and status in ("new", "changed"):
772
+ try:
773
+ from .config import Peer
774
+
775
+ add_trusted_client(config, Peer(name=client_device, host="", fingerprint=peer_fp), home=self.home)
776
+ except Exception as exc:
777
+ try:
778
+ tls.close()
779
+ except Exception:
780
+ pass
781
+ raise ConnectionError(f"failed to save trusted client: {exc}") from exc
782
+ try:
783
+ _send_header(tls, {"type": "hello_ok", "device": self.device})
784
+ except Exception as exc:
785
+ raise ConnectionError(f"hello failed: {exc}") from exc
786
+ session = Session(tls, self.cwd, device=self.device, peer_device=client_device, peer_host=dialled_host)
787
+ session.peer_fingerprint = peer_fp
788
+ session.client_trust_status = status
789
+ session.client_old_fingerprint = old_fp
790
+ return session
791
+
792
+ def stop(self) -> None:
793
+ # shutdown() first: close() alone does not wake a thread
794
+ # blocked in accept() or select(), so the worker would stay
795
+ # blocked and the port stayed bound, and the next Create
796
+ # Server page in the same process failed to bind with
797
+ # EADDRINUSE. shutdown() wakes both at once, and close()
798
+ # then frees the socket.
799
+ for server in list(self._server_socks):
800
+ try:
801
+ server.shutdown(socket.SHUT_RDWR)
802
+ except Exception:
803
+ pass
804
+ try:
805
+ server.close()
806
+ except Exception:
807
+ pass
808
+ self._server_socks = []
809
+
810
+
811
+ def join_session(host: str, port: int, cwd: Path, device: str,
812
+ known_fingerprint: str = "", home: Path | None = None,
813
+ approve_fingerprint=None) -> Session:
814
+ """Client side: dial the host, check its cert fingerprint, return a Session.
815
+
816
+ approve_fingerprint(fingerprint) -> bool is called when the
817
+ fingerprint is unknown or changed; in the UI it is the Allow button.
818
+
819
+ After the host fingerprint check, the client proves its own
820
+ identity to a v1.1 host with a signed hello challenge. A v1.0
821
+ host replies hello_ok directly, without a challenge, and that
822
+ path still works, because the client side check is unchanged
823
+ and the old host simply never learns the client fingerprint.
824
+ """
825
+ key_path, cert_path = ensure_identity(home)
826
+ ctx = _client_context(key_path, cert_path)
827
+ raw = socket.create_connection((host, port), timeout=30)
828
+ try:
829
+ tls = ctx.wrap_socket(raw, server_hostname=host)
830
+ except Exception as exc:
831
+ try:
832
+ raw.close()
833
+ except Exception:
834
+ pass
835
+ raise ConnectionError(f"TLS failed: {exc}") from exc
836
+ # Critical: create_connection left a 30s timeout on this socket.
837
+ # Without clearing it, an idle session timed out after 30s.
838
+ _set_keepalive(tls)
839
+ server_der = tls.getpeercert(binary_form=True)
840
+ server_fp = cert_fingerprint(server_der) if server_der else ""
841
+ if known_fingerprint and server_fp != known_fingerprint:
842
+ try:
843
+ tls.close()
844
+ except Exception:
845
+ pass
846
+ raise ConnectionError("fingerprint changed, session aborted")
847
+ if not known_fingerprint and approve_fingerprint is not None:
848
+ if not approve_fingerprint(server_fp):
849
+ try:
850
+ tls.close()
851
+ except Exception:
852
+ pass
853
+ raise ConnectionError("client denied the fingerprint")
854
+ try:
855
+ try:
856
+ cert_b64 = base64.b64encode(cert_der_from_pem(cert_path)).decode()
857
+ except Exception:
858
+ cert_b64 = ""
859
+ _send_header(tls, {"type": "hello", "device": device, "host": host, "cert": cert_b64})
860
+ reply = _read_header(tls)
861
+ if reply.get("type") == "hello_ok":
862
+ hello_ok = reply
863
+ elif reply.get("type") == "error":
864
+ raise ConnectionError(f"host rejected: {reply.get('message', 'session rejected')}")
865
+ elif reply.get("type") != "hello_challenge":
866
+ raise ConnectionError("host did not accept the session")
867
+ else:
868
+ try:
869
+ nonce = base64.b64decode(str(reply.get("nonce", "") or ""), validate=True)
870
+ signature = sign_nonce(key_path, nonce)
871
+ except Exception as exc:
872
+ raise ConnectionError(f"hello proof failed: {exc}") from exc
873
+ _send_header(tls, {"type": "hello_proof", "signature": base64.b64encode(signature).decode()})
874
+ hello_ok = _read_header(tls)
875
+ if hello_ok.get("type") == "error":
876
+ raise ConnectionError(f"host rejected: {hello_ok.get('message', 'session rejected')}")
877
+ if hello_ok.get("type") != "hello_ok":
878
+ raise ConnectionError("host did not accept the session")
879
+ except Exception:
880
+ try:
881
+ tls.close()
882
+ except Exception:
883
+ pass
884
+ raise
885
+ session = Session(tls, cwd, device=device, peer_device=str(hello_ok.get("device", "")), peer_host=host)
886
+ session.peer_fingerprint = server_fp
887
+ return session