Coverage for src/web.py: 100%

648 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-26 21:28 +0000

1"""Web UI for icloud-docker. 

2 

3Goal — give the user a single page they can hit from any device to: 

4 1) (primary) authenticate / re-authenticate Apple ID + 2FA; 

5 2) (secondary) confirm config paths, mount markers, and last-sync status; 

6 3) (tertiary) tail the recent log lines. 

7 

8The web server runs in a daemon thread spawned from ``main.py`` alongside 

9the existing ``sync.sync()`` loop. The two share state through the 

10filesystem (keyring, session cookies, log file). No new persistence layer. 

11 

12Designed for **LAN- or proxy-trusted** exposure. There is no built-in 

13login on this UI — put Cloudflare Access / Authelia / Tailscale in front 

14when exposing publicly. Opt-out via ``app.web_ui.enabled: false`` in 

15``config.yaml``. 

16""" 

17 

18__author__ = "Mandar Patil (mandarons@pm.me)" 

19 

20import base64 

21import hmac 

22import json 

23import os 

24import re 

25import secrets 

26import shutil 

27import struct 

28import tempfile 

29import threading 

30import time 

31from typing import Any 

32 

33from flask import Flask, jsonify, redirect, render_template, request, url_for 

34from werkzeug.serving import make_server 

35 

36from src import ( 

37 DEFAULT_CONFIG_FILE_PATH, 

38 DEFAULT_COOKIE_DIRECTORY, 

39 ENV_CONFIG_FILE_PATH_KEY, 

40 ENV_ICLOUD_PASSWORD_KEY, 

41 config_parser, 

42 get_logger, 

43 read_config, 

44 web_signals, 

45) 

46from src.config_utils import get_config_value_or_none 

47 

48LOGGER = get_logger() 

49 

50# Module-level holder for the live icloudpy session created during 

51# POST /auth/password, so POST /auth/code can call validate_2fa_code on 

52# the SAME session. Cleared after a successful trust_session or via 

53# POST /auth/reset. 

54_PENDING_AUTH: dict[str, Any] = {} 

55_AUTH_LOCK = threading.Lock() 

56 

57# Drop stale pending auth after this many seconds. The half-finished 

58# sign-in in ``_PENDING_AUTH["api"]`` holds the submitted Apple ID password 

59# in process memory while waiting for the user's code or key; without an 

60# expiry it would linger indefinitely if the user closed the browser tab 

61# mid-flow. 10 min is generous for typing a code. The password is never 

62# written anywhere. 

63_PENDING_AUTH_TTL_SECONDS = 600 

64 

65# Apple IDs whose last web sign-in used a password other than the stored 

66# one, so the stored copy is probably out of date. The dashboard offers to 

67# remove it; nothing removes it unasked. Each maps to a keyed fingerprint 

68# of the stored password it was compared with, so the notice goes away by 

69# itself once that password is replaced or removed some other way (the 

70# `icloud` command runs in another process). Memory only: after a restart 

71# the plain stored-password notice is still shown. 

72_OUTDATED_PASSWORD: dict[str, bytes] = {} 

73_FINGERPRINT_KEY = secrets.token_bytes(32) 

74 

75 

76def _fingerprint(password: str) -> bytes: 

77 return hmac.new(_FINGERPRINT_KEY, password.encode("utf-8"), "sha256").digest() 

78 

79 

80def _stale_stored_fingerprint(username: str, password: str | None) -> bytes | None: 

81 """Fingerprint of the stored password if one is stored (not from the 

82 environment) and differs from ``password``; otherwise None.""" 

83 if not password or os.environ.get(ENV_ICLOUD_PASSWORD_KEY): 

84 return None 

85 try: 

86 stored = _stored_password(username) 

87 except Exception as e: # advisory only; never fail a sign-in over it 

88 LOGGER.debug(f"Web UI keyring probe raised: {e!s}") 

89 return None 

90 if stored is None or stored == password: 

91 return None 

92 return _fingerprint(stored) 

93 

94 

95def _note_sign_in(username: str | None, *, stale_stored: bytes | None) -> None: 

96 """Remember whether a completed sign-in showed the stored password is out of date.""" 

97 if not username: 

98 return 

99 with _AUTH_LOCK: 

100 if stale_stored is not None: 

101 _OUTDATED_PASSWORD[username] = stale_stored 

102 else: 

103 _OUTDATED_PASSWORD.pop(username, None) 

104 

105 

106def _stored_password_outdated(username: str | None) -> bool: 

107 """True while the stored password is still the one a sign-in showed is stale. 

108 

109 Reads the keyring only for an Apple ID that was flagged.""" 

110 if not username: 

111 return False 

112 with _AUTH_LOCK: 

113 flagged = _OUTDATED_PASSWORD.get(username) 

114 if flagged is None or os.environ.get(ENV_ICLOUD_PASSWORD_KEY): 

115 return False 

116 try: 

117 stored = _stored_password(username) 

118 except Exception as e: # advisory only; never break a render 

119 LOGGER.debug(f"Web UI keyring probe raised: {e!s}") 

120 return False 

121 return stored is not None and hmac.compare_digest(_fingerprint(stored), flagged) 

122 

123 

124def _wake_sync_loop() -> None: 

125 """Cut short the auth-retry wait after a re-auth succeeds. 

126 

127 Without this the loop serves out the rest of an interval that began 

128 before the problem was solved -- the user completes the sign-in and 

129 the dashboard keeps reporting the sync as stopped for up to 

130 ``retry_login_interval``. 

131 

132 Best-effort: a missed nudge costs a delay, never correctness. 

133 """ 

134 try: 

135 web_signals.record_reauth_completed() 

136 except Exception as e: # noqa: BLE001 - never fail a successful sign-in 

137 LOGGER.debug(f"could not signal the completed re-auth: {e!s}") 

138 

139 

140def _pending_auth_is_stale() -> bool: 

141 """True when the in-memory password is older than the TTL. 

142 

143 Caller must hold ``_AUTH_LOCK``. Returns False for an empty dict 

144 (nothing to expire) and for entries that pre-date stashed_at 

145 bookkeeping (defensive — we never penalise a fresh stash). 

146 """ 

147 if not _PENDING_AUTH: 

148 return False 

149 stashed_at = _PENDING_AUTH.get("stashed_at") 

150 if stashed_at is None: 

151 return False 

152 return (time.monotonic() - stashed_at) > _PENDING_AUTH_TTL_SECONDS 

153 

154 

155def _clear_pending_auth_unlocked() -> None: 

156 """Forget the pending auth, and a security-key ceremony's private cookie 

157 jar with it -- it holds that ceremony's Apple session cookies, and an 

158 abandoned ceremony would otherwise leave them in the temp dir until the 

159 container restarts. Caller holds the lock.""" 

160 _discard_ceremony_dir(_PENDING_AUTH.get("cookie_dir")) 

161 _PENDING_AUTH.clear() 

162 

163 

164def _expire_stale_pending_auth_unlocked() -> None: 

165 """If the pending auth is older than the TTL, wipe it. Caller holds the lock.""" 

166 if _pending_auth_is_stale(): 

167 LOGGER.info("Web UI: expiring stale _PENDING_AUTH past TTL.") 

168 _clear_pending_auth_unlocked() 

169 

170 

171# CSRF defence. Threat model: even with the default host pinned to 

172# 127.0.0.1, a user who opts into LAN exposure (host: 0.0.0.0) AND lacks 

173# a proper auth proxy in front would otherwise be vulnerable to a 

174# same-network attacker who tricks them into loading a page that posts 

175# to ``/auth/refresh-trust`` or ``/api/sync``. Double-submit cookie 

176# pattern: per-process random token, set as a SameSite=Strict cookie, 

177# required on every state-changing POST as either a form field 

178# ``csrf_token`` or an ``X-CSRF-Token`` header. SameSite=Strict alone 

179# already blocks the cross-site cookie send in modern browsers; the 

180# server-side compare is belt-and-braces for older clients. 

181_CSRF_TOKEN = secrets.token_urlsafe(32) 

182_CSRF_COOKIE_NAME = "csrf_token" 

183 

184 

185def _get_csrf_token() -> str: 

186 """Expose the token to templates (so forms can embed it) and to 

187 tests (so they can post it). Process-lifetime, regenerated on 

188 restart -- enough for a single-user operator console.""" 

189 return _CSRF_TOKEN 

190 

191 

192def _require_csrf() -> tuple[Any, int] | None: 

