From 070de26b957d1f9fc29f84362955c4e27b449989 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= <michael.loipfuehrer@stusta.de>
Date: Sun, 15 Mar 2020 14:27:45 +0100
Subject: [PATCH] move to python package with debian build

---
 .gitignore                       |   2 +
 .gitlab-ci.yml                   |  48 ++++
 MANIFEST.in                      |   2 +
 build.sh                         |   3 +
 deploy.sh                        |   4 -
 lustmolch.py                     | 416 -------------------------------
 lustmolch/__init__.py            |   0
 lustmolch/__main__.py            |   4 +
 lustmolch/cli.py                 |  59 +++++
 lustmolch/config.py              |  77 ++++++
 lustmolch/lustmolch.py           | 278 +++++++++++++++++++++
 setup.py                         |  37 +++
 stdeb.cfg                        |   2 +
 templates/container/bootstrap.sh |   4 +-
 www/index.html                   |  18 --
 www/style.css                    |  11 -
 16 files changed, 514 insertions(+), 451 deletions(-)
 create mode 100644 .gitlab-ci.yml
 create mode 100644 MANIFEST.in
 create mode 100644 build.sh
 delete mode 100755 deploy.sh
 delete mode 100755 lustmolch.py
 create mode 100644 lustmolch/__init__.py
 create mode 100644 lustmolch/__main__.py
 create mode 100644 lustmolch/cli.py
 create mode 100644 lustmolch/config.py
 create mode 100755 lustmolch/lustmolch.py
 create mode 100644 setup.py
 create mode 100644 stdeb.cfg
 delete mode 100644 www/index.html
 delete mode 100644 www/style.css

diff --git a/.gitignore b/.gitignore
index a8cedc3..51575d9 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 0000000..558ab67
--- /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 0000000..ffc75f0
--- /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 0000000..7ef88d3
--- /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 b0d8f33..0000000
--- 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 957bb6e..0000000
--- 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 0000000..e69de29
diff --git a/lustmolch/__main__.py b/lustmolch/__main__.py
new file mode 100644
index 0000000..4d48ecc
--- /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 0000000..2f6477c
--- /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 0000000..939e859
--- /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 0000000..248a70f
--- /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 0000000..d6a6162
--- /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 0000000..f4cc46e
--- /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 9a1024e..e987d29 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 3f19249..0000000
--- 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 8a7b209..0000000
--- 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
-- 
GitLab