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()