https_serve.py (16.0 KB)
1 #!/usr/bin/env python3 2 """ 3 Serve a mixapp over HTTPS on your local network so you can install it as a PWA 4 without a public web host or tunnel. 5 6 Uses mkcert to mint a locally-trusted certificate for this machine's LAN IP. A 7 device won't trust that cert until it trusts mkcert's root CA, so we print 8 two QR codes: 9 10 1. an HTTP link that hands the device the mkcert root CA (the public root 11 cert, no secret; served to any device until you stop the script) 12 2. an HTTPS link to the mixapp itself, for PWA installation 13 14 Both servers shut down together on Ctrl+C. 15 16 After scanning the first QR code, install + trust the CA on the device: 17 iOS: Settings → General → VPN & Device Management → install the profile, 18 then Settings → General → About → Certificate Trust Settings → 19 enable full trust 20 Android: open the downloaded file and follow the prompt to install it as a 21 CA certificate 22 23 Run with --reset to revoke the CA and delete the local certificates. 24 """ 25 26 import http.server 27 import socketserver 28 import socket 29 import ssl 30 import sys 31 import os 32 import json 33 import subprocess 34 import threading 35 from pathlib import Path 36 37 import serve # reuse serve.py's track-management backend (TrackRequestHandler, SSE, etc.) 38 39 CERT_HTTP_PORT = 8001 # preferred; falls back to the next free port 40 HTTPS_PORT = 8443 # preferred; falls back to the next free port 41 SCRIPT_DIR = Path(__file__).parent.absolute() 42 CERT_DIR = SCRIPT_DIR / ".https_certs" 43 CERT_FILE = CERT_DIR / "cert.pem" 44 KEY_FILE = CERT_DIR / "key.pem" 45 MANIFEST_FILE = SCRIPT_DIR / "manifest.json" 46 47 # Path the phone fetches the root CA from. Anything else over HTTP 404s. 48 CA_DOWNLOAD_PATH = "/rootCA.pem" 49 50 51 def manifest_base_path(): 52 """The base path the PWA was built for (manifest scope/start_url). 53 The installed app launches here, so we must serve the project under 54 it or the standalone window 404s. Defaults to '/'.""" 55 try: 56 manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8")) 57 except (OSError, json.JSONDecodeError): 58 return "/" 59 base = manifest.get("scope") or manifest.get("start_url") or "/" 60 if not base.startswith("/"): 61 base = "/" + base 62 if not base.endswith("/"): 63 base = base + "/" 64 return base 65 66 67 def run_in_venv(): 68 """Re-run this script in the shared venv with qrcode available, 69 forwarding any user flags (eg. --reset) to the re-exec.""" 70 import scan 71 # mutagen: serve.py's upload path reads tags to canonicalize filenames. 72 python_path = scan.setup_venv(packages=("qrcode", "mutagen")) 73 try: 74 subprocess.check_call([str(python_path), __file__, "--in-venv", *sys.argv[1:]]) 75 except (KeyboardInterrupt, subprocess.CalledProcessError): 76 pass 77 sys.exit(0) 78 79 80 def get_local_ip(): 81 """Get the LAN IP other devices on the network can reach this machine at.""" 82 try: 83 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 84 s.connect(("8.8.8.8", 80)) 85 local_ip = s.getsockname()[0] 86 s.close() 87 return local_ip 88 except Exception: 89 return None 90 91 92 def find_available_port(start_port, max_attempts=20): 93 """Return the first bindable port at or after start_port. Uses SO_REUSEADDR 94 so a socket lingering in TIME_WAIT from a prior run doesn't block us.""" 95 for port in range(start_port, start_port + max_attempts): 96 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 97 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 98 try: 99 s.bind(("", port)) 100 return port 101 except OSError: 102 continue 103 finally: 104 s.close() 105 return None 106 107 108 def print_qr_code(url, caption): 109 """Generate and print a QR code using block characters.""" 110 try: 111 import qrcode 112 113 qr = qrcode.QRCode( 114 version=1, 115 error_correction=qrcode.constants.ERROR_CORRECT_L, 116 box_size=1, 117 border=1, 118 ) 119 qr.add_data(url) 120 qr.make(fit=True) 121 122 matrix = qr.get_matrix() 123 124 print(f"\n{caption}\n") 125 for y in range(0, len(matrix), 2): 126 line = " " 127 for x in range(len(matrix[y])): 128 top = matrix[y][x] 129 bottom = matrix[y + 1][x] if y + 1 < len(matrix) else False 130 if top and bottom: 131 line += "█" 132 elif top: 133 line += "▀" 134 elif bottom: 135 line += "▄" 136 else: 137 line += " " 138 print(line) 139 except ImportError: 140 print(f"\n{caption}\n (QR unavailable; open this URL manually) {url}") 141 except Exception as e: 142 print(f"\nCould not generate QR code: {e}\n open manually: {url}") 143 144 145 def mkcert_install_command(): 146 """Best-guess (command, package-manager-label) to install mkcert on this 147 platform, or (None, None) if no known manager is on PATH.""" 148 from shutil import which 149 if sys.platform == "darwin": 150 if which("brew"): 151 return (["brew", "install", "mkcert"], "Homebrew") 152 elif sys.platform == "win32": 153 if which("choco"): 154 return (["choco", "install", "mkcert", "-y"], "Chocolatey") 155 if which("scoop"): 156 return (["scoop", "install", "mkcert"], "Scoop") 157 else: # linux / other unix 158 # Most distros package mkcert; pick whichever manager is present. 159 if which("apt"): 160 return (["sudo", "apt", "install", "-y", "mkcert"], "apt") 161 if which("dnf"): 162 return (["sudo", "dnf", "install", "-y", "mkcert"], "dnf") 163 if which("pacman"): 164 return (["sudo", "pacman", "-S", "--noconfirm", "mkcert"], "pacman") 165 if which("brew"): 166 return (["brew", "install", "mkcert"], "Homebrew") 167 return (None, None) 168 169 170 def manual_install_hint(): 171 """Platform-appropriate manual install instructions for mkcert.""" 172 if sys.platform == "darwin": 173 return "brew install mkcert (https://github.com/FiloSottile/mkcert)" 174 if sys.platform == "win32": 175 return "choco install mkcert OR scoop install mkcert (https://github.com/FiloSottile/mkcert)" 176 return "install 'mkcert' via your package manager (https://github.com/FiloSottile/mkcert)" 177 178 179 def require_mkcert(): 180 """Ensure mkcert is on PATH, offering to install it if it isn't.""" 181 from shutil import which 182 if which("mkcert") is not None: 183 return 184 185 print("mkcert is required but was not found on PATH.") 186 cmd, label = mkcert_install_command() 187 if cmd is None: 188 print(f" Install it manually: {manual_install_hint()}") 189 sys.exit(1) 190 191 answer = input(f"Install it now with {label} ({' '.join(cmd)})? [y/N] ").strip().lower() 192 if answer not in ("y", "yes"): 193 print(f" Install it manually: {manual_install_hint()}") 194 sys.exit(1) 195 196 try: 197 subprocess.check_call(cmd) 198 except (subprocess.CalledProcessError, KeyboardInterrupt): 199 print(f"\nInstall failed. Install it manually: {manual_install_hint()}") 200 sys.exit(1) 201 202 if which("mkcert") is None: 203 print("mkcert still not on PATH after install. Open a new terminal and retry.") 204 sys.exit(1) 205 206 207 def ca_root_file(): 208 """Path to mkcert's root CA cert (rootCA.pem).""" 209 caroot = subprocess.run( 210 ["mkcert", "-CAROOT"], capture_output=True, text=True 211 ).stdout.strip() 212 return Path(caroot) / "rootCA.pem" 213 214 215 def ensure_ca_installed(): 216 """Ensure mkcert's local CA exists and is trusted by this machine. 217 May prompt for your password to write to the system trust store.""" 218 if not ca_root_file().exists(): 219 print("Setting up mkcert local CA (you may be prompted for your password)...") 220 subprocess.check_call(["mkcert", "-install"]) 221 222 223 def ensure_leaf_cert(local_ip): 224 """Mint a cert/key for this machine's LAN IP (and localhost) if missing.""" 225 CERT_DIR.mkdir(parents=True, exist_ok=True) 226 if CERT_FILE.exists() and KEY_FILE.exists(): 227 return 228 print(f"Minting certificate for {local_ip}...") 229 subprocess.check_call([ 230 "mkcert", 231 "-cert-file", str(CERT_FILE), 232 "-key-file", str(KEY_FILE), 233 local_ip, "localhost", "127.0.0.1", 234 ]) 235 236 237 def start_ca_server(local_ip): 238 """Start a plain-HTTP server that hands out the mkcert root CA, and return 239 it running. The CA is the *public* root cert (no secret), so there's no 240 reason to limit how many times or to how many devices it's served. leave 241 it up alongside the HTTPS server so you can set up multiple devices, and 242 tear both down together on Ctrl+C. Caller owns shutdown.""" 243 ca_file = ca_root_file() 244 if not ca_file.exists(): 245 print(f"Error: root CA not found at {ca_file}") 246 sys.exit(1) 247 248 ca_bytes = ca_file.read_bytes() 249 250 class CAHandler(http.server.BaseHTTPRequestHandler): 251 def log_message(self, *args): 252 pass 253 254 def do_GET(self): 255 # Redirect bare visits to the download path so a typo'd / still works. 256 if self.path in ("/", "/index.html"): 257 self.send_response(302) 258 self.send_header("Location", CA_DOWNLOAD_PATH) 259 self.end_headers() 260 return 261 if self.path != CA_DOWNLOAD_PATH: 262 self.send_error(404) 263 return 264 # x-x509-ca-cert makes iOS offer to install it as a CA profile. 265 self.send_response(200) 266 self.send_header("Content-Type", "application/x-x509-ca-cert") 267 self.send_header("Content-Length", str(len(ca_bytes))) 268 self.send_header( 269 "Content-Disposition", 'attachment; filename="rootCA.pem"' 270 ) 271 self.end_headers() 272 try: 273 self.wfile.write(ca_bytes) 274 except (BrokenPipeError, ConnectionResetError): 275 return 276 277 port = find_available_port(CERT_HTTP_PORT) 278 if port is None: 279 print(f"Error: no free port near {CERT_HTTP_PORT} for the CA server.") 280 sys.exit(1) 281 http.server.HTTPServer.allow_reuse_address = True 282 httpd = http.server.HTTPServer(("", port), CAHandler) 283 threading.Thread(target=httpd.serve_forever, daemon=True).start() 284 285 cert_url = f"http://{local_ip}:{port}{CA_DOWNLOAD_PATH}" 286 print("=" * 69) 287 print("STEP 1: install + trust the local CA on each device (first time only)") 288 print("=" * 69) 289 print_qr_code(cert_url, "Scan to download the root certificate:") 290 print(f" {cert_url}") 291 print("\nOn iOS, after downloading:") 292 print(" • Settings → General → VPN & Device Management → install the profile") 293 print(" • Settings → General → About → Certificate Trust Settings →") 294 print(" toggle ON full trust for the mkcert CA") 295 print("\nOn Android, after downloading:") 296 print(" • open the downloaded file, or Settings → Security → Encryption &") 297 print(" credentials → Install a certificate → CA certificate, then confirm") 298 print(" • installing the CA is what makes it trusted; there's no separate") 299 print(" trust toggle (exact menu names vary by version/manufacturer)") 300 print("\n(skip this step on a device that already trusts the CA from a previous run)") 301 return httpd 302 303 304 def serve_https(local_ip, base_path, ca_httpd=None): 305 """Serve SCRIPT_DIR over HTTPS for PWA installation, mounted under 306 base_path so the installed app's start_url resolves. Runs until Ctrl+C, 307 then also shuts down the CA HTTP server (if given).""" 308 os.chdir(SCRIPT_DIR) 309 310 # Rescan /mix periodically so edits made on disk reach connected browser 311 # tabs over the SSE stream, same as serve.py. 312 serve.start_rescan_ticker() 313 314 # Subclass serve.py's handler so editing works over HTTPS too: it adds the 315 # SSE change stream (/events), reorder save (POST /tracks), drag-drop 316 # upload (PUT /upload/<name>), and delete (DELETE /tracks/<name>). Those 317 # endpoints are root-absolute, so base_path stripping (GET-only) doesn't 318 # touch them. 319 class QuietHandler(serve.TrackRequestHandler): 320 def end_headers(self): 321 self.send_header("Cache-Control", "no-cache") 322 super().end_headers() 323 324 def log_message(self, *args): 325 pass 326 327 def handle(self): 328 try: 329 super().handle() 330 except (BrokenPipeError, ConnectionResetError): 331 pass 332 333 def translate_path(self, path): 334 # Strip the manifest base prefix so /mixapp_name/foo maps to ./foo. 335 if base_path != "/" and path.startswith(base_path): 336 path = "/" + path[len(base_path):] 337 return super().translate_path(path) 338 339 def do_GET(self): 340 # Bounce a bare root visit into the app so manual navigation works. 341 if base_path != "/" and self.path in ("/", "/index.html"): 342 self.send_response(302) 343 self.send_header("Location", base_path) 344 self.end_headers() 345 return 346 # super() is serve.TrackRequestHandler.do_GET, which handles 347 # /events (SSE) and rescans on /mix/tracks.json before serving. 348 return super().do_GET() 349 350 ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 351 ctx.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE)) 352 353 port = find_available_port(HTTPS_PORT) 354 if port is None: 355 print(f"Error: no free port near {HTTPS_PORT} for the HTTPS server.") 356 if ca_httpd is not None: 357 ca_httpd.shutdown() 358 ca_httpd.server_close() 359 sys.exit(1) 360 361 with socketserver.ThreadingTCPServer(("", port), QuietHandler) as httpd: 362 httpd.daemon_threads = True 363 httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) 364 https_url = f"https://{local_ip}:{port}{base_path}" 365 366 print("\n" + "=" * 59) 367 print("STEP 2: install your mixapp (once the device trusts the CA)") 368 print("=" * 59) 369 print_qr_code(https_url, "Scan to open your mixapp over HTTPS:") 370 print(f" {https_url}") 371 print("\nThen install it as a PWA: https://hunterirving.github.io/web_workshop/pages/pwa") 372 print("\nPress Ctrl+C to stop the server(s)") 373 374 try: 375 httpd.serve_forever() 376 except KeyboardInterrupt: 377 print("\n\nShutting down...") 378 finally: 379 if ca_httpd is not None: 380 ca_httpd.shutdown() 381 ca_httpd.server_close() 382 383 384 def caroot_dir(): 385 """Path to mkcert's CAROOT folder (holds rootCA.pem and rootCA-key.pem).""" 386 caroot = subprocess.run( 387 ["mkcert", "-CAROOT"], capture_output=True, text=True 388 ).stdout.strip() 389 return Path(caroot) if caroot else None 390 391 392 def reset_certs(): 393 """Opt-in teardown, in two stages so it's safe alongside other mkcert use. 394 395 mkcert keeps ONE shared CA per machine, so anything else you run with 396 mkcert trusts the same CA. Stage 1 removes only THIS tool's own leaf certs 397 (always safe). Stage 2 removes the shared mkcert CA itself (untrust + optional 398 key deletion). Guarded, because it affects every project that uses 399 mkcert, not just this one.""" 400 from shutil import rmtree, which 401 402 # Stage 1: our own leaf certs. 403 if CERT_DIR.exists(): 404 rmtree(CERT_DIR) 405 print(f"Removed this tool's local certificates ({CERT_DIR}).") 406 else: 407 print("No local certificates to remove.") 408 409 # Stage 2: the shared mkcert CA. Skip entirely unless asked, since other 410 # projects on this machine may rely on it and we can't detect them. 411 print( 412 "\nThe mkcert CA itself is SHARED across everything you use mkcert for" 413 " on this computer, not just this tool. Leaving it installed is normal." 414 ) 415 answer = input( 416 "Also remove the shared mkcert CA (untrust it system-wide)? [y/N] " 417 ).strip().lower() 418 if answer not in ("y", "yes"): 419 print("Left the mkcert CA in place.") 420 return 421 422 if which("mkcert"): 423 try: 424 subprocess.check_call(["mkcert", "-uninstall"]) 425 print("Stopped this computer from trusting the mkcert CA.") 426 except subprocess.CalledProcessError as e: 427 print(f"mkcert -uninstall failed: {e}") 428 429 # mkcert -uninstall removes trust but does NOT delete the CA. The private key 430 # (rootCA-key.pem) is the sensitive material, so offer to delete it too, with 431 # a separate confirmation since it's irreversible and shared. 432 caroot = caroot_dir() 433 if caroot and (caroot / "rootCA-key.pem").exists(): 434 print( 435 "\nThe CA's PRIVATE KEY is still on disk at:\n" 436 f" {caroot}\n" 437 "Per mkcert: \"the rootCA-key.pem file ... gives complete power to\n" 438 "intercept secure requests from your machine. Do not share it.\"\n" 439 "Deleting it removes the shared CA entirely; future mkcert use\n" 440 "(here or in any project) will generate a brand-new one." 441 ) 442 ans2 = input("Delete the CA key + folder now? [y/N] ").strip().lower() 443 if ans2 in ("y", "yes"): 444 rmtree(caroot) 445 print(f"Removed {caroot}") 446 else: 447 print("Left the CA key in place.") 448 449 print("\nNote: this does NOT touch any device; remove or untrust the CA\n" 450 "there separately (see the instructions printed during install).") 451 452 453 def start(): 454 require_mkcert() 455 456 local_ip = get_local_ip() 457 if not local_ip: 458 print("Error: could not determine this machine's LAN IP.") 459 print("Make sure you're connected to a network and try again.") 460 sys.exit(1) 461 462 base_path = manifest_base_path() 463 if not MANIFEST_FILE.exists(): 464 print("Warning: manifest.json not found. Run ./build.py first so the\n" 465 "PWA is installable. Serving at root for now.\n") 466 467 ensure_ca_installed() 468 ensure_leaf_cert(local_ip) 469 ca_httpd = start_ca_server(local_ip) 470 serve_https(local_ip, base_path, ca_httpd=ca_httpd) 471 472 473 def main(): 474 if "--in-venv" not in sys.argv: 475 run_in_venv() 476 else: 477 try: 478 if "--reset" in sys.argv: 479 reset_certs() 480 else: 481 start() 482 except KeyboardInterrupt: 483 print("\nCancelled.") 484 sys.exit(130) 485 486 487 if __name__ == "__main__": 488 main()