#!/usr/bin/env python3
"""
tETHer - Ethernet tethering for Linux.
NAT a single device through an interface with optional port forwarding.
Usage:
    eth-tether <interface> --via <upstream> [--name <label>]
               [--ports <file>] [--scan <file>] [--scan-delay <seconds>]
               [--port <port>]
    eth-tether --sudoers
Examples:
    eth-tether eth0 --via wlan0
    eth-tether eth0 --via wlan0 --name camera --scan ~/camera-ports.txt
    eth-tether eth0 --via wlan0 --name camera --ports ~/camera-ports.txt
    eth-tether eth0 --port 80 --port 554
    eth-tether --sudoers | sudo tee /etc/sudoers.d/eth-tether
Port file format (one per line):
    80:80
    554:554
    # comments are ignored
"""
import argparse
import atexit
import os
import signal
import subprocess
import sys
import tempfile
import time

INTERNAL_IP = "10.42.0.1"
DHCP_RANGE_START = "10.42.0.100"
DHCP_RANGE_END = "10.42.0.200"
LEASE_FILE = "/tmp/tether-leases"
SCAN_DELAY_DEFAULT = 10

cleanup_actions = []
PRIVILEGED_COMMANDS = ["ip", "iptables", "sysctl", "dnsmasq", "pkill", "nmap", "resolvectl"]

def get_default_route_iface():
    """Return the interface used by the default route, or None."""
    result = run_capture("ip route show default")
    for line in result.splitlines():
        parts = line.split()
        if 'dev' in parts:
            return parts[parts.index('dev') + 1]
    return None

def print_sudoers(iface, via):
    user = os.environ.get("USER", "YOUR_USER")
    def which(cmd):
        return run_capture(f"which {cmd}") or f"/usr/bin/{cmd}"
    ip         = which("ip")
    ipt        = which("iptables")
    sysctl     = which("sysctl")
    dnsmasq    = which("dnsmasq")
    pkill      = which("pkill")
    nmap       = which("nmap")
    resolvectl = which("resolvectl")
    cmds = [
        f"{ip} addr flush dev {iface}",
        f"{ip} addr add {INTERNAL_IP}/24 dev {iface}",
        f"{ip} link set {iface} up",
        f"{ip} link set {iface} down",
        f"{sysctl} -w net.ipv4.ip_forward=1",
        f"{ipt} -t nat -A POSTROUTING -o {via} -j MASQUERADE",
        f"{ipt} -t nat -D POSTROUTING -o {via} -j MASQUERADE",
        f"{ipt} -A FORWARD -i {iface} -o {via} -j ACCEPT",
        f"{ipt} -D FORWARD -i {iface} -o {via} -j ACCEPT",
        r"{ipt} -A FORWARD -i {via} -o {iface} -m state --state RELATED\,ESTABLISHED -j ACCEPT".format(ipt=ipt, via=via, iface=iface),
        r"{ipt} -D FORWARD -i {via} -o {iface} -m state --state RELATED\,ESTABLISHED -j ACCEPT".format(ipt=ipt, via=via, iface=iface),
        f"{pkill} -F /tmp/tether-dnsmasq-{iface}.pid",
        f"{pkill} -HUP -F /tmp/tether-dnsmasq-{iface}.pid",
        r"{dnsmasq} ^--conf-file=/tmp/[^ ]+\.conf$".format(dnsmasq=dnsmasq),
        r"{ipt} ^-t nat -A PREROUTING -i {via} -p tcp --dport [0-9]+ -j DNAT --to-destination 10\.42\.[0-9]+\.[0-9]+:[0-9]+$".format(ipt=ipt, via=via),
        r"{ipt} ^-t nat -D PREROUTING -i {via} -p tcp --dport [0-9]+ -j DNAT --to-destination 10\.42\.[0-9]+\.[0-9]+:[0-9]+$".format(ipt=ipt, via=via),
        r"{ipt} ^-A FORWARD -p tcp -d 10\.42\.[0-9]+\.[0-9]+ --dport [0-9]+ -j ACCEPT$".format(ipt=ipt),
        r"{ipt} ^-D FORWARD -p tcp -d 10\.42\.[0-9]+\.[0-9]+ --dport [0-9]+ -j ACCEPT$".format(ipt=ipt),
        r"{nmap} ^-p- --open -T4 --host-timeout 60s -oG - 10\.42\.[0-9]+\.[0-9]+$".format(nmap=nmap),
        f"{resolvectl} dns {iface} 10.42.0.1",
        f"{resolvectl} domain {iface} tether",
        f"{resolvectl} revert {iface}",
        f"/bin/rm -f {LEASE_FILE}",
        f"/bin/rm -f /tmp/tether-dnsmasq-{iface}.pid",
    ]
    print("# Generated by eth-tether --sudoers")
    print(f"# Install with: eth-tether --sudoers {iface} {via} | sudo tee /etc/sudoers.d/eth-tether")
    print()
    for cmd in cmds:
        print(f"{user} ALL=(ALL) NOPASSWD: {cmd}")