193 """Validate CSRF token on the current request. Returns ``None`` 

194 when the request is allowed, or a ``(response, status)`` tuple 

195 when it should be rejected. Use at the top of every state- 

196 changing endpoint: 

197 

198 rejection = _require_csrf() 

199 if rejection is not None: 

200 return rejection 

201 

202 **Calling from a script / monitor.** Every state-changing endpoint 

203 needs BOTH the CSRF cookie and a matching token, so a bare 

204 ``curl -X POST /api/sync -d service=drive`` gets a 403. The cookie 

205 is only set by a prior page load, so fetch it first and echo it 

206 back in the ``X-CSRF-Token`` header:: 

207 

208 curl -c jar -s http://127.0.0.1:8080/ >/dev/null 

209 TOKEN=$(awk '/csrf_token/ {print $7}' jar) 

210 curl -b jar -H "X-CSRF-Token: $TOKEN" \ 

211 -X POST http://127.0.0.1:8080/api/sync -d service=drive 

212 

213 The 403 bodies name which leg failed ("CSRF cookie missing or 

214 stale" vs "CSRF token mismatch") so the fix is obvious. 

215 """ 

216 cookie = request.cookies.get(_CSRF_COOKIE_NAME) 

217 submitted = request.form.get("csrf_token") or request.headers.get("X-CSRF-Token") 

218 # The cookie must match this process's token AND the submitted value 

219 # must match the cookie. Browsers won't include a SameSite=Strict 

220 # cookie on a cross-site POST, so the cookie absence alone is the 

221 # primary signal; the form/header echo is the belt-and-braces leg. 

222 if not cookie or not hmac.compare_digest(cookie, _CSRF_TOKEN): 

223 return jsonify({"error": "CSRF cookie missing or stale"}), 403 

224 if not submitted or not hmac.compare_digest(submitted, cookie): 

225 return jsonify({"error": "CSRF token mismatch"}), 403 

226 return None 

227 

228 

229def _current_config_path() -> str: 

230 """Resolve the active config path the same way sync.py does.""" 

231 return os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH) 

232 

233 

234def _load_current_config() -> dict | None: 

235 """Re-read config.yaml fresh on every request so edits show up live. 

236 

237 Defensive: mandarons' ``read_config`` reaches into 

238 ``config["app"]["credentials"]["username"]`` unconditionally and 

239 crashes if the credentials block is missing. Catch that so a partial 

240 config (e.g. fresh install with only ``app.logger`` set) still lets 

241 the web UI render the setup-needed state instead of 500-ing. 

242 """ 

243 path = _current_config_path() 

244 if not os.path.isfile(path): 

245 return None 

246 try: 

247 return read_config(config_path=path) 

248 except Exception as e: 

249 # Broad on purpose: ``/api/health`` exists for external monitors 

250 # and must be robust against any config-loading failure (YAML 

251 # parse errors, permission denied, missing credentials block, 

252 # ruamel internals raising). A 500 on /api/health blinds the 

253 # monitor; rendering a "config error" state lets the user fix 

254 # it via the UI. 

255 LOGGER.warning(f"Web UI: read_config failed: {e!s}") 

256 return None 

257 

258 

259def _get_marker_filename(config: dict) -> str: 

260 """Marker filename from ``app.mount_marker_filename`` (default ``.mounted``).""" 

261 return config_parser.get_mount_marker_filename(config=config) 

262 

263 

264def _get_require_mount_marker(config: dict, service: str) -> bool: 

265 """``{drive,photos}.require_mount_marker`` for the given service.""" 

266 getter = getattr(config_parser, f"get_{service}_require_mount_marker") 

267 return bool(getter(config=config)) 

268 

269 

270def _get_library_destinations(config: dict) -> dict[str, str]: 

271 """``photos.library_destinations`` mapping (empty dict when unset).""" 

272 return config_parser.get_photos_library_destinations(config=config) or {} 

273 

274 

275def _get_configured_libraries(config: dict) -> list[str]: 

276 """Libraries the operator named: ``photos.filters.libraries``, then any 

277 ``photos.library_destinations`` keys, in config order. 

278 

279 Read directly rather than through ``get_photos_libraries_filter``, which 

280 logs "downloading all libraries" whenever the filter is unset -- once per 

281 dashboard load. 

282 """ 

283 named = get_config_value_or_none(config=config, config_path=["photos", "filters", "libraries"]) or [] 

284 if isinstance(named, str): 

285 # ``libraries: PrimarySync`` rather than a list -- iterating the 

286 # string would list one library per character. 

287 named = [named] 

288 named = [n for n in named if isinstance(n, str)] 

289 return list(dict.fromkeys(named + list(_get_library_destinations(config=config)))) 

290 

291 

292def _build_service(config: dict, service: str, marker_filename: str) -> dict[str, Any]: 

293 """Compose a single service entry (Photos or Drive) for /api/status.""" 

294 if service == "photos": 

295 # Read-only on purpose: ``prepare_*_destination`` calls 

296 # ``join_and_ensure_path`` (mkdir), so a plain ``GET /api/status`` 

297 # would write to disk -- and 500 the whole dashboard on a 

298 # read-only destination mount. Composing the non-mutating getters 

299 # lets a read-only or absent destination degrade to 

300 # ``destination_exists: false`` instead. 

301 destination = os.path.join( 

302 config_parser.get_root_destination_path(config=config), 

303 config_parser.get_photos_destination_path(config=config), 

304 ) 

305 interval = config_parser.get_photos_sync_interval( 

306 config=config, 

307 log_messages=False, 

308 ) 

309 name = "Photos" 

310 library_destinations = _get_library_destinations(config=config) 

311 else: 

312 destination = os.path.join( 

313 config_parser.get_root_destination_path(config=config), 

314 config_parser.get_drive_destination_path(config=config), 

315 ) 

316 interval = config_parser.get_drive_sync_interval( 

317 config=config, 

318 log_messages=False, 

319 ) 

320 name = "Drive" 

321 library_destinations = {} 

322 

323 marker_path = os.path.join(destination, marker_filename) 

324 state = web_signals.get_sync_state(service=service) 

325 stats = None 

326 if state: 

327 completed_at = state.get("completed_at") 

328 stats = { 

329 "last_sync_relative": (web_signals.format_relative_time(completed_at) if completed_at else None), 

330 "files_downloaded": state.get("files_downloaded"), 

331 "files_skipped": state.get("files_skipped"), 

332 "files_removed": state.get("files_removed"), 

333 # Named for what it is: the last COMPLETED cycle's 

334 # downloaded+skipped total. The state record overwrites per 

335 # cycle rather than accumulating, so this is not a running 

336 # count of files on disk. 

337 "last_cycle_total": ( 

338 (state.get("files_downloaded") or 0) + (state.get("files_skipped") or 0) 

339 if (state.get("files_downloaded") is not None or state.get("files_skipped") is not None) 

340 else None 

341 ), 

342 "errors": state.get("errors", 0), 

343 "duration_seconds": state.get("duration_seconds"), 

344 } 

345 return { 

346 "name": name, 

347 "destination": destination, 

348 "destination_exists": os.path.isdir(destination), 

349 "sync_interval_s": interval, 

350 "require_mount_marker": _get_require_mount_marker( 

351 config=config, 

352 service=service, 

353 ), 

354 "marker_present": os.path.isfile(marker_path), 

355 "marker_path": marker_path, 

356 "library_destinations": library_destinations, 

357 "libraries": ( 

358 _build_libraries(_get_configured_libraries(config=config), library_destinations) 

359 if service == "photos" 

360 else [] 

361 ), 

362 "stats": stats, 

363 "force_sync_pending": service in web_signals.pending_force_syncs(), 

364 } 

365 

366 

367def _logger_filename(config: dict | None) -> str: 

368 """Resolve where ``sync.py`` is writing log lines. Best-effort. 

369 

370 Reads ``app.logger.filename`` directly off the config dict to avoid 

371 introducing a new ``config_parser`` helper just for this — keeps the 

372 upstream PR diff small. 

373 """ 

374 if not config: 

375 return "" 

376 try: 

377 return config.get("app", {}).get("logger", {}).get("filename", "") or "" 

378 except AttributeError: 

379 return "" 

380 

381 

382def _tail_log_file(path: str, lines: int = 200) -> list[str]: 

383 """Return the last ``lines`` lines of ``path``. 

384 

385 Best-effort: missing path, unreadable file, or decode failure all 

386 return an empty list. Reads from the end in 8 KiB blocks so the cost 

387 is bounded by ``lines * average_line_length`` rather than file size. 

388 """ 

389 if not path or not os.path.isfile(path): 

390 return [] 

391 try: 

392 with open(path, "rb") as f: 

393 f.seek(0, os.SEEK_END) 

394 size = f.tell() 

395 block = 8192 

396 data = b"" 

397 while size > 0 and data.count(b"\n") <= lines: 

398 read_size = min(block, size) 

399 size -= read_size 

400 f.seek(size) 

401 data = f.read(read_size) + data 

402 return data.decode("utf-8", errors="replace").splitlines()[-lines:] 

403 except OSError as e: 

404 LOGGER.warning(f"Web UI could not tail log {path}: {e!s}") 

405 return [] 

406 

407 

408def _build_status(config: dict | None) -> dict[str, Any]: 

409 """Compose the payload returned by /api/status (and consumed by the 

410 dashboard template).""" 

