https_serve.py (12.4 KB)
1 #!/usr/bin/env python3 2 """ 3 Serve this project over HTTPS on your local network so the browser will hand it 4 geolocation + device-orientation (both require a secure context off localhost). 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 two 8 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 app itself 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 ssl 29 import sys 30 import os 31 import subprocess 32 import threading 33 from pathlib import Path 34 35 import serve # shared helpers: venv setup, QR printing, IP/port, QuietHandler 36 37 CERT_HTTP_PORT = 8001 # preferred; falls back to the next free port 38 HTTPS_PORT = 8443 # preferred; falls back to the next free port 39 SCRIPT_DIR = Path(__file__).parent.absolute() 40 CERT_DIR = SCRIPT_DIR / ".https_certs" 41 CERT_FILE = CERT_DIR / "cert.pem" 42 KEY_FILE = CERT_DIR / "key.pem" 43 44 # Path the phone fetches the root CA from. Anything else over HTTP 404s. 45 CA_DOWNLOAD_PATH = "/rootCA.pem" 46 47 48 def run_in_venv(): 49 """Re-run this script in serve.py's shared venv (qrcode), forwarding any 50 user flags (eg. --reset) to the re-exec.""" 51 python_path = serve.setup_venv() 52 try: 53 subprocess.check_call([str(python_path), __file__, "--in-venv", *sys.argv[1:]]) 54 except (KeyboardInterrupt, subprocess.CalledProcessError): 55 pass 56 sys.exit(0) 57 58 59 # get_local_ip, find_available_port, print_qr_code, and QuietHandler are shared 60 # with serve.py — reached via the `serve.` prefix below. 61 62 63 def mkcert_install_command(): 64 """Best-guess (command, package-manager-label) to install mkcert on this 65 platform, or (None, None) if no known manager is on PATH.""" 66 from shutil import which 67 if sys.platform == "darwin": 68 if which("brew"): 69 return (["brew", "install", "mkcert"], "Homebrew") 70 elif sys.platform == "win32": 71 if which("choco"): 72 return (["choco", "install", "mkcert", "-y"], "Chocolatey") 73 if which("scoop"): 74 return (["scoop", "install", "mkcert"], "Scoop") 75 else: # linux / other unix 76 # Most distros package mkcert; pick whichever manager is present. 77 if which("apt"): 78 return (["sudo", "apt", "install", "-y", "mkcert"], "apt") 79 if which("dnf"): 80 return (["sudo", "dnf", "install", "-y", "mkcert"], "dnf") 81 if which("pacman"): 82 return (["sudo", "pacman", "-S", "--noconfirm", "mkcert"], "pacman") 83 if which("brew"): 84 return (["brew", "install", "mkcert"], "Homebrew") 85 return (None, None) 86 87 88 def manual_install_hint(): 89 """Platform-appropriate manual install instructions for mkcert.""" 90 if sys.platform == "darwin": 91 return "brew install mkcert (https://github.com/FiloSottile/mkcert)" 92 if sys.platform == "win32": 93 return "choco install mkcert OR scoop install mkcert (https://github.com/FiloSottile/mkcert)" 94 return "install 'mkcert' via your package manager (https://github.com/FiloSottile/mkcert)" 95 96 97 def require_mkcert(): 98 """Ensure mkcert is on PATH, offering to install it if it isn't.""" 99 from shutil import which 100 if which("mkcert") is not None: 101 return 102 103 print("mkcert is required but was not found on PATH.") 104 cmd, label = mkcert_install_command() 105 if cmd is None: 106 print(f" Install it manually: {manual_install_hint()}") 107 sys.exit(1) 108 109 answer = input(f"Install it now with {label} ({' '.join(cmd)})? [y/N] ").strip().lower() 110 if answer not in ("y", "yes"): 111 print(f" Install it manually: {manual_install_hint()}") 112 sys.exit(1) 113 114 try: 115 subprocess.check_call(cmd) 116 except (subprocess.CalledProcessError, KeyboardInterrupt): 117 print(f"\nInstall failed. Install it manually: {manual_install_hint()}") 118 sys.exit(1) 119 120 if which("mkcert") is None: 121 print("mkcert still not on PATH after install. Open a new terminal and retry.") 122 sys.exit(1) 123 124 125 def ca_root_file(): 126 """Path to mkcert's root CA cert (rootCA.pem).""" 127 caroot = subprocess.run( 128 ["mkcert", "-CAROOT"], capture_output=True, text=True 129 ).stdout.strip() 130 return Path(caroot) / "rootCA.pem" 131 132 133 def ensure_ca_installed(): 134 """Ensure mkcert's local CA exists and is trusted by this machine. 135 May prompt for your password to write to the system trust store.""" 136 if not ca_root_file().exists(): 137 print("Setting up mkcert local CA (you may be prompted for your password)...") 138 subprocess.check_call(["mkcert", "-install"]) 139 140 141 def ensure_leaf_cert(local_ip): 142 """Mint a cert/key for this machine's LAN IP (and localhost) if missing.""" 143 CERT_DIR.mkdir(parents=True, exist_ok=True) 144 if CERT_FILE.exists() and KEY_FILE.exists(): 145 return 146 print(f"Minting certificate for {local_ip}...") 147 subprocess.check_call([ 148 "mkcert", 149 "-cert-file", str(CERT_FILE), 150 "-key-file", str(KEY_FILE), 151 local_ip, "localhost", "127.0.0.1", 152 ]) 153 154 155 def start_ca_server(local_ip): 156 """Start a plain-HTTP server that hands out the mkcert root CA, and return 157 it running. The CA is the *public* root cert (no secret), so there's no 158 reason to limit how many times or to how many devices it's served. Leave 159 it up alongside the HTTPS server so you can set up multiple devices, and 160 tear both down together on Ctrl+C. Caller owns shutdown.""" 161 ca_file = ca_root_file() 162 if not ca_file.exists(): 163 print(f"Error: root CA not found at {ca_file}") 164 sys.exit(1) 165 166 ca_bytes = ca_file.read_bytes() 167 168 class CAHandler(http.server.BaseHTTPRequestHandler): 169 def log_message(self, *args): 170 pass 171 172 def do_GET(self): 173 # Redirect bare visits to the download path so a typo'd / still works. 174 if self.path in ("/", "/index.html"): 175 self.send_response(302) 176 self.send_header("Location", CA_DOWNLOAD_PATH) 177 self.end_headers() 178 return 179 if self.path != CA_DOWNLOAD_PATH: 180 self.send_error(404) 181 return 182 # x-x509-ca-cert makes iOS offer to install it as a CA profile. 183 self.send_response(200) 184 self.send_header("Content-Type", "application/x-x509-ca-cert") 185 self.send_header("Content-Length", str(len(ca_bytes))) 186 self.send_header( 187 "Content-Disposition", 'attachment; filename="rootCA.pem"' 188 ) 189 self.end_headers() 190 try: 191 self.wfile.write(ca_bytes) 192 except (BrokenPipeError, ConnectionResetError): 193 return 194 195 port = serve.find_available_port(CERT_HTTP_PORT) 196 if port is None: 197 print(f"Error: no free port near {CERT_HTTP_PORT} for the CA server.") 198 sys.exit(1) 199 http.server.HTTPServer.allow_reuse_address = True 200 httpd = http.server.HTTPServer(("", port), CAHandler) 201 threading.Thread(target=httpd.serve_forever, daemon=True).start() 202 203 cert_url = f"http://{local_ip}:{port}{CA_DOWNLOAD_PATH}" 204 print("=" * 69) 205 print("STEP 1: install + trust the local CA on each device (first time only)") 206 print("=" * 69) 207 serve.print_qr_code(cert_url, "Scan to download the root certificate:") 208 print(f" {cert_url}") 209 print("\nOn iOS, after downloading:") 210 print(" • Settings → General → VPN & Device Management → install the profile") 211 print(" • Settings → General → About → Certificate Trust Settings →") 212 print(" toggle ON full trust for the mkcert CA") 213 print("\nOn Android, after downloading:") 214 print(" • open the downloaded file, or Settings → Security → Encryption &") 215 print(" credentials → Install a certificate → CA certificate, then confirm") 216 print(" • installing the CA is what makes it trusted; there's no separate") 217 print(" trust toggle (exact menu names vary by version/manufacturer)") 218 print("\n(skip this step on a device that already trusts the CA from a previous run)") 219 return httpd 220 221 222 def serve_https(local_ip, ca_httpd=None): 223 """Serve SCRIPT_DIR over HTTPS. Runs until Ctrl+C, then also shuts down the 224 CA HTTP server (if given).""" 225 os.chdir(SCRIPT_DIR) 226 227 # serve.QuietHandler's keep-alive applies just as well to the TLS connection: 228 # the app's files reuse one connection instead of a fresh handshake per file. 229 ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 230 ctx.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE)) 231 232 port = serve.find_available_port(HTTPS_PORT) 233 if port is None: 234 print(f"Error: no free port near {HTTPS_PORT} for the HTTPS server.") 235 if ca_httpd is not None: 236 ca_httpd.shutdown() 237 ca_httpd.server_close() 238 sys.exit(1) 239 240 with socketserver.ThreadingTCPServer(("", port), serve.QuietHandler) as httpd: 241 httpd.daemon_threads = True 242 httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) 243 https_url = f"https://{local_ip}:{port}/" 244 245 print("\n" + "=" * 59) 246 print("STEP 2: open the app over HTTPS (once the device trusts the CA)") 247 print("=" * 59) 248 serve.print_qr_code(https_url, "Scan to open the app over HTTPS:") 249 print(f" {https_url}") 250 print("\n(HTTPS off localhost is what lets the browser grant location +") 251 print(" device orientation, which the compass / nav mode need)") 252 print("\nPress Ctrl+C to stop the server(s)") 253 254 try: 255 httpd.serve_forever() 256 except KeyboardInterrupt: 257 print("\n\nShutting down...") 258 finally: 259 if ca_httpd is not None: 260 ca_httpd.shutdown() 261 ca_httpd.server_close() 262 263 264 def caroot_dir(): 265 """Path to mkcert's CAROOT folder (holds rootCA.pem and rootCA-key.pem).""" 266 caroot = subprocess.run( 267 ["mkcert", "-CAROOT"], capture_output=True, text=True 268 ).stdout.strip() 269 return Path(caroot) if caroot else None 270 271 272 def reset_certs(): 273 """Opt-in teardown, in two stages so it's safe alongside other mkcert use. 274 275 mkcert keeps ONE shared CA per machine, so anything else you run with 276 mkcert trusts the same CA. Stage 1 removes only THIS tool's own leaf certs 277 (always safe). Stage 2 removes the shared mkcert CA itself (untrust + optional 278 key deletion). Guarded, because it affects every project that uses 279 mkcert, not just this one.""" 280 from shutil import rmtree, which 281 282 # Stage 1: our own leaf certs. 283 if CERT_DIR.exists(): 284 rmtree(CERT_DIR) 285 print(f"Removed this tool's local certificates ({CERT_DIR}).") 286 else: 287 print("No local certificates to remove.") 288 289 # Stage 2: the shared mkcert CA. Skip entirely unless asked, since other 290 # projects on this machine may rely on it and we can't detect them. 291 print( 292 "\nThe mkcert CA itself is SHARED across everything you use mkcert for" 293 " on this computer, not just this tool. Leaving it installed is normal." 294 ) 295 answer = input( 296 "Also remove the shared mkcert CA (untrust it system-wide)? [y/N] " 297 ).strip().lower() 298 if answer not in ("y", "yes"): 299 print("Left the mkcert CA in place.") 300 return 301 302 if which("mkcert"): 303 try: 304 subprocess.check_call(["mkcert", "-uninstall"]) 305 print("Stopped this computer from trusting the mkcert CA.") 306 except subprocess.CalledProcessError as e: 307 print(f"mkcert -uninstall failed: {e}") 308 309 # mkcert -uninstall removes trust but does NOT delete the CA. The private key 310 # (rootCA-key.pem) is the sensitive material, so offer to delete it too, with 311 # a separate confirmation since it's irreversible and shared. 312 caroot = caroot_dir() 313 if caroot and (caroot / "rootCA-key.pem").exists(): 314 print( 315 "\nThe CA's PRIVATE KEY is still on disk at:\n" 316 f" {caroot}\n" 317 "Per mkcert: \"the rootCA-key.pem file ... gives complete power to\n" 318 "intercept secure requests from your machine. Do not share it.\"\n" 319 "Deleting it removes the shared CA entirely; future mkcert use\n" 320 "(here or in any project) will generate a brand-new one." 321 ) 322 ans2 = input("Delete the CA key + folder now? [y/N] ").strip().lower() 323 if ans2 in ("y", "yes"): 324 rmtree(caroot) 325 print(f"Removed {caroot}") 326 else: 327 print("Left the CA key in place.") 328 329 print("\nNote: this does NOT touch any device; remove or untrust the CA\n" 330 "there separately (see the instructions printed during install).") 331 332 333 def start(): 334 require_mkcert() 335 336 local_ip = serve.get_local_ip() 337 if not local_ip: 338 print("Error: could not determine this machine's LAN IP.") 339 print("Make sure you're connected to a network and try again.") 340 sys.exit(1) 341 342 ensure_ca_installed() 343 ensure_leaf_cert(local_ip) 344 ca_httpd = start_ca_server(local_ip) 345 serve_https(local_ip, ca_httpd=ca_httpd) 346 347 348 def main(): 349 if "--in-venv" not in sys.argv: 350 run_in_venv() 351 else: 352 try: 353 if "--reset" in sys.argv: 354 reset_certs() 355 else: 356 start() 357 except KeyboardInterrupt: 358 print("\nCancelled.") 359 sys.exit(130) 360 361 362 if __name__ == "__main__": 363 main()