debug_mode = False
has_resolvectl = None

def check_resolvectl():
    global has_resolvectl
    if has_resolvectl is None:
        has_resolvectl = bool(run_capture("which resolvectl"))
        if not has_resolvectl:
            print("[tETHer] resolvectl not found, skipping DNS registration")
    return has_resolvectl

def run(cmd, check=True):
    subprocess.run(cmd, shell=True, check=check)

def run_capture(cmd):
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    return result.stdout.strip()

def sudo(cmd, check=True):
    full = f"sudo {cmd}"
    if debug_mode:
        print(f"[tETHer] $ {full}")
    subprocess.run(full, shell=True, check=check)

def sudo_capture(cmd):
    return run_capture(f"sudo {cmd}")

def cleanup():
    print("\n[tETHer] Cleaning up...")
    for action in reversed(cleanup_actions):
        try:
            action()
        except Exception as e:
            print(f"[tETHer] Warning during cleanup: {e}")
    print("[tETHer] Done.")

def setup_interface(iface):
    print(f"[tETHer] Bringing up {iface} with IP {INTERNAL_IP}...")
    sudo(f"ip addr flush dev {iface}")
    sudo(f"ip addr add {INTERNAL_IP}/24 dev {iface}")
    sudo(f"ip link set {iface} up")
    cleanup_actions.append(lambda: sudo(f"ip addr flush dev {iface}", check=False))
    cleanup_actions.append(lambda: sudo(f"ip link set {iface} down", check=False))

