#!/usr/bin/env python3
"""peer.py - meet at a word, end up connected.

BOTH SIDES RUN THE SAME LINE:

    python3 peer.py -k <word>

The word is the whole credential. Whoever gets there first is told to host: it spawns a cloudflared quick tunnel at its own SSH
port and offers the address. The second one claims that address and connects.
Nobody picks a role, because picking one is how you end up opening a door on the
wrong machine.

    --host / --join     force a role instead of taking the one you are given.
                        Worth it when you KNOW which machine should be reachable
                        and would rather fail than find out the hard way.

Requires cloudflared on the hosting side, and SSH actually listening there. On
macOS that is System Settings > General > Sharing > Remote Login; a tunnel to a
closed port comes up looking perfectly healthy and then refuses every
connection, so this checks before publishing.

Stdlib only - macOS ships python3 and ssh, and nothing here needs more.
"""

import argparse
import atexit
import json
import os
import re
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request

# The public meetup. It only ever holds a word -> address pair for a few
# minutes; the session itself goes peer-to-peer through the tunnel and never
# touches this host. Override with --server to run your own.
DEFAULT_SERVER = 'https://we.g0t.io/api'

def _find_cf():
    """Prefer a cloudflared sitting next to this script.

    mac.sh unpacks one into the working directory on purpose: installing to
    /usr/local/bin needs sudo, and Homebrew needs Homebrew. A local copy needs
    neither, so a peer with a stock Mac and no developer tooling can still host.
    """
    here = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cloudflared')
    if os.path.isfile(here) and os.access(here, os.X_OK):
        return here
    local = os.path.abspath('./cloudflared')
    if os.path.isfile(local) and os.access(local, os.X_OK):
        return local
    return 'cloudflared'


CF = _find_cf()
UA = 'Mozilla/5.0 (peer.py; +https://we.g0t.io/)'
URL_RE = re.compile(r'https://[a-zA-Z0-9-]+\.trycloudflare\.com')


def post(base, path, fields, timeout=15):
    """One form POST. Returns (http_status, parsed_json_or_None)."""
    data = urllib.parse.urlencode(fields).encode('utf-8')
    req = urllib.request.Request(base.rstrip('/') + path, data=data, method='POST')
    req.add_header('Content-Type', 'application/x-www-form-urlencoded')
    # Cloudflare sits in front of the meetup and answers the default
    # Python-urllib/3.x User-Agent with a 403 challenge page, so every call
    # fails looking exactly like an expired ticket. Claim a browser-shaped
    # agent instead. (Cost Jeff three downloads and an evening, 2026-10-04.)
    req.add_header('User-Agent', UA)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return r.status, json.loads(r.read().decode('utf-8', 'replace') or '{}')
    except urllib.error.HTTPError as e:
        body = e.read().decode('utf-8', 'replace')
        try:
            return e.code, json.loads(body or '{}')
        except Exception:
            return e.code, None
    except Exception as e:
        print('!! cannot reach %s: %s' % (base, e))
        return 0, None


def local_port_open(hostport):
    """Is anything actually listening where we are about to point a tunnel?"""
    m = re.match(r'^\w+://([^:/]+):?(\d+)?', hostport)
    if not m:
        return None
    host = m.group(1)
    port = int(m.group(2) or (22 if hostport.startswith('ssh') else 80))
    try:
        with socket.create_connection((host, port), timeout=3):
            return True
    except Exception:
        return False


def isolated_cf_config():
    """A throwaway cloudflared config, so we never inherit the machine's own.

    cloudflared reads ~/.cloudflared/config.yml even when you pass --url, and an
    ingress: list there WINS. A machine already running a named tunnel typically
    ends its ingress with `service: http_status:404`, so our quick-tunnel hostname
    matches no rule, falls through to that catch-all, and every request comes back
    404 - from a tunnel whose own metrics show the requests arriving. That cost an
    hour to find once (Daniel's box, 2026-10-04); it is not worth finding twice,
    and a peer helping you debug would have no reason to suspect their own config
    file.

    An empty file makes cloudflared log an error, so write one harmless key.
    """
    fd, path = tempfile.mkstemp(prefix='peer-cf-', suffix='.yml')
    with os.fdopen(fd, 'w') as f:
        f.write('no-autoupdate: true\n')
    atexit.register(lambda: _quiet_unlink(path))
    return path