411 if not config: 

412 return { 

413 "config_loaded": False, 

414 "config_path": _current_config_path(), 

415 "username": None, 

416 "services": [], 

417 } 

418 

419 marker_filename = _get_marker_filename(config=config) 

420 services = [] 

421 if "photos" in config: 

422 services.append( 

423 _build_service( 

424 config=config, 

425 service="photos", 

426 marker_filename=marker_filename, 

427 ), 

428 ) 

429 if "drive" in config: 

430 services.append( 

431 _build_service( 

432 config=config, 

433 service="drive", 

434 marker_filename=marker_filename, 

435 ), 

436 ) 

437 

438 username = config_parser.get_username(config=config) 

439 trust = web_signals.get_trust_state() 

440 trust_expires_at = trust.get("expires_at") 

441 trust_days_remaining: int | None = None 

442 if trust_expires_at: 

443 try: 

444 import datetime 

445 

446 exp = datetime.datetime.fromisoformat(trust_expires_at) 

447 now = datetime.datetime.now(tz=exp.tzinfo) if exp.tzinfo else datetime.datetime.now() 

448 trust_days_remaining = (exp - now).days 

449 except ( 

450 ValueError, 

451 TypeError, 

452 ): # pragma: no cover -- defensive against malformed iso 

453 trust_days_remaining = None 

454 return { 

455 "config_loaded": True, 

456 "config_path": _current_config_path(), 

457 "username": username, 

458 "region": config_parser.get_region(config=config), 

459 "marker_filename": marker_filename, 

460 "services": services, 

461 "auth_state": _detect_auth_state(username=username), 

462 # Which mode the container is in, and why the loop says it is stuck. 

463 # Without these the pill tells a password-free install that its 

464 # keyring is populated and blames every stoppage on a second factor. 

465 "password_stored": _password_is_stored(username) if username else False, 

466 # Deleting the keyring entry does not stick while this is set: the 

467 # sync loop writes it back every cycle. 

468 "password_from_env": bool(os.environ.get(ENV_ICLOUD_PASSWORD_KEY)), 

469 "stored_password_outdated": _stored_password_outdated(username), 

470 "auth_blocked_reason": web_signals.get_auth_blocked().get("reason"), 

471 "auth_method": web_signals.get_auth_method(username) if username else None, 

472 "force_sync_pending": web_signals.pending_force_syncs(), 

473 "trust_expires_at": trust_expires_at, 

474 "trust_days_remaining": trust_days_remaining, 

475 } 

476 

477 

478def _build_libraries(configured: list[str], library_destinations: dict[str, str]) -> list[dict[str, Any]]: 

479 """One row per photo library: where it goes and how it last went. 

480 

481 Only libraries this container actually syncs are listed: the ones the 

482 operator named in config, plus any that have completed a sync or are 

483 syncing right now. An account can carry libraries that never serve 

484 anything -- there is nothing to do with them, so they are left out 

485 rather than shown as a permanent failure. The log still records every 

486 attempt, and naming a library in config always lists it. 

487 

488 Configured libraries come first, in config order. 

489 """ 

490 states = web_signals.get_library_states() 

491 rows = [] 

492 active = sorted( 

493 n 

494 for n, entry in states.items() 

495 if n not in configured and (entry.get("completed_at") or entry.get("state") == "syncing") 

496 ) 

497 for name in configured + active: 

498 entry = states.get(name, {}) 

499 completed_at = entry.get("completed_at") 

500 rows.append( 

501 { 

502 "name": name, 

503 "subdir": library_destinations.get(name), 

504 "state": entry.get("state"), 

505 "error": entry.get("error"), 

506 "indexing": entry.get("indexing"), 

507 "completed_relative": ( 

508 web_signals.format_relative_time(completed_at) if completed_at else None 

509 ), 

510 }, 

511 ) 

512 return rows 

513 

514 

515def _password_is_stored(username: str) -> bool: 

516 """True when a password is cached for this Apple ID.""" 

517 try: 

518 from icloudpy import utils as icloudpy_utils 

519 

520 return bool(icloudpy_utils.password_exists_in_keyring(username)) 

521 except Exception as e: # pragma: no cover - advisory, never breaks a render 

522 LOGGER.debug(f"Web UI keyring probe raised: {e!s}") 

523 return False 

524 

525 

526def _saved_session_exists(username: str) -> bool: 

527 r"""True when this Apple ID has a session file carrying a session token. 

528 

529 icloudpy names the file after the apple id with every non-``\w`` 

530 character dropped, plus ``.session`` (``ICloudPyService.session_path``), 

531 and resumes from its ``session_token``. A file for *some other* account, 

532 or one without that key, is not a credential this container can use -- 

533 and in a password-free setup it is the only credential there is. 

534 """ 

535 sanitised = "".join(c for c in username if re.match(r"\w", c)) 

536 try: 

537 with open( 

538 os.path.join(DEFAULT_COOKIE_DIRECTORY, f"{sanitised}.session"), 

539 encoding="utf-8", 

540 ) as handle: 

541 return bool(json.load(handle).get("session_token")) 

542 except (OSError, ValueError): 

543 return False 

544 

545 

546def _detect_auth_state(username: str | None) -> str: 

547 """Best-effort check of whether the sync loop can actually authenticate. 

548 

549 Returns one of: 

550 - ``not_configured`` — no ``app.credentials.username`` in config. 

551 - ``setup_needed`` — username set, but neither a cached keyring 

552 password nor a saved session. The container's first 2FA flow 

553 hasn't been completed. 

554 - ``ready`` — username set, plus either a keyring entry or a saved 

555 session. Sync loop can resume the session on the next retry. 

556 - ``reauth_needed`` — a credential is present but the sync loop has 

557 reported that it cannot authenticate with it. 

558 

559 Distinct from a *live* iCloud session check (which would require 

560 hitting Apple). This is the cheap on-disk signal behind the pill the 

561 dashboard shows before the sync loop has reported anything. 

562 """ 

563 if not username: 

564 return "not_configured" 

565 try: 

566 from icloudpy import utils as icloudpy_utils 

567 

568 if icloudpy_utils.password_exists_in_keyring(username): 

569 # Ask the sync loop before claiming health: every on-disk 

570 # signal still looks correct while an account is stuck on a 

571 # second factor, so "keyring populated" alone would render a 

572 # green dashboard over a sync that has not run for weeks. 

573 if web_signals.get_auth_blocked().get("blocked"): 

574 return "reauth_needed" 

575 return "ready" 

576 # An empty keyring is a supported configuration, not an unfinished 

577 # setup: the sync loop runs off the saved session alone when no 

578 # password is stored. Judge such a container on its session, or the 

579 # dashboard would tell a deliberately password-free operator to go 

580 # and finish a setup that is already finished. 

581 if _saved_session_exists(username): 

582 if web_signals.get_auth_blocked().get("blocked"): 

583 return "reauth_needed" 

584 return "ready" 

585 except Exception as e: 

586 LOGGER.debug(f"Web UI auth-state check raised: {e!s}") 

587 return "setup_needed" 

588 

589 

590def create_app(testing: bool = False) -> Flask: 

591 """Construct the Flask app. 

592 

593 Splitting this out keeps ``tests/`` able to build the app under 

594 ``TESTING=True`` without spawning a thread. 

595 """ 

596 from werkzeug.middleware.proxy_fix import ProxyFix 

597 

598 # No ``static_folder``: templates are single-file with inline CSS and 

599 # nothing ships under src/static, so wiring it would only add a 404 

600 # route for /static/*. 

601 template_dir = os.path.join(os.path.dirname(__file__), "templates") 

602 app = Flask(__name__, template_folder=template_dir) 

603 app.config["TESTING"] = testing 

604 

605 # Trust X-Forwarded-* from a single reverse-proxy hop (Cloudflare Tunnel, 

606 # Authelia / Traefik). Lets ``url_for`` produce ``https://`` URLs and 

607 # prevents Flask from mis-detecting the scheme when behind a TLS- 

608 # terminating proxy. One hop is correct here — Cloudflare → backend. 

609 app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) 

610 

611 @app.after_request 

612 def _no_cache(response): 

613 """Defense against intermediaries (browser back/forward cache, 

614 Cloudflare's auto-minify, mobile carrier proxies) serving stale 

615 dashboard or auth payloads. The dashboard is always live data — 

616 a cached snapshot would hide a missing mount marker or an 

617 expired session.""" 

618 response.headers["Cache-Control"] = "private, no-store, no-cache, must-revalidate, max-age=0" 

619 response.headers["Pragma"] = "no-cache" 

620 response.headers["Expires"] = "0" 

621 # CSRF defence: set the SameSite=Strict token cookie on every 

622 # response so forms rendered server-side can read it (via the 

623 # template) and same-site fetches automatically include it. 

624 # ``secure=False`` because the default deployment is loopback 

625 # over plain HTTP; users behind a TLS proxy benefit from the 

