diff --git a/README.md b/README.md index b59fc2e..b18193d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,4 @@ uv run middleman.py Open `localhost:3000` and pick one of the examples. -See also [DEVELOPMENT.md](DEVELOPMENT.md). - -For deployment instructions via Dokku, see the [deployment guide](deploy-dokku.md). \ No newline at end of file +See also [DEVELOPMENT.md](DEVELOPMENT.md). \ No newline at end of file diff --git a/deploy-dokku.md b/deploy-dokku.md deleted file mode 100644 index b759eba..0000000 --- a/deploy-dokku.md +++ /dev/null @@ -1,76 +0,0 @@ -# Middleman via Dokku - -This guide explains how to set up a [Dokku](https://dokku.com) server for Middleman deployment with [Tailscale](https://tailscale.com) support. - -On a fresh host machine (tested on Debian 12), first install [Podman](https://podman.io) and verify it works properly: -```bash -sudo apt install -y podman -sudo podman run hello-world -``` - -Enable Podman system socket: -```bash -sudo systemctl enable --now podman.socket -systemctl status podman.socket --no-pager -``` - -Override the socket path by running `sudo systemctl edit podman.socket` and edit the contents to (note the empty `ListenStream`): - -``` -[Socket] -ListenStream= -ListenStream=/run/podman.sock -SocketMode=0666 -``` - -(The above step is crucial because `sudo chmod 666 /run/podman.sock` doesn't survive reboots. The `/run` directory is a temporary RAM-backed filesystem that gets wiped on boot, and the Podman socket is recreated by [systemd](https://systemd.io) with its default permissions each time.) - -Reboot the machine, then log in again and run this quick test (it should display full Podman information without throwing any errors): -```bash -CONTAINER_HOST="unix:///run/podman.sock" podman --remote info -``` - -Also run this simple check: -```bash -CONTAINER_HOST="unix:///run/podman.sock" podman --remote run hello-world -``` - -Install and configure Tailscale on the host machine by following [the official guide](https://tailscale.com/kb/1031/install-linux): -```bash -curl -fsSL https://tailscale.com/install.sh | sh -sudo tailscale up -``` - -Verify that the machine appears on the [Tailscale admin page](https://login.tailscale.com/admin/machines). - -Install Dokku by following the [official installation guide](https://dokku.com/docs/getting-started/installation): -```bash -wget -NP . https://dokku.com/install/v0.37.2/bootstrap.sh -sudo DOKKU_TAG=v0.37.2 bash bootstrap.sh -``` - -Add at least one SSH key for manual deployment. - -Create the app: -```bash -dokku apps:create middleman -dokku ports:add middleman http:80:3000 -dokku ports:add middleman https:443:3000 -dokku config:set middleman CONTAINER_HOST="unix:///run/podman.sock" -dokku docker-options:add middleman deploy "--cap-add=NET_ADMIN" -dokku docker-options:add middleman deploy "--cap-add=NET_RAW" -dokku docker-options:add middleman deploy "--device=/dev/net/tun:/dev/net/tun" -dokku docker-options:add middleman deploy,run "-v /run/podman.sock:/run/podman.sock" -``` - -Obtain the auth key from the Tailscale admin and set it: -```bash -dokku config:set middleman TS_AUTHKEY=your-tailscale-auth-key -``` - -Optionally set the domain as needed: -```bash -dokku domains:set middleman your-domain -``` - -Deploy Middleman to this Dokku instance manually. diff --git a/middleman.py b/middleman.py index 5251ae1..ff58709 100755 --- a/middleman.py +++ b/middleman.py @@ -5,10 +5,11 @@ import os import random import re -import subprocess + import sys import urllib.request import urllib.parse +import urllib.error from glob import glob from dataclasses import dataclass @@ -25,6 +26,7 @@ import zendriver as zd CDP_URL = os.getenv("CDP_URL", "http://127.0.0.1:9222") +CHROMEFLEET_URL = os.getenv("CHROMEFLEET_URL") MIDDLEMAN_DEBUG = os.getenv("MIDDLEMAN_DEBUG") MIDDLEMAN_PAUSE = os.getenv("MIDDLEMAN_PAUSE") @@ -973,153 +975,58 @@ async def check_cdp() -> bool: return False -def run_podman(args: list) -> subprocess.CompletedProcess: - cmd = ["podman"] - if os.environ.get("CONTAINER_HOST"): - cmd.append("--remote") - cmd.extend(args) - return subprocess.run(cmd, capture_output=True, text=True, check=True, encoding="utf-8", errors="replace") - - -async def setup_tailscale(container_id, name) -> str | None: - try: - print(f"{ARROW} Setting up Tailscale in container {container_id}...") - - TAILSCALE_IP_CMD = "sudo tailscale ip" - tailscale_commands = [ - "curl -fsSL https://tailscale.com/install.sh | sudo sh", - "sudo nohup tailscaled > /dev/null 2>&1 &", - f"sudo tailscale up --auth-key={os.getenv('TS_AUTHKEY')} --hostname={name}", - TAILSCALE_IP_CMD, - ] - - ip_address = None - for i, cmd in enumerate(tailscale_commands, 1): - print(f"{ARROW} Executing Tailscale setup: Step {i}/{len(tailscale_commands)}") - await asyncio.sleep(1) - result = run_podman(["exec", container_id, "sh", "-c", cmd]) - if result.returncode == 0: - if cmd == TAILSCALE_IP_CMD and result.stdout: - ip_address = result.stdout.strip().split("\n")[0] - else: - print(f"{CROSS} Failed to execute Tailscale setup step {i}: {result.stderr}") - return None - - if ip_address: - print(f"{CHECK} All Tailscale setup steps completed successfully") - print(f"{CHECK} Tailscale IP address: {ip_address}") - return ip_address - else: - print(f"{CROSS} Failed to get IP address from Tailscale") - return None - - except Exception as e: - print(f"{CROSS} Error setting up Tailscale: {e}") - return None - - -async def launch_tailscaled_chromium() -> bool: +async def launch_chromefleet_machine() -> bool: global CDP_URL try: - print(f"{ARROW} Launching local Chromium container with Tailscale...") - name = f"chromium-{nanoid.generate(FRIENDLY_CHARS, 5)}" - cmd = [ - "run", - "-d", - "--rm", - "--name", - name, - "--cap-add=NET_ADMIN", - "--cap-add=NET_RAW", - "--device", - "/dev/net/tun:/dev/net/tun", - "ghcr.io/remotebrowser/chromium-live", - ] - result = run_podman(cmd) - container_id = result.stdout.strip() - print(f"{CHECK} Container started: name={name} id={container_id}") - - await asyncio.sleep(3) - ip_address = await setup_tailscale(container_id, name) - if ip_address is None: - return False - - CDP_URL = f"http://{ip_address}:9222" - print(f"{CHECK} Set CDP_URL to {CDP_URL}") - print(f"{ARROW} Checking CDP availability...") - await asyncio.sleep(3) - for attempt in range(10): - if await check_cdp(): - print(f"{CHECK} Local Chromium CDP is ready") - return True - - if attempt == 9: - print(f"{CROSS} Failed to connect to CDP after container launch") - return False + machine_id = nanoid.generate(FRIENDLY_CHARS, 5) + print(f"{ARROW} Launching Chromium via Chrome Fleet API at {CYAN}{CHROMEFLEET_URL}{NORMAL}...") + print(f"{ARROW} Machine ID: {CYAN}{machine_id}{NORMAL}") - await asyncio.sleep(2) - - return False - - except subprocess.CalledProcessError as e: - print(f"{CROSS} Failed to launch container: {e}") - return False - except Exception as e: - print(f"{CROSS} Error launching Chromium: {e}") - return False + request_url = f"{CHROMEFLEET_URL}/api/v1/start/{machine_id}" + request = urllib.request.Request(request_url, method="GET") + with urllib.request.urlopen(request) as response: + data = json.loads(response.read().decode()) -async def launch_local_chromium() -> bool: - try: - print(f"{ARROW} Launching local Chromium container...") - cmd = [ - "run", - "-d", - "--rm", - "--network=host", - "-p", - "3001:3001", - "-p", - "9222:9222", - "ghcr.io/remotebrowser/chromium-live", - ] + if "cdp_url" not in data: + print(f"{CROSS} Chrome Fleet API response missing cdp_url field") + return False - result = run_podman(cmd) - container_id = result.stdout.strip() - print(f"{CHECK} Container started: {container_id}") + CDP_URL = data["cdp_url"] + print(f"{CHECK} Chrome Fleet launched Chromium at {CYAN}{CDP_URL}{NORMAL}") - print(f"{ARROW} Checking CDP availability...") - await asyncio.sleep(3) - for attempt in range(10): - if await check_cdp(): - print(f"{CHECK} Local Chromium CDP is ready") - return True + print(f"{ARROW} Checking CDP availability...") + await asyncio.sleep(3) + for attempt in range(10): + if await check_cdp(): + print(f"{CHECK} Chrome Fleet Chromium CDP is ready") + return True - if attempt == 9: - print(f"{CROSS} Failed to connect to CDP after container launch") - return False + if attempt == 9: + print(f"{CROSS} Failed to connect to CDP after Chrome Fleet launch") + return False - await asyncio.sleep(2) + await asyncio.sleep(2) - return False + return False - except subprocess.CalledProcessError as e: - print(f"{CROSS} Failed to launch container: {e}") + except urllib.error.HTTPError as e: + print(f"{CROSS} Chrome Fleet API request failed: {e.code} {e.reason}") return False except Exception as e: - print(f"{CROSS} Error launching Chromium: {e}") + print(f"{CROSS} Error launching Chrome Fleet Chromium: {e}") return False if __name__ == "__main__": if asyncio.run(check_cdp()) is False: print(f"{CROSS} No existing CDP found") - if os.getenv("TS_AUTHKEY"): - if asyncio.run(launch_tailscaled_chromium()) is False: - print("Fatal error: Unable to detect or launch Chromium with Tailscale!") - print("Middleman will not work properly.") - elif asyncio.run(launch_local_chromium()) is False: - print("Fatal error: Unable to launch containerized Chromium!") + if CHROMEFLEET_URL: + if asyncio.run(launch_chromefleet_machine()) is False: + print("Fatal error: Unable to launch a machine with Chrome Fleet!") + sys.exit(-1) + else: + print("Fatal error: CHROMEFLEET_URL is not set and no existing CDP found!") sys.exit(-1) result = asyncio.run(main())