Provision ARM Host

Provisions a new ARM64 EC2 instance (t4g.small, eu-central-1) with Claude Code, Codex, ask/ask_gpt, and a stunnel-protected SOCKS5 proxy. Run each step in order. Steps 2 and 3 take several minutes each — watch the output stream. Only the host name is variable; everything else is hardcoded. See ansible/playbooks for background and ssl/pki for the certificate setup this depends on.

Step 1 — Configuration

Enter the host name when prompted. The name should be a species of seaweed not already in use. Check ansible notes and /etc/hosts before choosing. All other parameters are hardcoded: t4g.small, eu-central-1, ARM Debian AMI (auto-selected by the playbook), SOCKS5 client port 2081 on pomelo.

HOST = input("Host name (e.g. nori): ").strip()
REGION = "eu-central-1"
INSTANCE_TYPE = "t4g.small"
ANSIBLE_DIR = "/home/john/ansible"
SOCKS_CLIENT_PORT = int(input("SOCKS port on pomelo (e.g. 2082): ").strip() or "2082")
STUNNEL_SERVER_PORT = 11080  # port on remote host

if not HOST:
    raise ValueError("Host name cannot be empty")
print(f"Host:          {HOST}")
print(f"Region:        {REGION}")
print(f"Instance type: {INSTANCE_TYPE}")
print(f"Pomelo port:   localhost:{SOCKS_CLIENT_PORT} -> {HOST}:{STUNNEL_SERVER_PORT}")

Step 2 — Launch Instance

Runs launch_instance.yml. This creates the EC2 instance, waits for SSH, then bootstraps it completely: creates the john user, copies your SSH key, installs Claude Code and copies credentials from this machine, installs Node.js, deploys the OpenAI API key and installs Codex, clones the ask repo and sets the ask_gpt alias, sets up rc.local/setdns, and adds the host to /etc/hosts and hosts.ini. Expect 5–10 minutes.

import subprocess

cmd = [
    "ansible-playbook", "launch_instance.yml",
    "-e", f"name={HOST} region={REGION} instance_type={INSTANCE_TYPE}",
    "-e", "@secrets.yml",
]
print(f"Running: {' '.join(cmd)}\n")
proc = subprocess.Popen(cmd, cwd=ANSIBLE_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in iter(proc.stdout.readline, ""):
    print(line, end="", flush=True)
proc.wait()
if proc.returncode != 0:
    raise RuntimeError(f"launch_instance.yml failed (exit {proc.returncode})")
print("\nInstance launched and bootstrapped successfully.")

Step 3 — Install SOCKS5 + Stunnel Server

Runs setup_server.yml with the socks,stunnel tags. This installs microsocks (the SOCKS5 daemon, listening on 127.0.0.1:1080), installs stunnel4, deploys the CA cert and key from ~/ssl/C/CA/ as the server identity, writes the stunnel config, and opens port 11080 in the AWS security group (restricted to pomelo's IP). Note: the stunnel config also includes notes and notes-mcp sections — those ports will be open but unused on this host, which is harmless.

import subprocess

cmd = [
    "ansible-playbook", "setup_server.yml",
    "-e", f"target={HOST} region={REGION}",
    "--tags", "socks,stunnel",
    "-e", "@secrets.yml",
]
print(f"Running: {' '.join(cmd)}\n")
proc = subprocess.Popen(cmd, cwd=ANSIBLE_DIR, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in iter(proc.stdout.readline, ""):
    print(line, end="", flush=True)
proc.wait()
if proc.returncode != 0:
    raise RuntimeError(f"setup_server.yml failed (exit {proc.returncode})")
print("\nSOCKS5 and stunnel deployed successfully.")

Step 4 — Update Pomelo Stunnel Client

Adds a new client tunnel section to pomelo's /etc/stunnel/stunnel.conf so pomelo can reach the new host's SOCKS5 proxy. Uses sudo tee to write the config (requires sudo without password for tee, or you will be prompted). Restarts stunnel4 on pomelo. The new tunnel will listen on localhost:2080 and forward to HOST:11080 over mutually-authenticated TLS.

import subprocess

STUNNEL_CONF = "/etc/stunnel/stunnel.conf"

new_section = (
    f"\n[socks5h-{HOST}]\n"
    f"client = yes\n"
    f"accept = {SOCKS_CLIENT_PORT}\n"
    f"connect = {HOST}:{STUNNEL_SERVER_PORT}\n"
    f"checkHost = John Critchley\n"
)

with open(STUNNEL_CONF) as f:
    conf = f.read()

if f"[socks5h-{HOST}]" in conf:
    print(f"Section [socks5h-{HOST}] already present — skipping")
else:
    # Insert before [imap] to keep private-CA tunnels grouped together
    if "[imap]" in conf:
        conf = conf.replace("[imap]", new_section + "\n[imap]")
    else:
        conf = conf.rstrip() + "\n" + new_section
    r = subprocess.run(["sudo", "tee", STUNNEL_CONF], input=conf, capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError(f"Failed to write {STUNNEL_CONF}: {r.stderr}")
    print(f"Added [socks5h-{HOST}] on localhost:{SOCKS_CLIENT_PORT}")

r = subprocess.run(["sudo", "systemctl", "restart", "stunnel4"], capture_output=True, text=True)
if r.returncode != 0:
    raise RuntimeError(f"Failed to restart stunnel4: {r.stderr}")
print("stunnel4 restarted on pomelo")

Step 5 — Verify

Checks that stunnel4 and microsocks are active on the remote host, and that the new tunnel port is listening on pomelo. A successful result shows active for both remote services and LISTEN for the local port.

import subprocess

# Check remote services
r = subprocess.run(
    ["ssh", HOST, "systemctl is-active stunnel4 microsocks"],
    capture_output=True, text=True
)
print(f"Remote services on {HOST}:")
for svc, state in zip(["stunnel4", "microsocks"], r.stdout.strip().splitlines()):
    status = "OK" if state == "active" else f"PROBLEM ({state})"
    print(f"  {svc}: {status}")

# Check local tunnel port
r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True)
listening = any(f":{SOCKS_CLIENT_PORT}" in line for line in r.stdout.splitlines())
print(f"\nPomelo localhost:{SOCKS_CLIENT_PORT}: {'LISTENING' if listening else 'NOT LISTENING — check stunnel4'}")
version 3  ·  created 2026-06-05  ·  updated 2026-06-05  ·  tags ['ansible', 'provisioning', 'arm', 'ec2', 'runnable']