626 # proxy's transport security, and SameSite=Strict is the load- 

627 # bearing protection here regardless of TLS. 

628 response.set_cookie( 

629 _CSRF_COOKIE_NAME, 

630 _CSRF_TOKEN, 

631 samesite="Strict", 

632 httponly=False, 

633 secure=False, 

634 path="/", 

635 ) 

636 return response 

637 

638 @app.route("/") 

639 def dashboard(): 

640 """Render the HTML dashboard — Apple-leaning design.""" 

641 config = _load_current_config() 

642 status_payload = _build_status(config=config) 

643 log_path = _logger_filename(config=config) 

644 log_lines = _tail_log_file(path=log_path, lines=200) 

645 return render_template( 

646 "dashboard.html", 

647 status=status_payload, 

648 log_lines=log_lines, 

649 log_path=log_path, 

650 active_nav="dashboard", 

651 version=os.environ.get("APP_VERSION", ""), 

652 csrf_token=_get_csrf_token(), 

653 ) 

654 

655 @app.route("/api/health") 

656 def health(): 

657 """Tiny endpoint for external monitors. 

658 

659 - 200 ``{"state": "ok"}`` when the config file is readable. 

660 - 503 ``{"state": "config_missing"}`` when it isn't. 

661 

662 ``2fa_required`` is *not* a 503 — Apple sessions expire all the time 

663 and the dashboard must stay reachable so the user can re-auth. 

664 """ 

665 if not os.path.isfile(_current_config_path()): 

666 return jsonify({"state": "config_missing"}), 503 

667 return jsonify({"state": "ok"}) 

668 

669 @app.route("/api/status") 

670 def status(): 

671 """Live status payload for the dashboard + external consumers.""" 

672 config = _load_current_config() 

673 payload = _build_status(config=config) 

674 if not payload["config_loaded"]: 

675 return jsonify(payload), 503 

676 return jsonify(payload) 

677 

678 @app.route("/api/logs") 

679 def logs(): 

680 """Last 200 lines of the configured log file. Best-effort: missing 

681 or unreadable returns an empty list (never 500 — the dashboard 

682 relies on this being reachable to render the rest of the page).""" 

683 config = _load_current_config() 

684 return jsonify( 

685 {"lines": _tail_log_file(path=_logger_filename(config=config), lines=200)}, 

686 ) 

687 

688 @app.route("/auth", methods=["GET"]) 

689 def auth_form(): 

690 """Auth form. Renders the password field by default; renders the 

691 6-digit code field instead when ``_PENDING_AUTH`` indicates that 

692 the password step already succeeded and 2FA is pending.""" 

693 return _render_auth(message=None, message_kind=None) 

694 

695 @app.route("/auth/password", methods=["POST"]) 

696 def auth_password(): 

697 """Step 1: sign in with the password, trigger 2FA push if needed. 

698 

699 The password is not saved. 

700 

701 On success of either path: redirects — to /auth (now showing the 

702 code form) if 2FA is pending, or back to / if the cached session 

703 was still trusted. 

704 

705 Exceptions are caught and rendered as an error pill on /auth so 

706 the user sees what Apple said. 

707 """ 

708 rejection = _require_csrf() 

709 if rejection is not None: 

710 return rejection 

711 

712 password = request.form.get("password", "") 

713 if not password: 

714 return ( 

715 _render_auth(message="Password is required.", message_kind="err"), 

716 400, 

717 ) 

718 

719 config = _load_current_config() 

720 username = None 

721 if config: 

722 try: 

723 username = config_parser.get_username(config=config) 

724 except (KeyError, AttributeError, TypeError): # pragma: no cover 

725 # Defensive: get_username walks app.credentials.username; 

726 # partial configs (no credentials block) raise. Treat as 

727 # missing. Rare in practice — coverage-pragma'd because 

728 # mocking get_username globally breaks _render_auth. 

729 username = None 

730 if not username: 

731 return ( 

732 _render_auth( 

733 message="No app.credentials.username in config.yaml — set it and reload.", 

734 message_kind="err", 

735 ), 

736 400, 

737 ) 

738 

739 try: 

740 # Late import so /api/health still works if icloudpy is mid-upgrade. 

741 import icloudpy 

742 

743 api = icloudpy.ICloudPyService( 

744 apple_id=username, 

745 password=password, 

746 cookie_directory=DEFAULT_COOKIE_DIRECTORY, 

747 ) 

748 except Exception as e: 

749 LOGGER.exception("Web UI auth failed during ICloudPyService instantiation") 

750 return ( 

751 _render_auth( 

752 message=f"Authentication failed: {e!s}", 

753 message_kind="err", 

754 ), 

755 400, 

756 ) 

757 

758 stale_stored = _stale_stored_fingerprint(username, password) 

759 if api.requires_2fa: 

760 # PR 1 / fix/ios-26.4-auth dependency — best-effort. Catches all 

761 # exceptions so a missing-method or push-trigger failure doesn't 

762 # block the user from typing in a code they got via SMS. 

763 try: 

764 trigger = getattr(api, "trigger_2fa_push_notification", None) 

765 if callable(trigger): 

766 trigger() 

767 except Exception as e: 

768 LOGGER.warning(f"Web UI 2FA push trigger failed (non-fatal): {e!s}") 

769 with _AUTH_LOCK: 

770 _expire_stale_pending_auth_unlocked() 

771 _PENDING_AUTH["api"] = api 

772 _PENDING_AUTH["username"] = username 

773 _PENDING_AUTH["stale_stored"] = stale_stored 

774 _PENDING_AUTH["stashed_at"] = time.monotonic() 

775 return redirect(url_for("auth_form")) 

776 

777 _note_sign_in(username, stale_stored=stale_stored) 

778 # No 2FA needed — the sign-in refreshed the saved session, which 

779 # is all the sync loop needs. The password is not saved: the form 

780 # promises that, and ENV_ICLOUD_PASSWORD or the `icloud` command's 

781 # "Save password in keyring?" are the ways to opt in to storing one. 

782 _wake_sync_loop() 

783 return redirect(url_for("dashboard")) 

784 

785 @app.route("/auth/code", methods=["POST"]) 

786 def auth_code(): 

787 """Step 2: validate the 6-digit code on the in-flight session, 

788 trust the browser, clear pending, redirect. 

789 

790 - 400 if the code field is empty or no pending auth exists. 

791 - 400 + 'Code rejected' if Apple says no — pending kept so the 

792 user can retry without re-entering the password. 

793 - On success: validate_2fa_code -> trust_session (failures here 

794 are logged but non-fatal — the code already worked) -> 

795 clear pending -> redirect to /. The password is never saved. 

796 """ 

797 rejection = _require_csrf() 

798 if rejection is not None: 

799 return rejection 

800 

801 code = request.form.get("code", "").strip() 

802 if not code: 

803 return ( 

804 _render_auth(message="Enter the 6-digit code.", message_kind="err"), 

805 400, 

806 ) 

807 

808 with _AUTH_LOCK: 

809 _expire_stale_pending_auth_unlocked() 

810 api = _PENDING_AUTH.get("api") 

811 username = _PENDING_AUTH.get("username") 

812 stale_stored = _PENDING_AUTH.get("stale_stored") 

813 if api is None: 

814 return ( 

815 _render_auth( 

816 message="No pending auth — submit your password first.", 

817 message_kind="err", 

818 ), 

819 400, 

820 ) 

821 

822 # All exit paths from here clear ``_PENDING_AUTH`` -- including 

823 # failed validate_2fa_code, rejected codes, and trust_session 

824 # failures. Without the ``finally`` the previous code only cleared 

825 # on the success path, leaving the password sitting in process 

826 # memory if Apple raised. On rejection the user retries via 

827 # /auth/refresh-trust or by re-entering the password; we'd rather 

828 # they take that path than leave a stale credential in memory. 

829 try: 

830 try: 

831 accepted = api.validate_2fa_code(code) 

832 except Exception as e: 

833 LOGGER.exception("Web UI: validate_2fa_code raised") 

834 return ( 

835 _render_auth( 

836 message=f"2FA validation error: {e!s}", 

837 message_kind="err", 

838 ), 

839 400, 

840 ) 

841 

842 if not accepted: 

843 return ( 

844 _render_auth( 

845 message="Code rejected by Apple. Try again — make sure you copy the latest code.", 

846 message_kind="err", 

847 ), 

848 400, 

849 ) 

850 

851 # Code worked. Best-effort trust so the next session resume skips 

852 # 2FA; if that fails (e.g. cookie store write error) just log it 

853 # — the user's auth still succeeded for this session. 

854 try: 

855 api.trust_session() 

856 except Exception as e: 

857 LOGGER.warning(f"Web UI trust_session failed (non-fatal): {e!s}") 

858 

859 # The trusted session is what the sync loop resumes; the password 

860 # is not saved (see auth_password). 

861 _note_sign_in(username, stale_stored=stale_stored) 