def _quiet_unlink(path):
    try:
        os.unlink(path)
    except Exception:
        pass


def take_role(args):
    """Ask the meetup who we are. Returns 'host' or 'join', or None on refusal."""
    if args.host:
        return 'host'
    if args.join:
        return 'join'
    status, body = post(args.server, '/meet',
                        {'ticket': args.ticket, 'keyword': args.keyword})
    if status == 403:
        print('!! that ticket is expired or unknown. Get a fresh one from the page.')
        return None
    if status == 409:
        print('!! %s' % ((body or {}).get('error') or 'that word is already in use'))
        return None
    if status != 200 or not body or not body.get('ok'):
        print('!! the meetup would not take us: %s'
              % ((body or {}).get('error') if body else 'no answer'))
        return None
    return body.get('role')


def run_host(args):
    expose = args.expose
    ok = local_port_open(expose)
    if ok is False:
        print('!! nothing is listening on %s' % expose)
        if expose.startswith('ssh'):
            print('   On macOS turn on: System Settings > General > Sharing > Remote Login.')
            print('   A tunnel to a closed port comes up fine and then refuses every')
            print('   connection, so this is worth fixing before publishing.')
        if not args.force:
            return 2
        print('   --force given: publishing anyway.')

    print('-- you are the host. Starting a tunnel for %s ...' % expose)
    cfg = isolated_cf_config()
    try:
        proc = subprocess.Popen([CF, 'tunnel', '--config', cfg, '--url', expose],
                                stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    except FileNotFoundError:
        print('!! %s is not installed or not on PATH.' % CF)
        print('   macOS, no Homebrew needed:')
        print('     curl -fsSL https://we.g0t.io/dl/cloudflared-darwin-$(uname -m).tgz | tar xz')
        print('   That drops it right here, which is where this script looks first.')
        return 3

    def shutdown(*_):
        print('\n-- taking the tunnel down')
        try:
            proc.terminate()
        except Exception:
            pass
        # Withdraw the word so the next session can reuse it immediately, and so
        # a stale address is never handed to anyone.
        post(args.server, '/release',
             {'ticket': args.ticket, 'keyword': args.keyword}, timeout=5)
        sys.exit(0)

    signal.signal(signal.SIGINT, shutdown)
    try:
        signal.signal(signal.SIGTERM, shutdown)
    except Exception:
        pass

    url, buf, deadline = None, '', time.time() + 45
    # cloudflared prints the address inside a log banner and the read boundary can
    # land mid-URL, so accumulate rather than matching each chunk alone.
    while time.time() < deadline:
        line = proc.stdout.readline()
        if not line:
            break
        buf += line.decode('utf-8', 'replace')
        m = URL_RE.search(buf)
        if m:
            url = m.group(0)
            break
        if len(buf) > 65536:
            buf = buf[-4096:]

    if not url:
        print('!! no tunnel address after 45s - is this machine online?')
        try:
            proc.terminate()
        except Exception:
            pass
        return 4

    print('-- tunnel up: %s' % url)
    status, body = post(args.server, '/offer',
                        {'ticket': args.ticket, 'keyword': args.keyword, 'url': url})
    if status != 200 or not body or not body.get('ok'):
        print('!! could not hand the address to the meetup: %s'
              % ((body or {}).get('error') if body else 'no answer'))
        try:
            proc.terminate()
        except Exception:
            pass
        return 5

    print('-- offered under your word. The other side can connect now.')
    print('-- LEAVE THIS WINDOW OPEN. Ctrl+C takes the tunnel down and withdraws the word.')
    try:
        proc.wait()
    except KeyboardInterrupt:
        shutdown()
    return 0


def run_join(args):
    print('-- you are joining. Waiting for the other side ...')
    deadline = time.time() + args.wait
    url = None
    while time.time() < deadline:
        status, body = post(args.server, '/claim',
                            {'ticket': args.ticket, 'keyword': args.keyword})
        if status == 200 and body and body.get('url'):
            url = body['url']
            break
        if status == 403:
            print('!! that ticket is expired or unknown. Get a fresh one from the page.')
            return 3
        if status == 404:
            # Not met yet, or the host dropped. Keep waiting - the whole point is
            # that the two of you do not have to be at the keyboard together.
            pass
        sys.stdout.write('.')
        sys.stdout.flush()
        time.sleep(args.poll)
    print('')

    if not url:
        print('!! nobody showed up on that word within %ds.' % args.wait)
        print('   Check you both typed it the same, then try again.')
        return 4

    host = urllib.parse.urlparse(url).hostname or url
    print('-- the other side is at: %s' % url)
    print('')
    user = args.user or '<their-username>'
    print('   Shell on their machine:')
    print('     ssh -o ProxyCommand="cloudflared access ssh --hostname %s" %s@%s'
          % (host, user, host))
    print('')
    print('   As a ~/.ssh/config block:')
    print('     Host peer')
    print('       HostName %s' % host)
    print('       ProxyCommand cloudflared access ssh --hostname %s' % host)
    print('')

    if not args.connect:
        print('-- not connecting automatically (pass --connect for that).')
        return 0

    if not args.user:
        print('!! --connect needs --user <their-username>.')
        return 5
    cmd = ['ssh', '-o', 'ProxyCommand=cloudflared access ssh --hostname %s' % host,
           '%s@%s' % (args.user, host)]
    print('-- connecting: %s' % ' '.join(cmd))
    try:
        return subprocess.call(cmd)
    except FileNotFoundError:
        print('!! ssh is not on PATH.')
        return 6


def main():
    ap = argparse.ArgumentParser(description='meet at a word, end up connected')
    ap.add_argument('-k', '--keyword', required=True, help='the word you both agreed on')
    ap.add_argument('-t', '--ticket', default=os.environ.get('PEER_TICKET', ''),
                    help='only needed on a gated meetup; otherwise fetched for you')
    ap.add_argument('--server', default=DEFAULT_SERVER,
                    help='the meetup server (default: %s)' % DEFAULT_SERVER)
    ap.add_argument('--expose', default='ssh://localhost:22',
                    help='host side: what the tunnel points at (default local SSH)')
    ap.add_argument('--user', help='join side: the username on their machine')
    ap.add_argument('--connect', action='store_true',
                    help='join side: actually run ssh instead of printing it')
    ap.add_argument('--wait', type=int, default=600,
                    help='join side: seconds to keep waiting (default 600)')
    ap.add_argument('--poll', type=float, default=2.0,
                    help='join side: seconds between checks')
    ap.add_argument('--force', action='store_true',
                    help='host side: publish even if the local port looks closed')
    role = ap.add_mutually_exclusive_group()
    role.add_argument('--host', action='store_true',
                      help='force the hosting role instead of taking the one assigned')
    role.add_argument('--join', action='store_true',
                      help='force the joining role instead of taking the one assigned')
    args = ap.parse_args()

    if not args.ticket:
        # Open meetup: the WORD is the credential, so there is nobody to be, and
        # a ticket is just the handle that tells the room which of the two seats
        # is yours. Fetch one rather than making people carry it on the command
        # line. (If the server is gated, this comes back 403 and we say so.)
        status, body = post(args.server, '/ticket', {})
        if status == 200 and body and body.get('ticket'):
            args.ticket = body['ticket']
        elif status == 403:
            print('!! this meetup needs an account. Sign in at %s and use the'
                  % DEFAULT_SERVER.rsplit('/', 1)[0])
            print('   line it gives you, which carries a ticket.')
            return 2
        else:
            print('!! could not reach the meetup at %s' % args.server)
            return 2

    r = take_role(args)
    if r is None:
        return 1
    return run_host(args) if r == 'host' else run_join(args)


if __name__ == '__main__':
    sys.exit(main())
