410 lines
15 KiB
Python
410 lines
15 KiB
Python
from configparser import ConfigParser
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from syslog import syslog
|
|
from time import sleep
|
|
import ipaddress
|
|
import json
|
|
import subprocess
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from .exceptions import ClientLimitError, ValiditySpecificationError
|
|
from .qr import generate_qr
|
|
|
|
|
|
confsuffix = '.conf'
|
|
serversuffix = '.serverconf'
|
|
metasuffix = '.json'
|
|
|
|
workdir = Path('./work')
|
|
lockfile = workdir.joinpath('lockfile.lock~')
|
|
|
|
|
|
def safe_join(*args) -> Path:
|
|
'''
|
|
Similar to flask's own safe_join, but uses Path objects
|
|
instead of strings
|
|
'''
|
|
base_path = args[0]
|
|
joined_path = Path.joinpath(*args)
|
|
try:
|
|
joined_path.relative_to(base_path)
|
|
except ValueError:
|
|
return None
|
|
return joined_path
|
|
|
|
def call_with_lock(func: callable, args: list=[], timeout: float=None):
|
|
sleep_time = 0.1
|
|
slept = 0
|
|
while timeout is None or slept < timeout:
|
|
try:
|
|
with open(lockfile, 'x') as lf:
|
|
print(f'Lock successful after {slept}s, '
|
|
f'tried on {sleep_time}s intervals.')
|
|
try:
|
|
result = func(*args)
|
|
return result
|
|
except Exception as e:
|
|
raise e
|
|
finally:
|
|
lockfile.unlink()
|
|
except FileExistsError:
|
|
slept += sleep_time
|
|
sleep(sleep_time)
|
|
raise TimeoutError('Unable to aquire lock within {timeout} seconds')
|
|
|
|
def dict_to_ini(indict: dict) -> str:
|
|
out = ''
|
|
for section, rows in indict.items():
|
|
if section == 'DEFAULT':
|
|
continue
|
|
out += f'[{section}]\n'
|
|
for key, value in rows.items():
|
|
out += f'{key}'
|
|
if value is None and key.startswith('#'):
|
|
out += '\n'
|
|
continue
|
|
out += f' = {value}\n'
|
|
return out
|
|
|
|
def generate_keypair():
|
|
privkey = run_wg('genkey')
|
|
pubkey = run_wg('pubkey', input=privkey)
|
|
return privkey.strip(), pubkey.strip()
|
|
|
|
def generate_user_serverside_config(config_id: str,
|
|
user_name: str,
|
|
client_ip: ipaddress,
|
|
client_pubkey: str):
|
|
config = ConfigParser(allow_no_value=True)
|
|
config['Peer'] = {
|
|
f'# {user_name}/{config_id}': None,
|
|
'PublicKey': client_pubkey,
|
|
'AllowedIPs': f'{client_ip}/32'
|
|
}
|
|
return config
|
|
|
|
def generate_user_clientside_config(client_ip: str,
|
|
client_privkey: str,
|
|
server_address: ipaddress,
|
|
server_port: int,
|
|
server_pubkey: str,
|
|
fragment_file: Path=None):
|
|
config = ConfigParser(interpolation=None)
|
|
config['Interface'] = {
|
|
'Address': f'{client_ip}/32',
|
|
'PrivateKey': client_privkey
|
|
}
|
|
config['Peer'] = {
|
|
'AllowedIPs': '0.0.0.0/0',
|
|
'Endpoint': f'{server_address}:{server_port}',
|
|
'PublicKey': server_pubkey
|
|
}
|
|
if fragment_file:
|
|
fragment = ConfigParser(interpolation=None)
|
|
fragment.read(fragment_file)
|
|
for section, contents in fragment.items():
|
|
for key, value in contents.items():
|
|
config[section][key] = value
|
|
return config
|
|
|
|
def run_wg(*args, input: str=None):
|
|
result = subprocess.run(['wg', *args],
|
|
input=input,
|
|
capture_output=True,
|
|
text=True)
|
|
return result.stdout
|
|
|
|
def run_command(command, *args) -> None:
|
|
# The command must be called on an absolute path so that it's possible
|
|
# to set up a safe sudoers rule for it.
|
|
command_path = Path().joinpath('commands.sh')
|
|
subprocess.run(['sudo', command_path.absolute(), command, *args])
|
|
|
|
def create_route(client_ip: ipaddress) -> None:
|
|
run_command('add', f'{client_ip}/32')
|
|
|
|
def delete_route(client_ip: str) -> None:
|
|
# We don't add /32 to the ip because it is already included
|
|
# from the client config
|
|
run_command('del', client_ip)
|
|
|
|
def parse_timestring(spec: str) -> relativedelta:
|
|
count, unit = spec.split()
|
|
try:
|
|
count = int(count)
|
|
except Exception:
|
|
raise ValiditySpecificationError(
|
|
f"'{spec}' is not recognized as a valid time specification")
|
|
if unit == 'year' or unit == 'years':
|
|
return relativedelta(years=count)
|
|
if unit == 'month' or unit == 'months':
|
|
return relativedelta(months=count)
|
|
if unit == 'week' or unit == 'weeks':
|
|
return relativedelta(weeks=count)
|
|
if unit == 'day' or unit == 'days':
|
|
return relativedelta(days=count)
|
|
raise ValiditySpecificationError(
|
|
f"'{spec}' is not recognized as a valid time specification")
|
|
|
|
|
|
class WireGuard:
|
|
def __init__(self, config: dict):
|
|
self.tunnel_id = config['tunnel_id']
|
|
self.server_address = ipaddress.ip_address(config['server_address'])
|
|
self.server_port = int(config['server_port'])
|
|
self.client_network = ipaddress.ip_network(config['client_network'])
|
|
self.configs_base = Path(config['configs_base'])
|
|
self.max_clients = config.getint('user_client_limit', fallback=0)
|
|
|
|
self.client_validity = 0
|
|
client_validity = config.get('user_client_validity', fallback=0)
|
|
if client_validity:
|
|
self.client_validity = parse_timestring(client_validity)
|
|
|
|
self.server_config_base = None
|
|
if 'server_extra_config' in config:
|
|
self.server_config_base = Path(config['server_extra_config'])
|
|
self.client_config_base = None
|
|
if 'client_extra_config' in config:
|
|
self.client_config_base = Path(config['client_extra_config'])
|
|
|
|
self.server_config_file = safe_join(workdir, self.tunnel_id + '.conf')
|
|
|
|
with open(config['server_privkey_file'], 'r') as privkey_file:
|
|
self.server_privkey = privkey_file.read().strip()
|
|
with open(config['server_pubkey_file'], 'r') as pubkey_file:
|
|
self.server_pubkey = pubkey_file.read().strip()
|
|
|
|
self.user_name = None
|
|
self.user_base = None
|
|
|
|
# Ensure a wg config exists on startup
|
|
self.update(force=True)
|
|
|
|
|
|
def log(self, context_id: str, message) -> None:
|
|
syslog(f'[{self.tunnel_id}] {context_id}: {message}')
|
|
|
|
def set_user(self, user_name: str) -> None:
|
|
user_base = safe_join(self.configs_base, user_name)
|
|
if not user_base.exists():
|
|
user_base.mkdir()
|
|
self.user_name = user_name
|
|
self.user_base = user_base
|
|
|
|
def get_used_ips(self):
|
|
latest_config = self.generate_server_config()
|
|
ips = []
|
|
for line in latest_config.split('\n'):
|
|
if line.lower().startswith('allowedips'):
|
|
ipstring = line.split('=')[1].strip().removesuffix('/32')
|
|
ips.append(ipaddress.ip_address(ipstring))
|
|
return ips
|
|
|
|
def get_free_ip(self):
|
|
used_ips = self.get_used_ips()
|
|
for addr in self.client_network.hosts():
|
|
if addr not in used_ips:
|
|
return addr
|
|
raise Exception('No addresses available')
|
|
|
|
def get_user_client_count(self) -> int:
|
|
# Path.glob() returns a generator, so we have to count by iteration
|
|
metafiles = self.user_base.glob(f'*{metasuffix}')
|
|
return sum(1 for f in metafiles)
|
|
|
|
def filepath(self, config_filename: str) -> Path:
|
|
if self.user_base is None:
|
|
raise Exception('Not properly initialized')
|
|
return safe_join(self.user_base, config_filename)
|
|
|
|
def config_filepath(self, config_id: str) -> Path:
|
|
return self.filepath(f'{config_id}{confsuffix}')
|
|
|
|
def serverconfig_filepath(self, config_id: str) -> Path:
|
|
return self.filepath(f'{config_id}{serversuffix}')
|
|
|
|
def meta_filepath(self, config_id: str) -> Path:
|
|
return self.filepath(f'{config_id}{metasuffix}')
|
|
|
|
def generate_server_config(self):
|
|
config = ConfigParser(interpolation=None)
|
|
config['Interface'] = {
|
|
'Address': self.server_address,
|
|
'ListenPort': self.server_port,
|
|
'PrivateKey': self.server_privkey
|
|
}
|
|
if self.server_config_base:
|
|
fragment = ConfigParser(interpolation=None)
|
|
fragment.read(self.server_config_base)
|
|
for key, value in fragment['Interface'].items():
|
|
config['Interface'][key] = value
|
|
# We need to leave configparser-land here due to lots
|
|
# of duplicated sections when configuring peers
|
|
server_config = dict_to_ini(config)
|
|
for conffile in self.configs_base.glob('*/*'+serversuffix):
|
|
with open(conffile, 'r') as cf:
|
|
server_config += '\n' + cf.read()
|
|
return server_config
|
|
|
|
def list_configs(self) -> list:
|
|
if self.user_base is None:
|
|
raise Exception('Not properly initialized')
|
|
return [p.stem for p
|
|
in self.user_base.glob(f'*{confsuffix}')]
|
|
|
|
def generate_config_files(self, *args) -> None:
|
|
client_ip = call_with_lock(self._unsafe_generate_config_files,
|
|
args,
|
|
10)
|
|
create_route(client_ip)
|
|
|
|
def _unsafe_generate_config_files(self,
|
|
config_id: str,
|
|
name: str,
|
|
description: str,
|
|
creation_time: datetime) -> ipaddress:
|
|
if self.max_clients \
|
|
and self.get_user_client_count() >= self.max_clients:
|
|
raise ClientLimitError('client limit reached')
|
|
client_privkey, client_pubkey = generate_keypair()
|
|
client_ip = self.get_free_ip()
|
|
with open(self.config_filepath(config_id), 'x') as cf, \
|
|
open(self.serverconfig_filepath(config_id), 'x') as sf, \
|
|
open(self.meta_filepath(config_id), 'x') as mf:
|
|
|
|
metadata = {'name': name,
|
|
'description': description,
|
|
'created': creation_time.isoformat(' ',
|
|
'minutes')}
|
|
json.dump(metadata, mf)
|
|
|
|
client_config = generate_user_clientside_config(
|
|
client_ip,
|
|
client_privkey,
|
|
self.server_address,
|
|
self.server_port,
|
|
self.server_pubkey,
|
|
self.client_config_base
|
|
)
|
|
client_config.write(cf)
|
|
|
|
server_config = generate_user_serverside_config(
|
|
config_id,
|
|
self.user_name,
|
|
client_ip,
|
|
client_pubkey
|
|
)
|
|
server_config.write(sf)
|
|
self.log(f'{self.user_name}/{config_id}', f'Created new config')
|
|
self.log(f'{self.user_name}/{config_id}', f'IP: {client_ip}')
|
|
self.wg_updated = True
|
|
return client_ip
|
|
|
|
def update_config(self, *args) -> None:
|
|
call_with_lock(self._unsafe_update_config,
|
|
args,
|
|
10)
|
|
|
|
def _unsafe_update_config(self,
|
|
config_id: str,
|
|
name: str,
|
|
description: str) -> None:
|
|
with open(self.meta_filepath(config_id), 'r+') as mf:
|
|
metadata = json.load(mf)
|
|
metadata['name'] = name
|
|
metadata['description'] = description
|
|
mf.seek(0)
|
|
json.dump(metadata, mf)
|
|
mf.truncate()
|
|
|
|
def get_config(self, config_id: str) -> dict:
|
|
with open(self.config_filepath(config_id), 'r') as cf, \
|
|
open(self.meta_filepath(config_id), 'r') as mf:
|
|
metadata = json.load(mf)
|
|
configdata = cf.read()
|
|
creation_date = metadata['created']
|
|
expiry_date = None
|
|
if self.client_validity:
|
|
expiry_date = (datetime.fromisoformat(creation_date)
|
|
+ self.client_validity)
|
|
expiry_date = expiry_date.strftime('%Y-%m-%d')
|
|
return {'id': config_id,
|
|
'name': metadata['name'],
|
|
'description': metadata['description'],
|
|
'created': creation_date,
|
|
'expires': expiry_date,
|
|
'qrcode': generate_qr(configdata),
|
|
'data': configdata}
|
|
|
|
def delete_config(self, *args) -> None:
|
|
call_with_lock(self._unsafe_delete_config,
|
|
args,
|
|
10)
|
|
|
|
def _unsafe_delete_config(self, config_id: str) -> None:
|
|
config_path = self.config_filepath(config_id)
|
|
paths = [config_path,
|
|
self.serverconfig_filepath(config_id),
|
|
self.meta_filepath(config_id)]
|
|
for path in paths:
|
|
if not path.exists():
|
|
raise FileNotFoundError(path)
|
|
|
|
config = ConfigParser()
|
|
config.read(config_path)
|
|
client_ip = config['Interface']['Address']
|
|
|
|
[path.unlink() for path in paths]
|
|
delete_route(client_ip)
|
|
|
|
self.log(f'{self.user_name}/{config_id}', 'Deleted config')
|
|
self.wg_updated = True
|
|
|
|
def delete_many_configs(self, *args) -> None:
|
|
call_with_lock(self._unsafe_delete_many_configs,
|
|
args,
|
|
10)
|
|
|
|
def _unsafe_delete_many_configs(self, expired: dict) -> None:
|
|
for user_name, configs in expired.items():
|
|
self.set_user(user_name)
|
|
for config_id in configs:
|
|
self._unsafe_delete_config(config_id)
|
|
|
|
def run_cleanup(self) -> None:
|
|
if not self.client_validity:
|
|
return
|
|
|
|
now = datetime.now()
|
|
expired = {}
|
|
|
|
# Collect expired configs
|
|
for metafile in self.configs_base.glob('*/*'+metasuffix):
|
|
with open(metafile, 'r') as cf:
|
|
metadata = json.load(cf)
|
|
created = datetime.fromisoformat(metadata['created'])
|
|
config_id = metafile.stem
|
|
user_name = metafile.parent.name
|
|
if now > created + self.client_validity:
|
|
if user_name not in expired:
|
|
expired[user_name] = []
|
|
expired[user_name].append(config_id)
|
|
|
|
# Delete the expired configs in a separate step to minimize lock time
|
|
self.delete_many_configs(expired)
|
|
|
|
def update(self, force: bool = False) -> None:
|
|
if not force and not self.wg_updated:
|
|
return
|
|
with open(self.server_config_file, 'w') as sf:
|
|
sf.write(self.generate_server_config())
|
|
self.server_config_file.chmod(0o600)
|
|
|
|
# Sync updated settings to interface
|
|
run_command('reload')
|
|
self.wg_updated = False
|
|
return
|