862 _wake_sync_loop() 

863 return redirect(url_for("dashboard")) 

864 finally: 

865 with _AUTH_LOCK: 

866 _clear_pending_auth_unlocked() 

867 

868 @app.route("/auth/security-key", methods=["GET"]) 

869 def auth_security_key(): 

870 """Start a security-key (FIDO2/WebAuthn) re-auth ceremony. 

871 

872 Apple disables every other second factor once security keys are 

873 enrolled, so the 6-digit paths (``/auth/code``, the Telegram 

874 listener) can never complete for such an account -- Apple answers 

875 every factor endpoint with an ``fsaChallenge`` instead of pushing 

876 a code. 

877 

878 The assertion cannot be produced by this page: WebAuthn requires 

879 ``rpId`` to be a registrable suffix of the page origin, and 

880 Apple's ``rpId`` is ``apple.com``. Browsers additionally blocklist 

881 FIDO devices from WebHID/WebUSB precisely to stop a page doing raw 

882 CTAP against another origin. So the signing step runs natively on 

883 whatever machine holds the key, and only the signed assertion 

884 comes back here. 

885 

886 This handler authenticates with the keyring password, reads 

887 Apple's challenge, stashes the live session under ``_PENDING_AUTH`` 

888 (same slot ``/auth/code`` uses) and renders the blob the operator 

889 feeds to the signer. 

890 """ 

891 # A plain page load must never contact Apple. Each sign-in attempt 

892 # counts against a rate limit that answers 409 on /signin/init once 

893 # tripped, and a reload -- or a browser prefetch -- would otherwise 

894 # spend one. The challenge is only fetched when the operator asks 

895 # for it, and a pending one is re-rendered rather than replaced so 

896 # a command already copied stays valid. 

897 with _AUTH_LOCK: 

898 _expire_stale_pending_auth_unlocked() 

899 pending_challenge = _PENDING_AUTH.get("fsa_challenge") 

900 if pending_challenge: 

901 LOGGER.info("Web UI security-key: re-rendering the pending challenge.") 

902 return _render_auth(security_key_blob=_pack_challenge(pending_challenge)) 

903 return redirect(url_for("auth_form")) 

904 

905 @app.route("/auth/security-key/start", methods=["POST"]) 

906 def auth_security_key_start(): 

907 """Ask Apple for a challenge. This is the only path that signs in.""" 

908 rejection = _require_csrf() 

909 if rejection is not None: 

910 return rejection 

911 

912 # Pressing the button twice must not spend a second sign-in. 

913 with _AUTH_LOCK: 

914 _expire_stale_pending_auth_unlocked() 

915 pending_challenge = _PENDING_AUTH.get("fsa_challenge") 

916 if pending_challenge: 

917 LOGGER.info("Web UI security-key: reusing the pending challenge.") 

918 return _render_auth(security_key_blob=_pack_challenge(pending_challenge)) 

919 

920 

921 config = _load_current_config() 

922 username = None 

923 if config: 

924 try: 

925 username = config_parser.get_username(config=config) 

926 except (KeyError, AttributeError, TypeError): 

927 username = None 

928 if not username: 

929 return ( 

930 _render_auth( 

931 message="No app.credentials.username in config.yaml — set it first.", 

932 message_kind="err", 

933 ), 

934 400, 

935 ) 

936 

937 # The password typed on this page, used for this sign-in only. A 

938 # stored one is the fallback for containers that opted in to 

939 # storing it (ENV_ICLOUD_PASSWORD or the `icloud` command). 

940 typed = request.form.get("password") or None 

941 password = typed 

942 if password is None: 

943 try: 

944 password = _stored_password(username) 

945 except Exception as e: 

946 LOGGER.exception("Web UI security-key: keyring lookup raised") 

947 return ( 

948 _render_auth(message=f"Keyring lookup failed: {e!s}", message_kind="err"), 

949 500, 

950 ) 

951 if not password: 

952 return ( 

953 _render_auth( 

954 message="Enter your Apple ID password to start the security-key sign-in.", 

955 message_kind="warn", 

956 ), 

957 400, 

958 ) 

959 

960 try: 

961 import icloudpy 

962 

963 ceremony_dir = _ceremony_cookie_dir() 

964 api = icloudpy.ICloudPyService( 

965 apple_id=username, 

966 password=password, 

967 cookie_directory=ceremony_dir, 

968 ) 

969 except Exception as e: 

970 _discard_ceremony_dir(locals().get("ceremony_dir")) 

971 LOGGER.exception("Web UI security-key: ICloudPyService raised") 

972 if _is_signin_throttled(e): 

973 return ( 

974 _render_auth( 

975 message=( 

976 "Apple is rate-limiting sign-ins for this account " 

977 "and will not issue a challenge yet. Nothing is " 

978 "wrong with your key — wait a while before trying " 

979 "again, as each attempt extends the limit." 

980 ), 

981 message_kind="warn", 

982 ), 

983 429, 

984 ) 

985 return ( 

986 _render_auth( 

987 message=f"Sign-in failed: {e!s}", 

988 message_kind="err", 

989 ), 

990 400, 

991 ) 

992 

993 stale_stored = _stale_stored_fingerprint(username, typed) 

994 if not api.requires_2fa: 

995 _note_sign_in(username, stale_stored=stale_stored) 

996 _wake_sync_loop() 

997 return redirect(url_for("dashboard")) 

998 

999 fsa = api.security_key_challenge 

1000 if not fsa: 

1001 return ( 

1002 _render_auth( 

1003 message=( 

1004 "Apple did not offer a security-key challenge. This " 

1005 "account may still use 6-digit codes — use the form below." 

1006 ), 

1007 message_kind="warn", 

1008 ), 

1009 400, 

1010 ) 

1011 

1012 with _AUTH_LOCK: 

1013 _expire_stale_pending_auth_unlocked() 

1014 _PENDING_AUTH["api"] = api 

1015 _PENDING_AUTH["username"] = username 

1016 _PENDING_AUTH["stale_stored"] = stale_stored 

1017 _PENDING_AUTH["stashed_at"] = time.monotonic() 

1018 # Keep Apple's challenge string verbatim. It is echoed back in 

1019 # the assertion and Apple compares it byte-for-byte, so a 

1020 # re-encode differing only in base64 padding is rejected with a 

1021 # 409 -- the signer's encoding must never be trusted here. 

1022 stashed = dict(fsa) 

1023 _PENDING_AUTH["fsa_challenge"] = stashed 

1024 _PENDING_AUTH["cookie_dir"] = ceremony_dir 

1025 

1026 _record_auth_method(username, "security_key") 

1027 # Render exactly what was stashed. Handing over a different 

1028 # challenge than the one being waited on costs a physical touch to 

1029 # discover, so the two are deliberately the same object. 

1030 return _render_auth(security_key_blob=_pack_challenge(stashed)) 

1031 

1032 @app.route("/auth/security-key", methods=["POST"]) 

1033 def auth_security_key_submit(): 

1034 """Finish the ceremony: submit the signed assertion to Apple. 

1035 

1036 The assertion is bound to the ``scnt``/``X-Apple-ID-Session-Id`` 

1037 of the session stashed by the GET handler, so it must be 

1038 submitted with that same session -- which is why the signer 

1039 never needs the password and never talks to Apple itself. 

1040 """ 

1041 rejection = _require_csrf() 

1042 if rejection is not None: 

1043 return rejection 

1044 

1045 raw = request.form.get("assertion", "").strip() 

1046 if not raw: 

1047 return ( 

1048 _render_auth(message="Paste the signer output.", message_kind="err"), 

1049 400, 

1050 ) 

1051 try: 

1052 decoded = json.loads(base64.b64decode(raw)) 

1053 except Exception: 

1054 return ( 

1055 _render_auth( 

1056 message="That does not look like signer output — copy the whole line.", 

1057 message_kind="err", 

1058 ), 

1059 400, 

1060 ) 

1061 

1062 # The signer emits one assertion per candidate origin, because the 

1063 # origin is fixed inside clientData at signing time and Apple rejects 

1064 # a wrong one with the same opaque 409 it uses for everything else. 

1065 signatures = decoded if isinstance(decoded, list) else [decoded] 

1066 # Valid base64 JSON that is not signer output (``[]``, ``["x"]``) 

1067 # must get the same answer as garbage, not a traceback. 

1068 if not signatures or not all(isinstance(sig, dict) for sig in signatures): 

1069 return ( 

1070 _render_auth( 

1071 message="That does not look like signer output — copy the whole line.", 

1072 message_kind="err", 

1073 ), 

1074 400, 

1075 ) 

1076 

1077 with _AUTH_LOCK: 

1078 _expire_stale_pending_auth_unlocked() 

1079 api = _PENDING_AUTH.get("api") 

1080 username = _PENDING_AUTH.get("username") 

1081 pending_fsa = _PENDING_AUTH.get("fsa_challenge") or {} 

