#!/usr/bin/env python3
"""Linux IPv4 VPN kill switch. Never runs on the VPN server automatically.

Own nftables output table only; survives tunnel/process exit. No keys needed.
Use from the local console. IPv6 underlay is intentionally unsupported.
"""
import argparse
import ipaddress
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys

TABLE='potato_client_guard'
MARK='potato-client-guard:v1'
CONFIG=Path('/etc/potato-client-guard.json')
PROGRAM=Path('/usr/local/libexec/potato-client-guard.py')
UNIT=Path('/etc/systemd/system/potato-client-guard.service')

def command(*args,input=None,check=True):
    result=subprocess.run(args,input=input,text=True,capture_output=True,timeout=15)
    if check and result.returncode:raise ValueError('System command failed: '+args[0])
    return result

def validate(data):
    if set(data)!={'tunnel','uplink','endpoint','port'}:raise ValueError('Invalid configuration fields')
    for field in ('tunnel','uplink'):
        if not isinstance(data[field],str) or not re.fullmatch(r'[a-zA-Z0-9_.-]{1,15}',data[field]) or data[field]=='lo':raise ValueError('Invalid interface name')
    if data['tunnel']==data['uplink']:raise ValueError('Tunnel and uplink must differ')
    address=ipaddress.IPv4Address(data['endpoint'])
    if address.is_unspecified or address.is_multicast or address.is_loopback or str(address)=='255.255.255.255':raise ValueError('Invalid IPv4 endpoint')
    if type(data['port']) is not int or not 1<=data['port']<=65535:raise ValueError('Invalid UDP port')
    return dict(data,endpoint=str(address))

def owned_table():
    result=command('nft','-j','list','table','inet',TABLE,check=False)
    if result.returncode:
        # A permission/probe failure must never be mistaken for absent state.
        listing=command('nft','-j','list','tables')
        if any(item.get('table',{}).get('family')=='inet' and item.get('table',{}).get('name')==TABLE for item in json.loads(listing.stdout)['nftables']):raise ValueError('Cannot inspect existing table')
        return False
    records=json.loads(result.stdout)['nftables']
    tables=[item['table'] for item in records if 'table' in item]
    if len(tables)!=1 or tables[0].get('comment')!=MARK:raise ValueError('Existing foreign table; no changes made')
    return True

def rules(data,replace=False):
    d=validate(data)
    return (f'delete table inet {TABLE}\n' if replace else '')+f'''table inet {TABLE} {{
 comment "{MARK}"
 chain output {{
  type filter hook output priority -150; policy accept;
  oifname "lo" counter accept
  meta nfproto ipv4 oifname "{d['tunnel']}" counter accept
  meta nfproto ipv4 oifname "{d['uplink']}" ip daddr {d['endpoint']} udp dport {d['port']} counter accept
  meta nfproto ipv4 oifname "{d['uplink']}" udp sport 68 udp dport 67 counter accept
  counter drop
 }}
}}
'''

def apply(data):
    data=validate(data)
    # An existing physical interface must never be accepted as the VPN tunnel.
    link=command('ip','-j','-d','link','show','dev',data['tunnel'],check=False)
    if link.returncode==0:
        info=json.loads(link.stdout)[0].get('linkinfo',{})
        if info.get('info_kind') not in ('wireguard','tun'):raise ValueError('Tunnel must be WireGuard or TUN')
    transaction=rules(data,owned_table())
    command('nft','--check','--file','-',input=transaction)
    command('nft','--file','-',input=transaction)

def atomic(path,data,mode):
    if path.is_symlink():raise ValueError('Refusing symlink')
    fd=os.open(str(path)+'.new',os.O_WRONLY|os.O_CREAT|os.O_EXCL,mode)
    try:
        with os.fdopen(fd,'wb') as stream:
            os.fchmod(stream.fileno(),mode);stream.write(data);stream.flush();os.fsync(stream.fileno())
        os.replace(str(path)+'.new',path)
    finally:
        if os.path.exists(str(path)+'.new'):os.unlink(str(path)+'.new')

def install(data):
    if any(p.exists() or p.is_symlink() for p in (CONFIG,PROGRAM,UNIT)):raise ValueError('Existing installation; review it before updating')
    command('systemctl','--version')
    apply(data)
    PROGRAM.parent.mkdir(parents=True,exist_ok=True)
    atomic(PROGRAM,Path(__file__).read_bytes(),0o755)
    atomic(CONFIG,(json.dumps(validate(data))+'\n').encode(),0o600)
    unit=f'''[Unit]
Description=Potato Boom persistent client kill switch
Wants=network-pre.target
Before=network-pre.target
After=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/python3 {PROGRAM} enable --config {CONFIG}
# Stopping the service keeps the blocking rules. Disable explicitly to restore normal routing.
[Install]
WantedBy=multi-user.target
'''
    atomic(UNIT,unit.encode(),0o644)
    command('systemctl','daemon-reload')
    command('systemctl','enable','--now','potato-client-guard.service')

def main():
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('action',choices=('enable','install','status','disable','uninstall'))
    parser.add_argument('--tunnel');parser.add_argument('--uplink');parser.add_argument('--endpoint');parser.add_argument('--port',type=int)
    parser.add_argument('--config',type=Path)
    args=parser.parse_args()
    if sys.platform!='linux' or os.geteuid()!=0:raise ValueError('Run as root on a Linux client')
    if args.action in ('enable','install','disable','uninstall') and os.environ.get('SSH_CONNECTION'):raise ValueError('Use the local console; this can block remote access')
    if not shutil.which('nft') or not shutil.which('ip'):raise ValueError('Install nftables and iproute2 first')
    if args.action in ('enable','install'):
        if args.config:
            if args.config.is_symlink() or args.config.stat().st_uid!=0 or args.config.stat().st_mode&0o077:raise ValueError('Config must be root-owned mode 0600')
            data=json.loads(args.config.read_text())
        else:data={name:getattr(args,name) for name in ('tunnel','uplink','endpoint','port')}
        data=validate(data)
        if args.action=='install':install(data)
        else:apply(data)
        print('CLIENT_GUARD_ENABLED')
    elif args.action=='status':print('CLIENT_GUARD_ENABLED' if owned_table() else 'CLIENT_GUARD_DISABLED')
    else:
        if args.action=='uninstall':
            # Only remove paths that identify this exact component.
            if UNIT.is_symlink() or PROGRAM.is_symlink() or CONFIG.is_symlink():raise ValueError('Unsafe installation paths')
            if not UNIT.is_file() or f'{PROGRAM} enable --config {CONFIG}' not in UNIT.read_text():raise ValueError('Unknown installation')
            command('systemctl','disable','--now','potato-client-guard.service')
        if owned_table():command('nft','delete','table','inet',TABLE)
        if args.action=='uninstall':
            for p in (UNIT,PROGRAM,CONFIG):p.unlink()
            command('systemctl','daemon-reload')
        print('CLIENT_GUARD_DISABLED_NORMAL_NETWORK_RESTORED')

if __name__=='__main__':
    try:main()
    except (ValueError,OSError,KeyError,TypeError,subprocess.TimeoutExpired,json.JSONDecodeError) as error:
        print('CLIENT_GUARD_ERROR: '+str(error),file=sys.stderr);raise SystemExit(1)