def setup_nat(iface, via):
    print(f"[tETHer] Setting up NAT from {iface} via {via}...")
    sudo("sysctl -w net.ipv4.ip_forward=1")
    sudo(f"iptables -t nat -A POSTROUTING -o {via} -j MASQUERADE")
    sudo(f"iptables -A FORWARD -i {iface} -o {via} -j ACCEPT")
    sudo(f"iptables -A FORWARD -i {via} -o {iface} -m state --state RELATED,ESTABLISHED -j ACCEPT")
    cleanup_actions.append(lambda: sudo(f"iptables -t nat -D POSTROUTING -o {via} -j MASQUERADE", check=False))
    cleanup_actions.append(lambda: sudo(f"iptables -D FORWARD -i {iface} -o {via} -j ACCEPT", check=False))
    cleanup_actions.append(lambda: sudo(f"iptables -D FORWARD -i {via} -o {iface} -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False))

def kill_dnsmasq(iface):
    """Kill tETHer dnsmasq for this interface using pidfile."""
    pidfile = f"/tmp/tether-dnsmasq-{iface}.pid"
    if not os.path.exists(pidfile):
        return
    sudo(f"pkill -F {pidfile}", check=False)
    for _ in range(20):
        time.sleep(0.2)
        result = subprocess.run(f"pkill -0 -F {pidfile}", shell=True, capture_output=True)
        if result.returncode != 0:
            break

def start_dnsmasq(iface, conf_path=None):
    print(f"[tETHer] Starting dnsmasq on {iface}...")
    pidfile = f"/tmp/tether-dnsmasq-{iface}.pid"
    if conf_path is None:
        conf = tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False)
        conf.write(f"""interface={iface}
bind-interfaces
dhcp-range={DHCP_RANGE_START},{DHCP_RANGE_END},12h
dhcp-leasefile={LEASE_FILE}
pid-file={pidfile}
log-dhcp
log-queries
filter-AAAA
""")
        conf.flush()
        conf.close()
        conf_path = conf.name
        cleanup_actions.append(lambda: os.unlink(conf_path) if os.path.exists(conf_path) else None)
        cleanup_actions.append(lambda: os.unlink(LEASE_FILE) if os.path.exists(LEASE_FILE) else None)
    if debug_mode:
        print(f"[tETHer] dnsmasq conf ({conf_path}):")
        with open(conf_path) as f:
            for line in f:
                print(f"  {line}", end="")
    kill_dnsmasq(iface)
    sudo(f"dnsmasq --conf-file={conf_path}")
    for _ in range(20):
        if os.path.exists(pidfile):
            break
        time.sleep(0.2)
    else:
        print(f"[tETHer] Warning: dnsmasq pidfile not found after startup")
    cleanup_actions.append(lambda: kill_dnsmasq(iface))
    return conf_path

def parse_leases():
    leases = []
    if not os.path.exists(LEASE_FILE):
        return leases
    with open(LEASE_FILE) as f:
        for line in f:
            parts = line.strip().split()
            if len(parts) >= 3:
                leases.append({'mac': parts[1], 'ip': parts[2]})
    return leases

def wait_for_device(name):
    print(f"[tETHer] Waiting for '{name}' to connect...")
    while True:
        leases = parse_leases()
        if leases:
            lease = leases[0]
            print(f"[tETHer] '{name}' connected: {lease['ip']} ({lease['mac']})")
            return lease['ip'], lease['mac']
        time.sleep(2)

def watch_for_rogues(expected_mac, name):
    leases = parse_leases()
    for lease in leases:
        if lease['mac'] != expected_mac:
            print(f"[tETHer] WARNING: Unexpected device connected: {lease['mac']} (expected '{name}' {expected_mac})")

def load_ports_file(path):
    """Load port mappings from a file. Returns list of (ext_port, int_port) tuples."""
    ports = []
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith('#'):
                continue
            if ':' not in line:
                print(f"[tETHer] Warning: skipping invalid port line: {line!r}")
                continue
            ext, int_ = line.split(':', 1)
            ports.append((ext.strip(), int_.strip()))
    return ports

def save_ports_file(path, ports):
    """Save port mappings to a file."""
    with open(path, 'w') as f:
        f.write("# tETHer port forwarding config\n")
        f.write("# format: external_port:internal_port\n")
        for ext, int_ in ports:
            f.write(f"{ext}:{int_}\n")
    print(f"[tETHer] Saved port config to {path}")

def scan_ports(device_ip, delay):
    """Scan device for open TCP ports. Returns list of (port, port) tuples."""
    if delay > 0:
        print(f"[tETHer] Waiting {delay}s for services to come up...")
        time.sleep(delay)
    print(f"[tETHer] Scanning {device_ip} for open ports...")
    result = sudo_capture(f"nmap -p- --open -T4 --host-timeout 60s -oG - {device_ip}")
    ports = []
    for line in result.splitlines():
        if 'Ports:' in line:
            parts = line.split('Ports:')[1].strip().split(',')
            for part in parts:
                part = part.strip()
                if '/open/tcp' in part:
                    port = part.split('/')[0].strip()
                    ports.append((port, port))
    if ports:
        print(f"[tETHer] Found open ports: {', '.join(p[0] for p in ports)}")
    else:
        print(f"[tETHer] No open ports found on {device_ip}")
    return ports

def register_name(iface, name, device_ip, dnsmasq_conf):
    """Register a local DNS name for the device via dnsmasq + systemd-resolved."""
    print(f"[tETHer] Registering '{name}' -> {device_ip}...")
    with open(dnsmasq_conf, 'a') as f:
        f.write(f"address=/{name}.tether/{device_ip}\n")
    if debug_mode:
        print(f"[tETHer] dnsmasq conf after name registration:")
        with open(dnsmasq_conf) as f:
            for line in f:
                print(f"  {line}", end="")
    start_dnsmasq(iface, conf_path=dnsmasq_conf)
    if check_resolvectl():
        sudo(f"resolvectl dns {iface} 10.42.0.1", check=False)
        sudo(f"resolvectl domain {iface} tether", check=False)
        cleanup_actions.append(lambda: sudo(f"resolvectl revert {iface}", check=False))

def force_cleanup(iface, via):
    """Best-effort cleanup of all tETHer state for a given interface."""
    print(f"[tETHer] Forcing cleanup of {iface}...")
    kill_dnsmasq(iface)
    sudo(f"iptables -t nat -D POSTROUTING -o {via} -j MASQUERADE", check=False)
    sudo(f"iptables -D FORWARD -i {iface} -o {via} -j ACCEPT", check=False)
    sudo(f"iptables -D FORWARD -i {via} -o {iface} -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False)
    sudo(f"ip addr flush dev {iface}", check=False)
    sudo(f"ip link set {iface} down", check=False)
    if check_resolvectl():
        sudo(f"resolvectl revert {iface}", check=False)
    for tmp in [LEASE_FILE, f"/tmp/tether-dnsmasq-{iface}.pid"]:
        sudo(f"/bin/rm -f {tmp}", check=False)
    print(f"[tETHer] Cleanup done.")

def setup_port_forwards(via, device_ip, ports):
    for ext_port, int_port in ports:
        print(f"[tETHer] Port forward: {via}:{ext_port} -> {device_ip}:{int_port}")
        sudo(f"iptables -t nat -A PREROUTING -i {via} -p tcp --dport {ext_port} -j DNAT --to-destination {device_ip}:{int_port}")
        sudo(f"iptables -A FORWARD -p tcp -d {device_ip} --dport {int_port} -j ACCEPT")
        cleanup_actions.append(lambda ep=ext_port, ip=device_ip, p=int_port: (
            sudo(f"iptables -t nat -D PREROUTING -i {via} -p tcp --dport {ep} -j DNAT --to-destination {ip}:{p}", check=False),
            sudo(f"iptables -D FORWARD -p tcp -d {ip} --dport {p} -j ACCEPT", check=False),
        ))

def main():
    parser = argparse.ArgumentParser(
        description="NAT tether a single device through an ethernet interface.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__
    )
    parser.add_argument("interface", nargs="?", help="Internal interface (e.g. eth0)")
    parser.add_argument("--via", help="Upstream interface (e.g. wlan0)")
    parser.add_argument("--name", default=None, help="Label for the expected device (default: interface name)")
    parser.add_argument("--ports", metavar="FILE", help="Load port forwards from file")
    parser.add_argument("--port", metavar="PORT", type=int, action="append", dest="port",
                        help="Forward this port from the device (repeatable: --port 80 --port 554)")
    parser.add_argument("--scan", metavar="FILE",
                        help="Scan device for open ports and save to file (skips scan if file exists)")
    parser.add_argument("--scan-delay", type=int, default=SCAN_DELAY_DEFAULT, metavar="SECONDS",
                        help=f"Seconds to wait before scanning (default: {SCAN_DELAY_DEFAULT})")
    parser.add_argument("--sudoers", action="store_true", help="Print sudoers rules and exit")
    parser.add_argument("--cleanup", action="store_true", help="Force cleanup of tETHer state and exit")
    parser.add_argument("--debug", action="store_true", help="Print commands as they are run")
    args = parser.parse_args()

    global debug_mode
    debug_mode = args.debug

    if not args.interface:
        parser.error("interface is required")
    if not args.name:
        args.name = args.interface
    if not args.via:
        args.via = get_default_route_iface()
        if not args.via:
            parser.error("could not detect default route interface, please specify --via")
        if not args.sudoers:
            print(f"[tETHer] Using default route interface: {args.via}")

    if args.sudoers:
        print_sudoers(args.interface, args.via)
        return

    if args.cleanup:
        force_cleanup(args.interface, args.via)
        return

    if args.ports and args.scan:
        parser.error("--ports and --scan are mutually exclusive")

    atexit.register(cleanup)
    signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
    signal.signal(signal.SIGINT, lambda *_: sys.exit(0))

    setup_interface(args.interface)
    setup_nat(args.interface, args.via)
    dnsmasq_conf = start_dnsmasq(args.interface)
    device_ip, device_mac = wait_for_device(args.name)
    register_name(args.interface, args.name, device_ip, dnsmasq_conf)

    ports = []
    if args.ports:
        print(f"[tETHer] Loading ports from {args.ports}...")
        ports = load_ports_file(args.ports)
    elif args.scan:
        scan_file = os.path.expanduser(args.scan)
        if os.path.exists(scan_file):
            print(f"[tETHer] Loading existing scan from {scan_file}...")
            ports = load_ports_file(scan_file)
        else:
            ports = scan_ports(device_ip, args.scan_delay)
            if ports:
                save_ports_file(scan_file, ports)

    if args.port:
        for p in args.port:
            ports.append((str(p), str(p)))

    if ports:
        setup_port_forwards(args.via, device_ip, ports)
        print(f"[tETHer] Ready. '{args.name}' reachable on {args.via} via {len(ports)} port(s).")
    else:
        print(f"[tETHer] Ready. No port forwards configured.")

    print("[tETHer] Running. Press Ctrl+C to stop and clean up.")
    while True:
        watch_for_rogues(device_mac, args.name)
        time.sleep(5)

if __name__ == "__main__":
    main()