1082 ceremony_dir = _PENDING_AUTH.get("cookie_dir") 

1083 stale_stored = _PENDING_AUTH.get("stale_stored") 

1084 issued_challenge = pending_fsa.get("challenge") 

1085 if api is None: 

1086 return ( 

1087 _render_auth( 

1088 message=("Challenge expired — start a new security-key re-auth and sign the fresh challenge."), 

1089 message_kind="err", 

1090 ), 

1091 400, 

1092 ) 

1093 

1094 # Apple compares the echoed challenge byte-for-byte, and the signer 

1095 # round-trips it through raw bytes, so it can come back re-encoded 

1096 # (base64 padding Apple did not send). Compare on the decoded value 

1097 # and submit the stashed original. 

1098 # 

1099 # A genuine mismatch means the signed challenge is not the one this 

1100 # session is waiting on -- almost always a stale page, since every 

1101 # page load starts a fresh Apple session with a fresh challenge. 

1102 # Overwriting it here would hide that and produce an assertion whose 

1103 # clientData disagrees with its challenge field, which Apple rejects 

1104 # with an opaque 409. Say so instead. 

1105 if issued_challenge: 

1106 signed = str(signatures[0].get("challenge") or "") 

1107 if _same_challenge(signed, issued_challenge): 

1108 for signature in signatures: 

1109 signature["challenge"] = issued_challenge 

1110 else: 

1111 LOGGER.warning( 

1112 "Web UI security-key: signed challenge does not match the " 

1113 "pending one -- stale page.", 

1114 ) 

1115 with _AUTH_LOCK: 

1116 _clear_pending_auth_unlocked() 

1117 return ( 

1118 _render_auth( 

1119 message=( 

1120 "That signature is for an older challenge. Each time " 

1121 "this page loads it starts a new one — reload, copy " 

1122 "the command again, and re-sign." 

1123 ), 

1124 message_kind="err", 

1125 ), 

1126 400, 

1127 ) 

1128 

1129 try: 

1130 if not api.confirm_security_key(assertion=signatures[0]): 

1131 LOGGER.warning("Web UI security-key: Apple refused the assertion.") 

1132 return ( 

1133 _render_auth( 

1134 message=( 

1135 "Apple refused that signature. Reload to start a " 

1136 "fresh challenge and sign again." 

1137 ), 

1138 message_kind="err", 

1139 ), 

1140 400, 

1141 ) 

1142 

1143 if ceremony_dir: 

1144 _publish_ceremony_session(ceremony_dir) 

1145 # Apple accepting the assertion is not proof the sync loop can 

1146 # sign in, and claiming success without checking is how a broken 

1147 # session got reported as working before. 

1148 if not _session_authenticates(username): 

1149 LOGGER.warning( 

1150 "Web UI security-key: assertion accepted but sign-in still " 

1151 "wants a factor.", 

1152 ) 

1153 return ( 

1154 _render_auth( 

1155 message=( 

1156 "Apple accepted your key, but sign-in still asks for " 

1157 "a second factor. Start over and sign a fresh " 

1158 "challenge." 

1159 ), 

1160 message_kind="err", 

1161 ), 

1162 400, 

1163 ) 

1164 LOGGER.info("Web UI: security-key re-auth succeeded; session trusted.") 

1165 _note_sign_in(username, stale_stored=stale_stored) 

1166 _wake_sync_loop() 

1167 # The signer clears the clipboard itself once the signature is 

1168 # read, so there is nothing left for the dashboard to do here. 

1169 return redirect(url_for("dashboard")) 

1170 except Exception as e: 

1171 LOGGER.exception("Web UI security-key: assertion submit raised") 

1172 return ( 

1173 _render_auth(message=f"Assertion submit failed: {e!s}", message_kind="err"), 

1174 400, 

1175 ) 

1176 finally: 

1177 _discard_ceremony_dir(ceremony_dir) 

1178 with _AUTH_LOCK: 

1179 _clear_pending_auth_unlocked() 

1180 

1181 @app.route("/auth/reset", methods=["POST"]) 

1182 def auth_reset(): 

1183 """Escape hatch — clear any in-flight pending-auth state. 

1184 

1185 Useful when the user closed the tab mid-2FA and wants to start 

1186 over without waiting for the in-memory state to expire. 

1187 """ 

1188 rejection = _require_csrf() 

1189 if rejection is not None: 

1190 return rejection 

1191 

1192 with _AUTH_LOCK: 

1193 _clear_pending_auth_unlocked() 

1194 return redirect(url_for("auth_form")) 

1195 

1196 @app.route("/auth/forget-password", methods=["POST"]) 

1197 def auth_forget_password(): 

1198 """Delete the stored password, once the session is shown to work without it. 

1199 

1200 People who signed in before the sign-in page stopped saving the 

1201 password may not want it on disk. Deleting it while the saved 

1202 session needs it would stop sync, so the session is tried alone 

1203 first, exactly as the password-free sync loop will use it. 

1204 """ 

1205 rejection = _require_csrf() 

1206 if rejection is not None: 

1207 return rejection 

1208 

1209 config = _load_current_config() 

1210 username = None 

1211 if config: 

1212 try: 

1213 username = config_parser.get_username(config=config) 

1214 except ( 

1215 KeyError, 

1216 AttributeError, 

1217 TypeError, 

1218 ): # pragma: no cover — defensive for hand-malformed configs 

1219 username = None 

1220 if not username: 

1221 return ( 

1222 _render_auth( 

1223 message="No app.credentials.username in config.yaml — set it first.", 

1224 message_kind="err", 

1225 ), 

1226 400, 

1227 ) 

1228 if os.environ.get(ENV_ICLOUD_PASSWORD_KEY): 

1229 # The sync loop writes it back every cycle while this is set. 

1230 return ( 

1231 _render_auth( 

1232 message="This password comes from ENV_ICLOUD_PASSWORD; remove it there.", 

1233 message_kind="err", 

1234 ), 

1235 400, 

1236 ) 

1237 if not _session_authenticates(username, session_only=True): 

1238 return ( 

1239 _render_auth( 

1240 message="Sync still needs the stored password. Sign in again, then remove it.", 

1241 message_kind="err", 

1242 ), 

1243 400, 

1244 ) 

1245 

1246 from icloudpy import utils as icloudpy_utils 

1247 

1248 try: 

1249 if _stored_password(username) is not None: # a resubmit finds it gone 

1250 icloudpy_utils.delete_password_in_keyring(username) 

1251 except Exception as e: 

1252 LOGGER.exception("Web UI: keyring delete raised") 

1253 return ( 

1254 _render_auth( 

1255 message=f"Could not remove the stored password: {e!s}", 

1256 message_kind="err", 

1257 ), 

1258 500, 

1259 ) 

1260 _note_sign_in(username, stale_stored=None) 

1261 LOGGER.info("Web UI: stored password removed; syncing from the saved session") 

1262 return _render_auth( 

1263 message="Stored password removed.", 

1264 message_kind="ok", 

1265 ) 

1266 

1267 @app.route("/auth/refresh-trust", methods=["POST"]) 

1268 def auth_refresh_trust(): 

1269 """One-tap re-auth that reuses whatever credential the container has. 

1270 

1271 When Apple's trusted-session lifetime is winding down (or has 

1272 already expired since the last sync attempt), this lets the user 

1273 kick off a fresh 2FA push without having to retype their 

1274 password. Useful for "reset the clock" workflows where the 

1275 password didn't change — only the trust window did. 

1276 

1277 Flow: 

1278 1. Look up keyring password by username from config. 

1279 2. If absent → carry on without one. ``trust_session`` and the 2FA 

1280 push both work from the saved session, so a password-free 

1281 container can still use this button; only a session Apple has 

1282 already stopped accepting needs a human with a password, and 

1283 that case is reported as such. 

1284 3. Spin up a transient client, fire the 2FA push if needed, stash 

1285 the live session under the same _PENDING_AUTH dict /auth/code 

1286 already consumes. 

1287 4. Redirect to /auth — UI is now in "enter 6-digit code" mode. 

1288 """ 

1289 rejection = _require_csrf() 

1290 if rejection is not None: 

1291 return rejection 

1292 

1293 config = _load_current_config() 

1294 username = None 

1295 if config: 

1296 try: 

1297 username = config_parser.get_username(config=config) 

1298 except ( 

1299 KeyError, 

1300 AttributeError, 

1301 TypeError, 

1302 ): # pragma: no cover — defensive for hand-malformed configs 

1303 username = None 

1304 if not username: 

1305 return ( 

1306 _render_auth( 

1307 message="No app.credentials.username in config.yaml — set it first.", 

1308 message_kind="err", 

1309 ), 

1310 400, 

1311 ) 

1312 

1313 from icloudpy import exceptions as icloudpy_exceptions 

1314 

1315 try: 

1316 from icloudpy import utils as icloudpy_utils 

1317 

