diff --git a/.gitignore b/.gitignore index a8cedc3e33983d98849c9743ed01a793588398f1..51575d915296360f424a8aa7f653f6d643eebb88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.idea *.env __pycache__ containers.json +.mypy_cache diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..558ab6766f44fa6c3fb3d9701d2fc1c1ba2fc087 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,48 @@ +default: + image: debian-python-build:v2 + +# Is performed before the scripts in the stages step +before_script: + - source /etc/profile + # Load the ssh private key from the gitlab build variables to enable dupload + # to connect to the repo via scp + - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - ssh-keyscan repo.stusta.mhn.de >> ~/.ssh/known_hosts + - chmod 644 ~/.ssh/known_hosts + +# Defines stages which are to be executed +stages: + - build_buster + - upload_to_repo + +# Stage "build_buster" +build_buster: + stage: build_buster + script: + - apt install python3-stdeb + - ./build.sh + - mkdir -p build/ + - mv ../python3-lustmolch*.deb build/ + - mv ../python3-lustmolch*.changes build/ + - mv ../python3-lustmolch*.tar.gz build/ + - mv ../python3-lustmolch*.dsc build/ + - mv ../python3-lustmolch*.buildinfo build/ + + # The files which are to be made available in GitLab + artifacts: + paths: + - build/* + +upload_to_repo: + stage: upload_to_repo + script: + - echo "Uploading debian package to ssn repository" + - ssh repo@repo.stusta.mhn.de "echo SSH-Connection working" + - dupload -f -t ssn_repo build/python3-lustmolch*.changes + when: manual + only: + - master diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..ffc75f0dcfdeb30eb8255ee0d6c2f135360f9d95 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md +include templates \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000000000000000000000000000000000000..7ef88d34e795298a1ba77e37e481c648423eb6cf --- /dev/null +++ b/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +python3 setup.py --command-packages=stdeb.command bdist_deb \ No newline at end of file diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index b0d8f33d345b6a5bfe2cc25231ab541ee52e5c10..0000000000000000000000000000000000000000 --- a/deploy.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -mkdir -p /var/www/lustmolch -cp -rf www/* /var/www/lustmolch/ diff --git a/lustmolch.py b/lustmolch.py deleted file mode 100755 index 957bb6e201296f07885464ee3b1ac84e0b20cf93..0000000000000000000000000000000000000000 --- a/lustmolch.py +++ /dev/null @@ -1,416 +0,0 @@ -#!/usr/bin/env python3 -import json -import shutil -from collections import namedtuple -from subprocess import run -from pathlib import Path -from jinja2 import Environment, PackageLoader -import click - - -env = Environment(loader=PackageLoader('lustmolch', 'templates')) -cfg_template = namedtuple('cfg_template', ['source', 'path', 'filename']) - -template_files_host = [ - cfg_template('nginx', Path('/etc/nginx/sites-available'), '{name}'), - cfg_template('80-container-ve.network', Path('/etc/systemd/network'), - '80-container-ve-{name}.network') -] -template_files_container = [ - cfg_template('sshd_config', Path('/etc/ssh'), 'sshd_config'), - cfg_template('80-container-host0.network', Path('/etc/systemd/network'), - '80-container-host0.network') -] -nspawn_config = cfg_template('nspawn', Path( - '/etc/systemd/nspawn'), '{name}.nspawn') - -DEFAULT_TEMPLATE_DIR = '/srv/lustmolch-tools/templates' -DEFAULT_CONF_FILE = '/etc/ssn/lustmolch-containers.json' - -FLAVOUR = 'buster' -DEBIAN_MIRROR = 'http://mirror.stusta.de/debian' - -SSN_IP_RANGES = ['10.150.0.0/17', '141.84.69.0/24'] -www_root = Path('/var/www') - -SSH_START_PORT = 10022 -SSH_PORT_INCREMENT = 1000 - -IP_LUSTMOLCH = '141.84.69.235' # TODO: find out dynamically - -IP_START_HOST = (192, 168, 0, 1) -IP_SUBNET_LENGTH = 30 - - -def gen_default_conf(): - return { - 'users': {}, - 'containers': {} - } - - -def get_config(file_path): - if not Path(file_path).exists(): - cfg = gen_default_conf() - else: - with open(file_path, 'r') as f: - cfg = json.load(f) - - return cfg - - -def next_ssh_port(cfg, name): - """ - Return the next available port for the containers ssh server to run on. - If the container is already present in the list of installed containers - returns the configured port. - Args: - config_file: Path to container configuration file (containers.json) - name: Container name - - Returns: SSH port - """ - - if name in cfg['containers']: - return cfg['containers'][name].get('ssh_port') - - port = SSH_START_PORT - for container in cfg['containers'].values(): - if container['ssh_port'] >= port: - port = container['ssh_port'] + SSH_PORT_INCREMENT - - return port - - -def next_ip_address(cfg, name): - """ - Return the next available (local) IP address to be assigned to the - container and the host interfaces. - Args: - config_file: Path to container configuration file (containers.json) - name: Container name - - Returns (tuple): host_ip, container_ip - """ - - if name in cfg['containers']: - c = cfg['containers'][name] - return (c.get('ip_address_host').split('/')[0], - c.get('ip_address_container').split('/')[0]) - - ip_host = list(IP_START_HOST) - - container_ips = [container['ip_address_host'].split('/')[0].split('.') - for container in cfg['containers'].values()] - - ip_host[2] = max([int(ip[2]) for ip in container_ips]) - ip_host[3] = max([int(ip[3]) for ip in container_ips]) - ip_host[3] += 4 - - if ip_host[3] >= 254: - ip_host[3] == 1 - ip_host[2] += 1 - if ip_host[2] == 254: - click.echo('Error no available IP addresses found') - raise Exception() - - ip_container = list(ip_host) - ip_container[3] += 1 - return ( - '.'.join( - str(x) for x in ip_host), '.'.join( - str(x) for x in ip_container)) - - -def update_config(config_file, container): - if not Path(config_file).exists(): - with open(config_file, 'w+') as f: - cfg = gen_default_conf() - cfg['containers'][container['name']] = container - json.dump(cfg, f, indent=4) - else: - with open(config_file, 'r') as f: - cfg = json.load(f) - cfg['containers'][container['name']] = container - - with open(config_file, 'w') as f: - json.dump(cfg, f, indent=4) - - -@click.group() -def cli(): - pass - - -@cli.command() -@click.option( - '--config-file', - default=DEFAULT_CONF_FILE, - help='Container configuration file') -def list_containers(config_file): - """output lustmolch configuration file""" - cfg = get_config(config_file) - - click.echo('Currently registered containers:\n') - click.echo(json.dumps(cfg, indent=4)) - - -@cli.command() -@click.option('--dry-run', is_flag=True, default=False) -@click.option( - '--config-file', - default=DEFAULT_CONF_FILE, - help='Container configuration file') -@click.argument('name') -def create_container(dry_run, config_file, name): - """Creates a systemd-nspawn container.""" - if dry_run: - click.echo(f'Doing a dry run') - - # create shared folder for html static files - www_dir = www_root / name - click.echo(f'Creating shared www directory "{www_dir}"') - if not dry_run: - www_dir.mkdir(parents=True, exist_ok=True) - - # place configuration files - cfg = get_config(config_file) - ip_address_host, ip_address_container = next_ip_address(cfg, name) - ssh_port = next_ssh_port(cfg, name) - context = { - 'name': name, - 'ssh_port': ssh_port, - 'ip_address_host': ip_address_host, - 'ip_address_container': ip_address_container, - 'ip_subnet_length': IP_SUBNET_LENGTH, - 'url': f'{name}.stusta.de', - 'users': [] - } - - click.echo(f'Generated context values for container: {repr(context)}') - - for cfg in template_files_host: - template = env.get_template('host/' + cfg.source) - file_name = cfg.path / (cfg.filename.format(**context)) - click.echo(f'Placing config file {file_name}') - if not dry_run: - with open(file_name, 'w+') as cfg_file: - cfg_file.write(template.render(context)) - - # create machine - machine_path = Path('/var/lib/machines', name) - click.echo('Running debootstrap') - if not dry_run: - run(['debootstrap', FLAVOUR, machine_path, DEBIAN_MIRROR], - capture_output=True, check=True) - - click.echo('Bootstrapping container') - if not dry_run: - # copy and run bootstrap shell script - script_location = '/opt/bootstrap.sh' - script_location_host = str(machine_path) + script_location - - shutil.copy( - str(Path(DEFAULT_TEMPLATE_DIR, 'container/bootstrap.sh')), - script_location_host) - Path(script_location_host).chmod(0o755) - run(['systemd-nspawn', '-D', str(machine_path), script_location], - check=True) - - click.echo(f'Installing systemd-nspawn config for container {name}') - if not dry_run: - template = env.get_template('host/' + nspawn_config.source) - file_name = nspawn_config.path / \ - (nspawn_config.filename.format(**context)) - with open(file_name, 'w+') as cfg_file: - cfg_file.write(template.render(context)) - - click.echo('Copying config files into container') - if not dry_run: - for cfg in template_files_container: - template = env.get_template('container/' + cfg.source) - file_name = cfg.filename.format(**context) - click.echo(f'Placing config file {file_name}') - if not dry_run: - with open(Path(f'{machine_path}{cfg.path}/{file_name}'), 'w+') as f: - f.write(template.render(context)) - - click.echo(f'Updating Iptable rules (filter, nat)') - if not dry_run: - for ip_range in SSN_IP_RANGES: - run(['iptables', '-A', 'INPUT', '-p', 'tcp', '-m', 'tcp', - '--dport', str(ssh_port), '-s', ip_range, '-j', 'ACCEPT']) - run(['iptables', '-t', 'nat', '-A', 'PREROUTING', '-p', 'tcp', - '-m', 'tcp', '--dport', str( - ssh_port), '-s', ip_range, '-j', 'DNAT', - '--to-destination', f'{ip_address_container}:22']) - run(['iptables', '-t', 'nat', '-A', 'POSTROUTING', '-o', f've-{name}', - '-j', 'SNAT', '--to-source', IP_LUSTMOLCH]) - - click.echo('Starting container') - if not dry_run: - run(['machinectl', 'start', name], capture_output=True, check=True) - - click.echo('Updating container configuration file') - if not dry_run: - update_config(config_file, container=context) - - click.echo(f'All done, ssh server running on port {ssh_port}\n' - 'To finish please run "iptables-save".') - - -@cli.command() -@click.option( - '--config-file', - default=DEFAULT_CONF_FILE, - help='Container configuration file') -@click.option('--key-string', is_flag=True, default=False) -@click.argument('name') -@click.argument('key') -def add_user(config_file, key_string, name, key): - """add user to lustmolch management""" - if key_string: - key_string = key - else: - with open(key, 'r') as f: - key_string = f.read() - cfg = get_config(config_file) - - cfg['users'][name] = { - 'name': name, - 'key': key - } - - with open(config_file, 'w') as f: - json.dump(cfg, f, indent=4) - - -@cli.command() -@click.option( - '--config-file', - default=DEFAULT_CONF_FILE, - help='Container configuration file') -@click.argument('name') -def remove_user(config_file, name): - """remove a user, doesn't remove the user from all containers""" - cfg = get_config(config_file) - if name in cfg['users']: - del cfg['users'][name] - - with open(config_file, 'w') as f: - json.dump(cfg, f, indent=4) - - -@cli.command() -@click.option('--dry-run', is_flag=True, default=False) -@click.option( - '--config-file', - default=DEFAULT_CONF_FILE, - help='Container configuration file') -def update_containers(dry_run, config_file): - """update users on all containers""" - cfg = get_config(config_file) - - for container in cfg['containers'].values(): - ssh_dir = Path('/var/lib/machines', container['name'], 'root/.ssh') - authorized_keys = ssh_dir / 'authorized_keys' - - keys = [user['key'] for user in cfg['users'].values() if user['name'] in container['users']] - keys = '\n'.join(keys) - - click.echo(f'Writing\n{keys}\n to authorized key file {authorized_keys}') - if not dry_run: - ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - authorized_keys.touch(mode=0o600, exist_ok=True) - authorized_keys.write_text(keys) - - -@cli.command() -@click.option( - '--config-file', - default=DEFAULT_CONF_FILE, - help='Container configuration file') -@click.option('--key-string', is_flag=True, default=False) -@click.argument('name') -@click.argument('key') -def install_ssh_key(config_file, key_string, name, key): - """copy a ssh key into a containers authorized_keys""" - click.echo('DEPRECATED: Use add-user, update-contaienrs instead. Will be removed soon!') - ssh_dir = Path('/var/lib/machines', name, 'root/.ssh') - authorized_keys = ssh_dir / 'authorized_keys' - if key_string: - key_string = key - else: - with open(key, 'r') as f: - key_string = f.read() - - click.echo(f'Appending ssh key\n{key_string} to {authorized_keys}') - ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - with open(authorized_keys, 'a+') as f: - f.write('\n' + key_string) - authorized_keys.chmod(0o600) - - -@cli.command() -@click.option('--dry-run', is_flag=True, default=False) -@click.option( - '--config-file', - default=DEFAULT_CONF_FILE, - help='Container configuration file') -@click.argument('name') -def remove_container(dry_run, config_file, name): - """delete a container and its configuration files""" - machine_path = Path('/var/lib/machines', name) - - click.echo(f'Stopping container') - if not dry_run: - run(['machinectl', 'stop', name], capture_output=True, check=False) - - # removing shared folder - www_dir = www_root / name - click.echo(f'Removing shared www folder') - try: - shutil.rmtree(www_dir, ignore_errors=True) - except OSError as e: - click.echo(f'{e} ignored when removing container') - - # deleting placed config files - for cfg in template_files_host: - file_name = cfg.path / cfg.filename.format(name=name) - click.echo(f'Removing config file {file_name}') - try: - file_name.unlink() - except OSError as e: - click.echo(f'{e} ignored when removing file {file_name}') - - click.echo('Removing nspawn config') - try: - (nspawn_config.path / nspawn_config.filename.format(name=name)).unlink() - except OSError as e: - click.echo(f'{e} ignored when removing nspawn config') - - # delete container itself - click.echo(f'Removing container') - try: - shutil.rmtree(machine_path, ignore_errors=True) - except OSError as e: - click.echo(f'{e} ignored when removing container') - - # remove container from configuration file - click.echo(f'Updating configuration file') - try: - with open(config_file, 'r') as f: - cfg = json.load(f) - if name in cfg: - del cfg[name] - with open(config_file, 'w') as f: - json.dump(cfg, f, indent=4) - except OSError as e: - click.echo(f'{e} ignored when updating config file') - - click.echo( - 'All done, although you might need to manually remove some iptable rules.') - - -if __name__ == '__main__': - cli() diff --git a/lustmolch/__init__.py b/lustmolch/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lustmolch/__main__.py b/lustmolch/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..4d48ecc84e2bb46bb0dfe67db118177388febdc1 --- /dev/null +++ b/lustmolch/__main__.py @@ -0,0 +1,4 @@ +from . import cli + +if __name__ == '__main__': + cli.cli() diff --git a/lustmolch/cli.py b/lustmolch/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..2f6477c0dd519c333d09dc5d845d992caa626724 --- /dev/null +++ b/lustmolch/cli.py @@ -0,0 +1,59 @@ +import click + +from lustmolch.config import DEFAULT_CONF_FILE, init_config +from lustmolch.lustmolch import ( + list_containers, + add_user, + create_container, + remove_container, + remove_user, + update_containers +) + + +@click.group() +@click.option( + '--config-file', + default=DEFAULT_CONF_FILE, + help='Container configuration file') +def cli(config_file: str): + init_config(config_file) + + +@cli.command() +def list_containers(): + list_containers() + + +@cli.command() +@click.option('--dry-run', is_flag=True, default=False) +@click.argument('name') +def create_container(dry_run: bool, name: str): + create_container(dry_run, name) + + +@cli.command() +@click.option('--key-string', is_flag=True, default=False) +@click.argument('name') +@click.argument('key') +def add_user(key_string: bool, name: str, key: str) -> None: + add_user(key_string, name, key) + + +@cli.command() +@click.argument('name') +def remove_user(name: str) -> None: + remove_user(name) + + +@cli.command() +@click.option('--dry-run', is_flag=True, default=False) +def update_containers(dry_run: bool) -> None: + update_containers(dry_run) + + +@cli.command() +@click.option('--dry-run', is_flag=True, default=False) +@click.argument('name') +def remove_container(dry_run: bool, name: str) -> None: + remove_container(dry_run, name) diff --git a/lustmolch/config.py b/lustmolch/config.py new file mode 100644 index 0000000000000000000000000000000000000000..939e8591aa53b7aaf071e2837427397606c58224 --- /dev/null +++ b/lustmolch/config.py @@ -0,0 +1,77 @@ +import json +import logging +from pathlib import Path +from typing import Dict, Any, Optional + +DEFAULT_TEMPLATE_DIR = '/srv/lustmolch-tools/templates' +DEFAULT_CONF_FILE = '/etc/ssn/lustmolch-containers.json' + +DEFAULTS = { + 'debian_flavour': 'buster', + 'debian_mirror': 'http://mirror.stusta.de/debian', + 'ssn_ip_ranges': ['10.150.0.0/17', '141.84.69.0/24'], + 'www_root': '/var/www', + 'ssh_start_port': 10022, + 'ssh_port_increment': 1000, + 'host_ip': '141.84.69.235', + 'ip_start_host': (192, 168, 0, 1), + 'ip_subnet_length': 30, + 'log_level': logging.INFO +} + + +class Config: + def __init__(self, file_path: Path, cfg: Dict[str, Any]): + self.config = cfg + self.file_path = file_path + + def get(self, key: str, default: Optional[Any] = None) -> Any: + if key not in self.config: + return DEFAULTS[key] + + return self.config.get(key, default) + + def set(self, key: str, value: Any): + self.config[key] = value + + def __getitem__(self, key: str) -> Any: + return self.config[key] + + def __setitem__(self, key: str, value: Any) -> Any: + self.config[key] = value + + def __delitem__(self, key): + del self.config[key] + + def save(self): + with self.file_path.open('w+') as f: + json.dump(self.config, f, indent=2) + + @classmethod + def from_defaults(cls, file_name: str) -> 'Config': + cfg: Dict[str, Any] = { + 'users': {}, + 'containers': {} + } + + return cls(Path(file_name), cfg) + + @classmethod + def from_file(cls, file_name: str) -> 'Config': + path = Path(file_name) + if not path.exists(): + logging.info('Config file does not exist, generating defaults') + return cls.from_defaults(file_name) + + with path.open('r') as f: + cfg = json.load(f) + + return cls(path, cfg) + + +config = Config.from_file(DEFAULT_CONF_FILE) + + +def init_config(config_file: str) -> None: + global config + config = Config.from_file(config_file) diff --git a/lustmolch/lustmolch.py b/lustmolch/lustmolch.py new file mode 100755 index 0000000000000000000000000000000000000000..248a70fcb10404930585b45ec5d44e5f437f72cf --- /dev/null +++ b/lustmolch/lustmolch.py @@ -0,0 +1,278 @@ +import json +import logging +import shutil +from collections import namedtuple +from pathlib import Path +from subprocess import run +from typing import Tuple + +from jinja2 import Environment, PackageLoader + +from .config import config, DEFAULT_TEMPLATE_DIR + +logging.basicConfig(level=config['log_level']) + +env = Environment(loader=PackageLoader('lustmolch', str(Path(__file__).parent.parent / 'templates'))) +cfg_template = namedtuple('cfg_template', ['source', 'path', 'filename']) + +template_files_host = [ + cfg_template('nginx', Path('/etc/nginx/sites-available'), '{name}'), + cfg_template('80-container-ve.network', Path('/etc/systemd/network'), + '80-container-ve-{name}.network') +] +template_files_container = [ + cfg_template('sshd_config', Path('/etc/ssh'), 'sshd_config'), + cfg_template('80-container-host0.network', Path('/etc/systemd/network'), + '80-container-host0.network') +] +nspawn_config = cfg_template('nspawn', Path( + '/etc/systemd/nspawn'), '{name}.nspawn') + + +def next_ssh_port(name: str) -> int: + """ + Return the next available port for the containers ssh server to run on. + If the container is already present in the list of installed containers + returns the configured port. + Args: + name: Container name + + Returns: SSH port + """ + + if name in config['containers']: + return config['containers'][name].get('ssh_port') + + port = config['ssh_start_port'] + for container in config['containers'].values(): + if container['ssh_port'] >= port: + port = container['ssh_port'] + config['ssh_port_increment'] + + return port + + +def next_ip_address(name: str) -> Tuple[str, str]: + """ + Return the next available (local) IP address to be assigned to the + container and the host interfaces. + Args: + name: Container name + + Returns (tuple): host_ip, container_ip + """ + + if name in config['containers']: + c = config['containers'][name] + return (c.get('ip_address_host').split('/')[0], + c.get('ip_address_container').split('/')[0]) + + ip_host = list(config['ip_start_host']) + + container_ips = [container['ip_address_host'].split('/')[0].split('.') + for container in config['containers'].values()] + + ip_host[2] = max([int(ip[2]) for ip in container_ips]) + ip_host[3] = max([int(ip[3]) for ip in container_ips]) + ip_host[3] += 4 + + if ip_host[3] >= 254: + ip_host[3] = 1 + ip_host[2] += 1 + if ip_host[2] == 254: + logging.error('Error no available IP addresses found') + raise Exception() + + ip_container = list(ip_host) + ip_container[3] += 1 + return ( + '.'.join( + str(x) for x in ip_host), '.'.join( + str(x) for x in ip_container)) + + +def list_containers(): + """output lustmolch configuration file""" + # TODO: make nice + + print('Currently registered containers:\n') + print(json.dumps(config.config, indent=2)) + + +def create_container(dry_run, name): + """Creates a systemd-nspawn container.""" + if dry_run: + logging.info(f'Doing a dry run') + + # create shared folder for html static files + www_dir = Path(config['www_root']) / name + logging.info(f'Creating shared www directory "{www_dir}"') + if not dry_run: + www_dir.mkdir(parents=True, exist_ok=True) + + # place configuration files + ip_address_host, ip_address_container = next_ip_address(name) + ssh_port = next_ssh_port(name) + context = { + 'name': name, + 'ssh_port': ssh_port, + 'ip_address_host': ip_address_host, + 'ip_address_container': ip_address_container, + 'ip_subnet_length': config['ip_subnet_lenght'], + 'url': f'{name}.stusta.de', + 'users': [] + } + + logging.info(f'Generated context values for container: {repr(context)}') + + for cfg in template_files_host: + template = env.get_template('host/' + cfg.source) + file_name = cfg.path / (cfg.filename.format(**context)) + logging.info(f'Placing config file {file_name}') + if not dry_run: + with open(file_name, 'w+') as cfg_file: + cfg_file.write(template.render(context)) + + # create machine + machine_path = Path('/var/lib/machines', name) + logging.info('Running debootstrap') + if not dry_run: + run(['debootstrap', config['debian_flavour'], machine_path, config['debian_mirror']], + capture_output=True, check=True) + + logging.info('Bootstrapping container') + if not dry_run: + # copy and run bootstrap shell script + script_location = '/opt/bootstrap.sh' + script_location_host = str(machine_path) + script_location + + shutil.copy( + str(Path(DEFAULT_TEMPLATE_DIR, 'container/bootstrap.sh')), + script_location_host) + Path(script_location_host).chmod(0o755) + run(['systemd-nspawn', '-D', str(machine_path), script_location], + check=True) + + logging.info(f'Installing systemd-nspawn config for container {name}') + if not dry_run: + template = env.get_template('host/' + nspawn_config.source) + file_name = nspawn_config.path / (nspawn_config.filename.format(**context)) + with open(file_name, 'w+') as cfg_file: + cfg_file.write(template.render(context)) + + logging.info('Copying config files into container') + if not dry_run: + for cfg in template_files_container: + template = env.get_template('container/' + cfg.source) + file_name = cfg.filename.format(**context) + logging.info(f'Placing config file {file_name}') + if not dry_run: + with open(Path(f'{machine_path}{cfg.path}/{file_name}'), 'w+') as f: + f.write(template.render(context)) + + logging.info(f'Updating Iptable rules (filter, nat)') + if not dry_run: + for ip_range in config['ssn_ip_ranges']: + run(['iptables', '-A', 'INPUT', '-p', 'tcp', '-m', 'tcp', + '--dport', str(ssh_port), '-s', ip_range, '-j', 'ACCEPT']) + run(['iptables', '-t', 'nat', '-A', 'PREROUTING', '-p', 'tcp', + '-m', 'tcp', '--dport', str( + ssh_port), '-s', ip_range, '-j', 'DNAT', + '--to-destination', f'{ip_address_container}:22']) + run(['iptables', '-t', 'nat', '-A', 'POSTROUTING', '-o', f've-{name}', + '-j', 'SNAT', '--to-source', config['host_ip']]) + + logging.info('Starting container') + if not dry_run: + run(['machinectl', 'start', name], capture_output=True, check=True) + + logging.info('Updating container configuration file') + if not dry_run: + config.save() + + logging.info(f'All done, ssh server running on port {ssh_port}\n' + 'To finish please run "iptables-save".') + + +def add_user(key_string: bool, name: str, key: str) -> None: + """add user to lustmolch management""" + if not key_string: + with open(key, 'r') as f: + key = f.read() + + config['users'][name] = { + 'name': name, + 'key': key + } + + config.save() + + +def remove_user(name: str) -> None: + """remove a user, doesn't remove the user from all containers""" + if name in config['users']: + del config['users'][name] + + config.save() + + +def update_containers(dry_run: bool) -> None: + """update users on all containers""" + + for container in config['containers'].values(): + ssh_dir = Path('/var/lib/machines', container['name'], 'root/.ssh') + authorized_keys = ssh_dir / 'authorized_keys' + + keys = [user['key'] for user in config['users'].values() if user['name'] in container['users']] + keys = '\n'.join(keys) + + logging.info(f'Writing\n{keys}\n to authorized key file {authorized_keys}') + if not dry_run: + ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + authorized_keys.touch(mode=0o600, exist_ok=True) + authorized_keys.write_text(keys) + + +def remove_container(dry_run: bool, name: str) -> None: + """delete a container and its configuration files""" + machine_path = Path('/var/lib/machines', name) + + logging.info(f'Stopping container') + if not dry_run: + run(['machinectl', 'stop', name], capture_output=True, check=False) + + # removing shared folder + www_dir = Path(config['www_root']) / name + logging.info(f'Removing shared www folder') + try: + shutil.rmtree(www_dir, ignore_errors=True) + except OSError as e: + logging.warning(f'{e} ignored when removing container') + + # deleting placed config files + for cfg in template_files_host: + file_name = cfg.path / cfg.filename.format(name=name) + logging.info(f'Removing config file {file_name}') + try: + file_name.unlink() + except OSError as e: + logging.warning(f'{e} ignored when removing file {file_name}') + + logging.info('Removing nspawn config') + try: + (nspawn_config.path / nspawn_config.filename.format(name=name)).unlink() + except OSError as e: + logging.warning(f'{e} ignored when removing nspawn config') + + # delete container itself + logging.info(f'Removing container') + try: + shutil.rmtree(machine_path, ignore_errors=True) + except OSError as e: + logging.warning(f'{e} ignored when removing container') + + # remove container from configuration file + logging.info(f'Updating configuration file') + del config['containers'][name] + config.save() + + logging.info('All done, although you might need to manually remove some iptable rules.') diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..d6a61624c6f8ffd46641ac0d4842d101799fae44 --- /dev/null +++ b/setup.py @@ -0,0 +1,37 @@ +from setuptools import setup + +VERSION = '1.0.0' +AUTHOR = 'Michael Loipführer' + + +def readme(): + with open('README.md') as f: + return f.read() + + +setup( + name='lustmolch', + version=VERSION, + description='Lustmolch systemd nspwan container utilities', + long_description=readme(), + url='http://gitlab.stusta.de/stustanet/lustmolch', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3.7', + 'Operating System :: POSIX :: Linux' + ], + author=AUTHOR, + author_email='ml@stusta.de', + install_requires=[ + 'Click', + 'jinja2' + ], + license='MIT', + packages=['lustmolch'], + entry_points={ + 'console_scripts': ['lustmolch=lustmolch.cli:cli'] + }, + include_package_data=True, + zip_safe=False +) diff --git a/stdeb.cfg b/stdeb.cfg new file mode 100644 index 0000000000000000000000000000000000000000..f4cc46e7d12f13562215568fd2d361e7a4ea9715 --- /dev/null +++ b/stdeb.cfg @@ -0,0 +1,2 @@ +[Default] +Depends: python3-click python3-jinja2 systemd-container debootstrap \ No newline at end of file diff --git a/templates/container/bootstrap.sh b/templates/container/bootstrap.sh index 9a1024eeba31962e8fd39d38a18abbabebbff3b3..e987d298a95fb1f6fde3a3f2db28c24e4c5aab27 100755 --- a/templates/container/bootstrap.sh +++ b/templates/container/bootstrap.sh @@ -3,5 +3,5 @@ apt-get update apt-get -y upgrade apt-get -y install dbus openssh-server vim -systemctl enable systemd-networkd.service -systemctl enable systemd-resolved.service +systemctl enable --now systemd-networkd.service +systemctl enable --now systemd-resolved.service diff --git a/www/index.html b/www/index.html deleted file mode 100644 index 3f19249e744c1df2b1dff547d21e1947494ca41f..0000000000000000000000000000000000000000 --- a/www/index.html +++ /dev/null @@ -1,18 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <title>StuSta</title> - <link href='/home/mikonse/stusta/stustanet/lustmolch/www/style.css' rel="stylesheet"> -</head> -<body> - <div class="container"> - <div class="link-box"> - <a href="pot.stusta.de">Pot</a> - </div> - <div class="link-box"> - <a href="manhattan.stusta.de">MANHATTAN</a> - </div> - </div> -</body> -</html> \ No newline at end of file diff --git a/www/style.css b/www/style.css deleted file mode 100644 index 8a7b209a07987d9b215cf01d65f172c3a6f18911..0000000000000000000000000000000000000000 --- a/www/style.css +++ /dev/null @@ -1,11 +0,0 @@ -.container { - flex: 1 1 auto; -} - -.link-box { - width: 150px; - height: 150px; - align-content: center; - justify-content: center; - background-color: red; -} \ No newline at end of file