#!/usr/bin/env python3
"""Interactive host IPsec setup, launched by the authenticated admin terminal."""
import fnmatch
import getpass
import ipaddress
import os
from pathlib import Path
import re
import subprocess


def validate(name, peer, local, remote, secret):
    if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,63}', name):
        raise ValueError('Invalid connection name')
    if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,252}', peer):
        raise ValueError('Invalid remote public IP or hostname')
    local = str(ipaddress.IPv4Network(local, strict=True))
    remote = str(ipaddress.IPv4Network(remote, strict=True))
    if not secret or any(ord(c) < 32 or ord(c) == 127 for c in secret):
        raise ValueError('The shared secret must be nonempty and on one line')
    return local, remote


def regular(path):
    if path.is_symlink() or (path.exists() and not path.is_file()):
        raise ValueError('Expected a regular file: ' + str(path))


def included(path, target):
    regular(path)
    for line in path.read_text().splitlines():
        match = re.match(r'^\s*include\s+(\S+)\s*(?:#.*)?$', line)
        if match and fnmatch.fnmatch(str(target), match[1]):
            return True
    return False


def save_connection(root, name, peer, local, remote, secret):
    local, remote = validate(name, peer, local, remote, secret)
    directory = root / 'ipsec.d'
    if directory.is_symlink() or not directory.is_dir():
        raise ValueError('Host /etc/ipsec.d must be an existing directory')
    connection = 'mykos-' + name
    config = directory / (connection + '.conn.conf')
    secrets = directory / (connection + '.secrets')
    for path in (config, secrets):
        regular(path)
        if path.exists():
            raise ValueError('Connection already exists: ' + name)
    # Use the host's existing Libreswan includes; never rewrite its global config.
    if not included(root / 'ipsec.conf', config):
        raise ValueError('Add an include for ' + str(directory / '*.conf') + ' to ' + str(root / 'ipsec.conf'))
    if not included(root / 'ipsec.secrets', secrets):
        raise ValueError('Add an include for ' + str(directory / '*.secrets') + ' to ' + str(root / 'ipsec.secrets'))
    escaped = secret.replace('\\', '\\\\').replace('"', '\\"')
    config_text = ('conn ' + connection + '\n'
                   '  ikev2=insist\n  authby=secret\n'
                   '  left=%defaultroute\n  leftsubnet=' + local + '\n'
                   '  right=' + peer + '\n  rightsubnet=' + remote + '\n'
                   '  auto=start\n  encapsulation=yes\n')
    # Exclusive creation prevents replacing an existing tunnel or its PSK.
    created = []
    try:
        for path, text in ((secrets, '%any ' + peer + ' : PSK "' + escaped + '"\n'), (config, config_text)):
            fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
            created.append(path)
            with os.fdopen(fd, 'w') as handle:
                handle.write(text)
    except Exception:
        for path in created:
            path.unlink()
        raise
    return connection


def main():
    print('Add VPN on host ' + os.uname().nodename)
    name = input('Connection name: ').strip()
    peer = input('Remote public IPv4 address or hostname: ').strip()
    local = input('Local IPv4 subnet (CIDR, e.g. 192.168.222.0/24): ').strip()
    remote = input('Remote IPv4 subnet (CIDR): ').strip()
    secret = getpass.getpass('VPN shared secret (PSK): ')
    validate(name, peer, local, remote, secret)
    print('Host tunnel: ' + local + ' -> ' + peer + ' -> ' + remote)
    if input('Create and load this host VPN? [y/N] ').strip().lower() != 'y':
        print('Cancelled.'); return
    connection = save_connection(Path('/etc'), name, peer, local, remote, secret)
    print('Saved host connection ' + connection + '. Existing VPNs were not restarted.')
    subprocess.run(['/usr/sbin/ipsec', 'auto', '--rereadsecrets'], check=True)
    subprocess.run(['/usr/sbin/ipsec', 'auto', '--add', connection], check=True)
    print('Connection loaded. Negotiating with the peer...')
    subprocess.run(['/usr/sbin/ipsec', 'auto', '--up', connection], check=True)


if __name__ == '__main__':
    try:
        main()
    except (ValueError, OSError, subprocess.CalledProcessError) as error:
        raise SystemExit('Host VPN setup: ' + str(error))