1318 password = icloudpy_utils.get_password_from_keyring(username) 

1319 except icloudpy_exceptions.ICloudPyNoStoredPasswordAvailableException: 

1320 # Password-free container. Not an error, and not a reason to 

1321 # refuse: the saved session is credential enough for both 

1322 # trust_session and the 2FA push. 

1323 password = None 

1324 except Exception as e: 

1325 LOGGER.exception("Web UI: keyring lookup raised") 

1326 return ( 

1327 _render_auth( 

1328 message=f"Keyring lookup failed: {e!s}", 

1329 message_kind="err", 

1330 ), 

1331 500, 

1332 ) 

1333 

1334 try: 

1335 if password is None: 

1336 # Session-only: the client refuses to sign in with 

1337 # credentials, so a dead session surfaces as the 

1338 # no-password exception below rather than a sign-in attempt. 

1339 from src import sync 

1340 

1341 api = sync.get_api_instance(username=username, password=None) 

1342 else: 

1343 import icloudpy 

1344 

1345 api = icloudpy.ICloudPyService( 

1346 apple_id=username, 

1347 password=password, 

1348 cookie_directory=DEFAULT_COOKIE_DIRECTORY, 

1349 ) 

1350 except icloudpy_exceptions.ICloudPyNoStoredPasswordAvailableException: 

1351 LOGGER.info("Web UI refresh-trust: no password and the saved session is not usable") 

1352 return ( 

1353 _render_auth( 

1354 message=( 

1355 "The saved session is no longer valid and no password is stored. " 

1356 "Enter your Apple ID password below, or run the documented " 

1357 "`icloud --username=… --session-directory=…` command to sign in " 

1358 "without storing one." 

1359 ), 

1360 message_kind="err", 

1361 ), 

1362 400, 

1363 ) 

1364 except Exception as e: 

1365 LOGGER.exception("Web UI refresh-trust: ICloudPyService raised") 

1366 return ( 

1367 _render_auth( 

1368 message=( 

1369 f"Refresh trust failed: {e!s}. The stored password may be out of date — sign in below." 

1370 ), 

1371 message_kind="err", 

1372 ), 

1373 400, 

1374 ) 

1375 

1376 if not api.requires_2fa: 

1377 # Trust window was still alive — nothing to do, sync loop is 

1378 # already authenticated. Bounce back to the dashboard with 

1379 # the success state. 

1380 _wake_sync_loop() 

1381 return redirect(url_for("dashboard")) 

1382 

1383 try: 

1384 trigger = getattr(api, "trigger_2fa_push_notification", None) 

1385 if callable(trigger): 

1386 trigger() 

1387 except Exception as e: 

1388 LOGGER.warning(f"Web UI refresh-trust 2FA push failed: {e!s}") 

1389 

1390 with _AUTH_LOCK: 

1391 _expire_stale_pending_auth_unlocked() 

1392 _PENDING_AUTH["api"] = api 

1393 _PENDING_AUTH["username"] = username 

1394 # Signed in with the stored password (or none): nothing stale, 

1395 # whatever an abandoned earlier sign-in left here. 

1396 _PENDING_AUTH["stale_stored"] = None 

1397 _PENDING_AUTH["stashed_at"] = time.monotonic() 

1398 return redirect(url_for("auth_form")) 

1399 

1400 @app.route("/api/sync", methods=["POST"]) 

1401 def api_sync(): 

1402 """Queue an immediate sync run for one or both services. 

1403 

1404 ``service=drive`` / ``service=photos`` / ``service=all``. The 

1405 web thread can't run sync.sync() directly — it would race with 

1406 the existing loop. Instead this touches a sentinel file in 

1407 ICLOUD_DOCKER_CONFIG_DIR; ``src.sync`` checks for it at the top 

1408 of each loop iteration and resets the countdown when present. 

1409 

1410 Idempotent: tapping repeatedly while a request is still queued 

1411 is a no-op (the sentinel just gets re-touched). 

1412 """ 

1413 rejection = _require_csrf() 

1414 if rejection is not None: 

1415 return rejection 

1416 

1417 service = (request.form.get("service") or request.args.get("service") or "").strip().lower() 

1418 if service == "all": 

1419 wanted = ("drive", "photos") 

1420 elif service in ("drive", "photos"): 

1421 wanted = (service,) 

1422 else: 

1423 return ( 

1424 jsonify({"error": "service must be one of: drive, photos, all"}), 

1425 400, 

1426 ) 

1427 

1428 # Honour the user's config — only queue services that are 

1429 # actually configured. Avoids touching a photos sentinel on a 

1430 # drive-only install. 

1431 config = _load_current_config() 

1432 configured = {svc for svc in ("drive", "photos") if config and svc in config} 

1433 if not configured: 

1434 return jsonify({"error": "no services configured"}), 400 

1435 

1436 queued = [svc for svc in wanted if svc in configured and web_signals.request_force_sync(svc)] 

1437 

1438 # Browser form submit gets a redirect; API consumers (curl, 

1439 # monitors) get JSON. Distinguished by Accept header. 

1440 if request.headers.get("Accept", "").startswith("application/json"): 

1441 return jsonify({"queued": queued}) 

1442 return redirect(url_for("dashboard")) 

1443 

1444 return app 

1445 

1446 

1447_SIGNER_PATH = os.path.join(os.path.dirname(__file__), "icloud_sign.py") 

1448 

1449 

1450def _lookup_auth_method(status_payload: dict[str, Any]) -> str | None: 

1451 """Recorded second factor for the configured account, if we've seen one. 

1452 

1453 Drives which route the auth page leads with: an account Apple has 

1454 answered with an ``fsaChallenge`` can never complete the 6-digit 

1455 form, so offering it first just wastes the operator's time. 

1456 """ 

1457 username = status_payload.get("username") if status_payload else None 

1458 if not username: 

1459 return None 

1460 try: 

1461 from src import web_signals 

1462 

1463 return web_signals.get_auth_method(username) 

1464 except Exception: # pragma: no cover - never break rendering over state 

1465 return None 

1466 

1467 

1468def _record_auth_method(username: str, method: str) -> None: 

1469 """Persist the account's second factor; never fatal to the auth flow.""" 

1470 try: 

1471 from src import web_signals 

1472 

1473 web_signals.record_auth_method(username=username, method=method) 

1474 except Exception as e: # pragma: no cover - bookkeeping must not break auth 

1475 LOGGER.warning(f"Web UI: recording auth method failed: {e!s}") 

1476 

1477 

1478def _pack_challenge(fsa: dict[str, Any]) -> str: 

1479 """Pack the challenge + credential handles into one short token. 

1480 

1481 Length matters: the operator copies this inside a single shell 

1482 command. Packing raw bytes (2-byte length prefix each) rather than 

1483 base64-of-JSON roughly halves it, and ``rpId`` is dropped entirely 

1484 because Apple's is always ``apple.com``. 

1485 """ 

1486 

1487 def decode(value: str) -> bytes: 

1488 return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) 

1489 

1490 parts = [decode(fsa["challenge"])] 

1491 parts.extend(decode(handle) for handle in fsa["keyHandles"]) 

1492 packed = b"".join(struct.pack("!H", len(part)) + part for part in parts) 

1493 return base64.urlsafe_b64encode(packed).decode("ascii").rstrip("=") 

1494 

1495 

1496def _build_signer_command(blob: str) -> str: 

1497 """The self-contained form: the signer source travels inside the command. 

1498 

1499 Piped to ``uv run`` on stdin, so nothing is left on the operator's disk 

1500 and the machine holding the key needs no network route back to this 

1501 container. Always offered, because the short form below cannot reach a 

1502 dashboard that sits behind proxy authentication. 

1503 """ 

1504 try: 

1505 with open(_SIGNER_PATH, encoding="utf-8") as handle: 

1506 source = handle.read() 

1507 except OSError: 

1508 return "src/icloud_sign.py is missing from this image." 

1509 return f"uv run --quiet - {blob} <<'ICLOUDSIGN'\n{source}ICLOUDSIGN" 

1510 

1511 

1512_SIGNER_REPO_RAW = "https://raw.githubusercontent.com/mandarons/icloud-docker" 

1513 

1514 

1515def _signer_source_ref() -> str | None: 

1516 """The release tag this image was built from, e.g. ``v1.29.0``. 

1517 

1518 Release images are built from ``v*`` tags and carry the version in 

1519 ``APP_VERSION``. Development and PR builds (``dev``, ``pr-123``) have no 

1520 tag to point at, so they get None. 

1521 """ 

1522 version = os.environ.get("APP_VERSION", "").strip() 

1523 if not re.fullmatch(r"\d+\.\d+\.\d+", version): 

1524 return None 

1525 return f"v{version}" 

1526 

1527 

1528def _build_short_signer_command(blob: str) -> str | None: 

