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