From d064dd93d4734cfa840f902d7a7a9ad361895ff8 Mon Sep 17 00:00:00 2001 From: haskal Date: Mon, 14 Jun 2021 04:45:02 -0400 Subject: [PATCH] leylines server initial commit --- leylines/.gitignore | 10 +++ leylines/Makefile | 9 +++ leylines/leylines/__init__.py | 108 ++++++++++++++++++++++++++ leylines/leylines/__main__.py | 5 ++ leylines/leylines/database.py | 142 ++++++++++++++++++++++++++++++++++ leylines/setup.py | 20 +++++ 6 files changed, 294 insertions(+) create mode 100644 leylines/.gitignore create mode 100644 leylines/Makefile create mode 100644 leylines/leylines/__init__.py create mode 100644 leylines/leylines/__main__.py create mode 100644 leylines/leylines/database.py create mode 100644 leylines/setup.py diff --git a/leylines/.gitignore b/leylines/.gitignore new file mode 100644 index 0000000..8311b76 --- /dev/null +++ b/leylines/.gitignore @@ -0,0 +1,10 @@ +.venv +*.pyc +*.pyo +*.pyd +__pycache__ +*.egg-info +.env +.vimrc +.ycm_extra_conf.py +*.db diff --git a/leylines/Makefile b/leylines/Makefile new file mode 100644 index 0000000..1b2beff --- /dev/null +++ b/leylines/Makefile @@ -0,0 +1,9 @@ +.PHONY: check install + +check: + @mypy . || true + @flake8 . || true + @pyflakes . || true + +install: + pip install --user -e . diff --git a/leylines/leylines/__init__.py b/leylines/leylines/__init__.py new file mode 100644 index 0000000..c755db5 --- /dev/null +++ b/leylines/leylines/__init__.py @@ -0,0 +1,108 @@ +import binascii +from typing import TypeVar, List, Tuple, Optional + +from pyroute2 import IPRoute, WireGuard + +import monocypher +from .database import Database, Node, SERVER_NODE_ID + + +IFNAME = 'leyline-wg' +DEFAULT_PORT = 31337 +db = Database("leylines.db") + + +def net_init() -> None: + with open("/proc/sys/net/ipv4/ip_forward", "w") as f: + f.write("1") + + +def seckey_to_pubkey(seckey: str) -> str: + seckey_bytes = binascii.a2b_base64(seckey) + pubkey_bytes = monocypher.crypto_key_exchange_public_key(seckey_bytes) + return binascii.b2a_base64(pubkey_bytes).decode().strip() + + +def get_server_node() -> Node: + server_node = db.get_server_node() + if server_node is None: + raise Exception("no server node defined") + return server_node + + +def generate_node_config(id: int) -> Optional[str]: + node = db.get_node(id) + if node is None: + return None + + server_node = get_server_node() + interface = db.get_subnet() + + return ( + "[Interface]\n" + + f"PrivateKey={node.seckey}\n" + f"Address={node.ip}/{interface.network.prefixlen}\n" + "[Peer]\n" + f"PublicKey={seckey_to_pubkey(server_node.seckey)}\n" + f"AllowedIPs={interface.network}\n" + f"Endpoint={server_node.public_ip}:{DEFAULT_PORT}\n" + ) + + +def add_node_to_wg(wg: WireGuard, node: Node) -> None: + if node.id == SERVER_NODE_ID: + return + + peer = { + "public_key": seckey_to_pubkey(node.seckey), + "persistent_keepalive": 60, + "allowed_ips": [f"{node.ip}/32"] + } + wg.set(IFNAME, peer=peer) + + +def create_or_get_interface() -> int: + with IPRoute() as ipr: + lookup = ipr.link_lookup(ifname=IFNAME) + if len(lookup) > 0: + return lookup[0] + + server_node = get_server_node() + interface = db.get_subnet() + + ipr.link("add", ifname=IFNAME, kind="wireguard") + dev = ipr.link_lookup(ifname=IFNAME)[0] + ipr.link("set", index=dev, state="down") + ipr.addr("add", index=dev, address=str(server_node.ip), mask=interface.network.prefixlen, + broadcast=str(interface.network.broadcast_address)) + ipr.link("set", index=dev, state="up") + + with WireGuard() as wg: + wg.set(IFNAME, private_key=server_node.seckey, listen_port=DEFAULT_PORT) + return dev + + +def delete_interface() -> None: + with IPRoute() as ipr: + ipr.link("del", ifname=IFNAME) + + +def sync_interface() -> None: + create_or_get_interface() + with WireGuard() as wg: + info = wg.info(IFNAME)[0].get_attr("WGDEVICE_A_PEERS") + if info is None: + info = [] + node_keys = { + seckey_to_pubkey(node.seckey): node + for node in db.get_nodes() if node.id != SERVER_NODE_ID + } + for peer in info: + pubkey = peer.get_attr("WGPEER_A_PUBLIC_KEY") + if pubkey in node_keys: + del node_keys[pubkey] + else: + wg.set(IFNAME, peer={"public_key": pubkey, "remove": True}) + + for node in node_keys.values(): + add_node_to_wg(wg, node) diff --git a/leylines/leylines/__main__.py b/leylines/leylines/__main__.py new file mode 100644 index 0000000..7c0a889 --- /dev/null +++ b/leylines/leylines/__main__.py @@ -0,0 +1,5 @@ +from leylines import net_init, create_or_get_interface, sync_interface + +net_init() +create_or_get_interface() +sync_interface() diff --git a/leylines/leylines/database.py b/leylines/leylines/database.py new file mode 100644 index 0000000..1703925 --- /dev/null +++ b/leylines/leylines/database.py @@ -0,0 +1,142 @@ +import binascii +import ipaddress +import secrets +import sqlite3 +from typing import Iterable, List, NamedTuple, Optional, Set + +import monocypher + +DEFAULT_DB_PATH = "/var/lib/leylines/leylines.db" +DEFAULT_SUBNET = "172.16.10.1/24" +SERVER_NODE_ID = 1 + + +class Node(NamedTuple): + id: int + name: str + public_ip: Optional[ipaddress.IPv4Address] + ip: ipaddress.IPv4Address + seckey: str + ssh_key: str + + +class Database: + __slots__ = ["conn"] + conn: sqlite3.Connection + + def __init__(self, path=DEFAULT_DB_PATH) -> None: + self.conn = sqlite3.connect(path) + self.conn.execute("PRAGMA foreign_keys = 1") + self.conn.execute( + """CREATE TABLE IF NOT EXISTS nodes (id INTEGER PRIMARY KEY, name TEXT NOT NULL, + public_ip TEXT, ip TEXT NOT NULL, seckey TEXT NOT NULL, + ssh_key TEXT NOT NULL)""") + self.conn.execute( + """CREATE TABLE IF NOT EXISTS node_resources + (node INTEGER NOT NULL, resource TEXT NOT NULL, PRIMARY KEY (node, resource), + FOREIGN KEY(node) REFERENCES nodes(id) ON DELETE CASCADE)""") + self.conn.execute( + "CREATE TABLE IF NOT EXISTS settings (name TEXT PRIMARY KEY, value TEXT NOT NULL)") + self.conn.execute( + "INSERT OR IGNORE INTO settings (name, value) VALUES ('subnet', ?)", (DEFAULT_SUBNET,)) + + self.conn.commit() + + def init_server(self, name: str, public_ip: ipaddress.IPv4Address) -> None: + if self.get_node(SERVER_NODE_ID) is not None: + raise Exception("there is already a server node defined") + self.add_node(name, public_ip, "lol no") + + def get_subnet(self) -> ipaddress.IPv4Interface: + cur = self.conn.cursor() + cur.execute("SELECT value FROM settings WHERE name='subnet'") + return ipaddress.IPv4Interface(cur.fetchone()[0]) + + def _get_free_ip(self) -> ipaddress.IPv4Address: + subnet = self.get_subnet().network + subnets = [self.get_subnet().network] + ips = [n.ip for n in self.get_nodes()] + ips.extend([subnet.network_address, subnet.broadcast_address]) + for ip in ips: + ip_net = ipaddress.IPv4Network(ip) + new_subnets: List[ipaddress.IPv4Network] = [] + for subnet in subnets: + if subnet.overlaps(ip_net): + new_subnets.extend(subnet.address_exclude(ip_net)) + else: + new_subnets.append(subnet) + subnets = new_subnets + if len(subnets) == 0: + raise Exception("no more ips left to allocate!") + return next(subnets[0].hosts()) + + def add_node(self, name: str, public_ip: Optional[ipaddress.IPv4Address], ssh_key: str) -> Node: + cur = self.conn.cursor() + ip = self._get_free_ip() + + insert_public_ip: Optional[str] = None + if public_ip is not None: + insert_public_ip = str(public_ip) + + seckey_bytes = monocypher.crypto_key_exchange_make_key() + seckey = binascii.b2a_base64(seckey_bytes).decode().strip() + cur.execute( + "INSERT INTO nodes (name, public_ip, ip, seckey, ssh_key) VALUES(?, ?, ?, ?, ?) RETURNING id", + (name, insert_public_ip, str(ip), seckey, ssh_key)) + id: int = cur.fetchone()[0] + cur.close() + self.conn.commit() + return Node(id, name, public_ip, ip, seckey, ssh_key) + + def get_node(self, id: int) -> Optional[Node]: + cur = self.conn.cursor() + cur.execute("SELECT id, name, public_ip, ip, seckey, ssh_key FROM nodes WHERE id=?", (id,)) + row = cur.fetchone() + if row is None: + return None + cur.close() + public_ip: Optional[ipaddress.IPv4Address] = None + if row[2] is not None: + public_ip = ipaddress.IPv4Address(row[2]) + return Node(row[0], row[1], public_ip, ipaddress.IPv4Address(row[3]), row[4], row[5]) + + def get_server_node(self) -> Optional[Node]: + return self.get_node(SERVER_NODE_ID) + + def get_nodes(self) -> Iterable[Node]: + cur = self.conn.cursor() + cur.execute("SELECT id, name, public_ip, ip, seckey, ssh_key FROM nodes") + nodes = [] + while True: + row = cur.fetchone() + if row is None: + break + public_ip: Optional[ipaddress.IPv4Address] = None + if row[2] is not None: + public_ip = ipaddress.IPv4Address(row[2]) + nodes.append(Node(row[0], row[1], public_ip, + ipaddress.IPv4Address(row[3]), row[4], row[5])) + cur.close() + return nodes + + def get_node_resources(self, id: int) -> Set[str]: + cur = self.conn.cursor() + cur.execute("SELECT resource FROM node_resources WHERE node=?", (id,)) + resources = set() + while True: + row = cur.fetchone() + if row is None: + break + resources.add(row[0]) + cur.close() + return resources + + def add_node_resource(self, id: int, resource: str) -> None: + self.conn.execute("INSERT OR IGNORE INTO node_resources (node, resource) VALUES (?, ?)", + (id, resource)) + self.conn.commit() + + def remove_node_resource(self, id: int, resource: str) -> None: + self.conn.execute("DELETE FROM node_resources WHERE node=? and resource=?", + (id, resource)) + self.conn.commit() diff --git a/leylines/setup.py b/leylines/setup.py new file mode 100644 index 0000000..8f4d180 --- /dev/null +++ b/leylines/setup.py @@ -0,0 +1,20 @@ +from setuptools import setup + +setup(name='leylines', + version='0.1', + description='Sample text', + url='https://awoo.systems', + author='haskal', + author_email='haskal@awoo.systems', + license='AGPLv3', + packages=['leylines'], + install_requires=[ + # requirements + ], + include_package_data=True, + entry_points={ + 'console_scripts': [ + # bins + ] + }, + zip_safe=True)