1529 """``uv run <signer at this release's tag> <blob>`` -- one line. 

1530 

1531 Fetched from the public repo at the tag this image was built from: 

1532 reachable from any machine (a dashboard behind an auth proxy, as one 

1533 accepting an Apple ID password should be, cannot be fetched by uv), and 

1534 readable before running. The signer declares its fido2 dependency inline 

1535 (PEP 723), so no ``--with`` is needed. 

1536 

1537 None for a build that is not a release; the self-contained form is then 

1538 the only one offered. 

1539 """ 

1540 ref = _signer_source_ref() 

1541 if ref is None: 

1542 return None 

1543 return f"uv run --quiet {_SIGNER_REPO_RAW}/{ref}/src/icloud_sign.py {blob}" 

1544 

1545 

1546def _stored_password(username: str) -> str | None: 

1547 """The password stored in the keyring, or None if there isn't one. 

1548 

1549 icloudpy raises rather than returning None for a missing entry, and a 

1550 password-free container is a supported setup, not an error. 

1551 """ 

1552 from icloudpy import exceptions as icloudpy_exceptions 

1553 from icloudpy import utils as icloudpy_utils 

1554 

1555 try: 

1556 return icloudpy_utils.get_password_from_keyring(username) or None 

1557 except icloudpy_exceptions.ICloudPyNoStoredPasswordAvailableException: 

1558 return None 

1559 

1560 

1561def _session_authenticates(username: str, *, session_only: bool = False) -> bool: 

1562 """Confirm the sync loop can now sign in. 

1563 

1564 Apple accepting the assertion is not the same as the loop being able 

1565 to authenticate, and reporting success on the submit alone once 

1566 announced a session that still wanted a second factor. 

1567 ``session_only`` ignores any stored password, to check the loop could 

1568 sign in without one. 

1569 """ 

1570 try: 

1571 from src import sync 

1572 

1573 config = _load_current_config() 

1574 # A stored password if the operator opted in to one; otherwise the 

1575 # session alone, exactly as the password-free sync loop signs in. 

1576 api = sync.get_api_instance( 

1577 username=username, 

1578 password=None if session_only else _stored_password(username), 

1579 cookie_directory=DEFAULT_COOKIE_DIRECTORY, 

1580 server_region=config_parser.get_region(config=config) if config else "global", 

1581 ) 

1582 except Exception as e: 

1583 LOGGER.warning(f"Web UI: session failed to authenticate: {e!s}") 

1584 return False 

1585 LOGGER.info(f"Web UI: session check -> requires_2fa={api.requires_2fa}") 

1586 return not api.requires_2fa 

1587 

1588 

1589def _same_challenge(left: str, right: str) -> bool: 

1590 """Compare two base64 challenge strings by value, not spelling. 

1591 

1592 Apple emits the standard alphabet unpadded; a round-trip through raw 

1593 bytes can come back padded, or urlsafe. Those are the same challenge. 

1594 """ 

1595 

1596 def decode(value: str) -> bytes | None: 

1597 try: 

1598 return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) 

1599 except (ValueError, TypeError): 

1600 return None 

1601 

1602 decoded = decode(left) 

1603 return decoded is not None and decoded == decode(right) 

1604 

1605 

1606def _is_signin_throttled(error: Exception) -> bool: 

1607 """True when Apple refused to start a sign-in rather than to finish one. 

1608 

1609 Apple answers a rate-limited account with 409 on ``/signin/init``, the 

1610 very first SRP call, so no challenge can be issued at all. Worth 

1611 distinguishing: it is not a bad password, a bad key, or a bad 

1612 assertion, and retrying is what prolongs it. 

1613 """ 

1614 text = str(error) 

1615 return "signin/init" in text and "409" in text 

1616 

1617 

1618def _ceremony_cookie_dir() -> str: 

1619 """A private, empty cookie jar for one security-key ceremony. 

1620 

1621 icloudpy loads its jar with ``ignore_expires=True``, so cookies from a 

1622 dead session are replayed on every new sign-in -- an expired 

1623 ``X-APPLE-WEBAUTH-TOKEN`` and friends arriving alongside a fresh SRP 

1624 handshake. Apple's reply to that contradiction is 409 Conflict, which 

1625 is exactly what the assertion submit kept receiving. 

1626 

1627 The ceremony therefore runs against its own jar and publishes the 

1628 result only once Apple has accepted it. 

1629 """ 

1630 return tempfile.mkdtemp(prefix="icloud-securitykey-") 

1631 

1632 

1633def _publish_ceremony_session(cookie_dir: str) -> None: 

1634 """Move a ceremony's session files into the directory sync reads. 

1635 

1636 Called only after Apple accepts the assertion and the session is 

1637 trusted, so a failed attempt can never damage a working session. The 

1638 previous files are kept alongside with a ``.bak`` suffix. 

1639 """ 

1640 try: 

1641 os.makedirs(DEFAULT_COOKIE_DIRECTORY, exist_ok=True) 

1642 for name in os.listdir(cookie_dir): 

1643 source = os.path.join(cookie_dir, name) 

1644 target = os.path.join(DEFAULT_COOKIE_DIRECTORY, name) 

1645 if os.path.exists(target): 

1646 shutil.copy2(target, f"{target}.bak") 

1647 shutil.copy2(source, target) 

1648 LOGGER.info(f"Web UI: published ceremony session to {DEFAULT_COOKIE_DIRECTORY}.") 

1649 except OSError: 

1650 LOGGER.exception("Web UI: could not publish ceremony session") 

1651 

1652 

1653def _discard_ceremony_dir(cookie_dir: str | None) -> None: 

1654 """Remove a ceremony's private jar. Best-effort.""" 

1655 if cookie_dir: 

1656 shutil.rmtree(cookie_dir, ignore_errors=True) 

1657 

1658 

1659def _render_auth( 

1660 message: str | None = None, 

1661 message_kind: str | None = None, 

1662 security_key_blob: str | None = None, 

1663): 

1664 """Render auth.html with the current pending state and an optional 

1665 error/info pill. Factored out so the POST endpoints can reuse it. 

1666 

1667 ``security_key_blob`` carries the base64 challenge bundle for the 

1668 FIDO2 ceremony; when set the template shows the signer instructions 

1669 instead of the 6-digit code form.""" 

1670 from flask import render_template as _render 

1671 

1672 config = _load_current_config() 

1673 status_payload = _build_status(config=config) 

1674 with _AUTH_LOCK: 

1675 pending = bool(_PENDING_AUTH) 

1676 return _render( 

1677 "auth.html", 

1678 status=status_payload, 

1679 pending=pending, 

1680 message=message, 

1681 message_kind=message_kind, 

1682 active_nav="auth", 

1683 version=os.environ.get("APP_VERSION", ""), 

1684 csrf_token=_get_csrf_token(), 

1685 security_key_blob=security_key_blob, 

1686 signer_command=_build_signer_command(security_key_blob) if security_key_blob else None, 

1687 signer_source_ref=_signer_source_ref(), 

1688 signer_command_short=( 

1689 _build_short_signer_command(security_key_blob) 

1690 if security_key_blob 

1691 else None 

1692 ), 

1693 auth_method=_lookup_auth_method(status_payload), 

1694 ) 

1695 

1696 

1697def start_in_thread( 

1698 host: str = "127.0.0.1", 

1699 port: int = 8080, 

1700) -> threading.Thread: 

1701 """Launch the Flask app on a daemon thread. 

1702 

1703 The main sync loop owns the process; the web thread dies when the 

1704 parent process exits. Returns the thread, or ``None`` if the port 

1705 could not be bound (the sync loop continues regardless). 

1706 

1707 Uses ``werkzeug.serving.make_server`` rather than ``Flask.run()``: 

1708 binding happens synchronously here so a port conflict is reported 

1709 as an error instead of a false "listening" line, and it avoids the 

1710 dev-server banner. Deliberately dependency-free (no gunicorn) -- 

1711 this is a single-user, behind-a-proxy operator console (default 

1712 host 127.0.0.1), not a public-facing API. 

1713 """ 

1714 app = create_app() 

1715 

1716 # Bind in the main thread so the "listening" log is truthful: a port 

1717 # conflict raises here and is reported as a failure, instead of the 

1718 # old optimistic log racing a background bind error. 

1719 try: 

1720 server = make_server(host, port, app, threaded=True) 

1721 except OSError as e: 

1722 LOGGER.error(f"Web UI failed to bind {host}:{port} — {e!s}") 

1723 return None 

1724 

1725 def _serve_bound(): 

1726 try: 

1727 server.serve_forever() 

1728 except Exception as e: # noqa: BLE001 -- daemon thread, never crash the sync loop 

1729 LOGGER.error(f"Web UI server stopped: {e!s}") 

1730 

1731 thread = threading.Thread(target=_serve_bound, name="icloud-web-ui", daemon=True) 

1732 thread.start() 

1733 LOGGER.info(f"Web UI listening on http://{host}:{port}/ (host={host}, port={port})") 

1734 return thread