Compare commits

...

12 Commits

Author SHA1 Message Date
1d4b720cee feat: ssh config builder & fetcher 2025-10-29 17:54:42 +01:00
c8640eb035 fix: cleanup stdout output 2025-10-27 14:13:53 +01:00
2a9559642b fix: simple warnings/handlers for ipv6 2025-10-27 14:13:51 +01:00
ac2fbfdef2 fix: add error for invalid port config 2025-10-27 14:13:36 +01:00
9cb5204fec add: qemu iptables hook 2025-10-24 18:05:16 +02:00
cd76f3fe6b wip: wireguard server extender 2025-05-10 12:46:50 +02:00
Kathrin Maurer
9634f35a1e feat: kubernetes ingress/alt http port support 2025-02-28 16:15:51 +01:00
73106f6d57 wip: skel for provisioning script 2025-01-18 13:18:40 +00:00
Kathrin Maurer
3222b4b437 fix: soft-fail on invalid backup config 2025-01-09 23:43:16 +01:00
Kathrin Maurer
e99e729a83 fix: allow targetportoverwrite as L4 param 2025-01-05 19:50:05 +01:00
Kathrin Maurer
84cb2f9fb2 fix: add master-address.txt to gitignore 2025-01-05 17:39:12 +01:00
Kathrin Maurer
f71269d14b fix: check for invalid entries 2025-01-05 17:38:45 +01:00
12 changed files with 438 additions and 12 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ password.txt
ssh_config_for_clients ssh_config_for_clients
virsh_backup virsh_backup
.wireguard_keys .wireguard_keys
master-address.txt

View File

@@ -3,6 +3,7 @@ import functools
import os import os
import subprocess import subprocess
import json import json
import sys
environment = jinja2.Environment(loader=jinja2.FileSystemLoader(searchpath="./templates")) environment = jinja2.Environment(loader=jinja2.FileSystemLoader(searchpath="./templates"))
@@ -20,6 +21,15 @@ def createBackupScriptStructure(backupList, baseDomain="", icingaOnly=False, bac
asyncIcingaConf = {} asyncIcingaConf = {}
for backup in backupList: for backup in backupList:
if not backup:
print("Warning: Empty backup mapping in List", file=sys.stderr)
continue
if type(backup) == str:
print(f"Warning: Backup Entry is a stirng instead of a dict-object ({backup})", file=sys.stderr)
continue
if backup.get("disabled"): if backup.get("disabled"):
continue continue

View File

@@ -0,0 +1,45 @@
#!/usr/bin/python
import sys
import subprocess
import os
import paramiko
HOSTS = [
"root@atlantishq.de",
"root@katzencluster.atlantishq.de",
"root@atlantis-helsinki.atlantishq.de"
]
BASE_FILE = "~/.ssh/base_config"
MAIN_CONFIG = "~/.ssh/config"
if __name__ == "__main__":
contents = ""
for target in HOSTS:
RUN_CMD = ["ssh", "-t", target , "cd /root/athq-vm-management/; python3 main.py"]
COPY_CMD = ["ssh", "-t", target, "cat /root/athq-vm-management/ssh_config_for_clients"]
print("Doing", target, file=sys.stderr)
out = subprocess.run(RUN_CMD, capture_output=True, universal_newlines=True)
if out.returncode != 0:
print("failed (run command)!")
print(out.stderr)
sys.exit(1)
out = subprocess.run(COPY_CMD, capture_output=True, universal_newlines=True)
if out.returncode != 0:
print("failed (cat command)!")
print(out.stderr)
sys.exit(1)
contents += out.stdout
contents += "\n"
with open(os.path.expanduser(BASE_FILE)) as f:
with open(os.path.expanduser(MAIN_CONFIG), "w") as fout:
fout.write(f.read())
fout.write("\n")
fout.write(contents)

View File

@@ -0,0 +1,12 @@
#!/bin/bash
if [[ "$2" == "started" ]]; then
/usr/sbin/iptables -I LIBVIRT_FWI 1 -o virbr0 -i atlantishq -d 192.168.123.0/24 -j ACCEPT
/usr/sbin/iptables -I LIBVIRT_FWI 1 -o virbr0 -i at_helsinki -d 192.168.123.0/24 -j ACCEPT
/usr/sbin/iptables -I LIBVIRT_FWI 1 -o virbr0 -i hc_worker_1 -d 192.168.123.0/24 -j ACCEPT
/usr/sbin/iptables -I LIBVIRT_FWO 1 -i virbr0 -s 192.168.123.0/24 -o atlantishq -j ACCEPT
/usr/sbin/iptables -I LIBVIRT_FWO 1 -i virbr0 -s 192.168.123.0/24 -o hc_worker_1 -j ACCEPT
/usr/sbin/iptables -I LIBVIRT_FWO 1 -i virbr0 -s 192.168.123.0/24 -o at_helsinki -j ACCEPT
fi

View File

@@ -0,0 +1,107 @@
import subprocess
import json
import os
from pathlib import Path
import configparser
import ipaddress
LINK_BASE = f"10.0.{len(EXISTING_HOSTS)+10}.1/32"
LINK_BASE_PEER = f"10.0.{len(EXISTING_HOSTS)+10}.2/32"
PORT_BASE = 51820 + len(EXISTING_HOSTS)
MASTER_CONFIG = """[Interface]
PrivateKey = {local_private}
Address = {local_address}
[Peer]
Endpoint = {peer_endpoint}
PublicKey = {peer_public}
AllowedIPs = {", ".join(peer_allowed_ips)}
"""
PEER_CONFIG = f"""[Interface]
PrivateKey = {peer_private}
Address = {peer_address}
[Peer]
PublicKey = {local_public}
AllowedIPs = {", ".join(peer_allowed_ips)}
"""
def create_wireguard_config_pair(
new_host: str,
old_host: str,
new_host_allowed_ips: list[str],
old_host_allowed_ips,
old_host_public_key,
old_host_private_key,
output_dir: str = "./tmp"
):
# Validate input subnet
try:
ipaddress.IPv4Interface(local_address)
except ValueError as e:
raise ValueError(f"Invalid IP address: {local_address}") from e
Path(output_dir).mkdir(parents=True, exist_ok=True)
# Generate key pairs
local_private, local_public = generate_keypair()
peer_private, peer_public = generate_keypair()
new_host = MASTER_CONFIG.format(
local_private = local_private,
local_address = LINK_BASE,
local_public = local_public,
peer_endpoint = old_host,
peer_public = old_host_public_key,
peer_allowed_ips = [LINK_BASE_PEER, old_host_allowed_ips],
)
old_host = PEER_CONFIG.format(
peer_private = old_host_private_key,
peer_address = LINK_BASE_PEER,
local_public= local_public,
peer_allowed_ips = [LINK_BASE, new_host_subnet]
)
old_host_file = Path(output_dir) / f"{new_host}_local.conf"
new_host_file = Path(output_dir) / f"{new_host}_peer.conf"
new_host_file.write_text(new_host)
old_host_file.write_text(old_host)
return str(local_file), str(peer_file)
def generate_wireguard_keys():
private_key = subprocess.check_output(["wg", "genkey"]).strip()
public_key = subprocess.check_output(["wg", "pubkey"], input=private_key).strip()
return private_key.decode(), public_key.decode()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="WireGuard utility script")
parser.add_argument("--new-host", help="Hostname or IP of the new host")
parser.add_argument("--new-host-subnet", type=ipaddress.IPv4Network, help="Subnet of new host")
parser.add_argument("--write", action=argparse.BooleanOptionalAction, default=False)
parser.add_argument("--allow-overwrite", action=argparse.BooleanOptionalAction, default=False)
args = parser.parse_args()
configs = {}
for host in EXISTING_HOSTS:
fetch_conf_files(host)
configs |= load_all_confs(host)
# output current state #
print(json.dumps(configs, indent=2))
if args.new_host and args.new_host_subnet:
# TODO get keys from old host
create_wireguard_config_pair(LINK_BASE, new_host) #TODO

View File

@@ -0,0 +1,159 @@
import subprocess
import json
import os
from pathlib import Path
import configparser
import ipaddress
# FIXME/WARNING: dont change this order without checking the output
EXISTING_HOSTS = [
"atlantishq.de",
"katzencluster.atlantishq.de",
"atlantis-helsinki.atlantishq.de",
]
USER = "root"
REMOTE_DIR = "/etc/wireguard"
LOCAL_TMP_DIR = "./tmp"
LINK_BASE = f"10.0.{len(EXISTING_HOSTS)+10}.1/32"
LINK_BASE_PEER = f"10.0.{len(EXISTING_HOSTS)+10}.2/32"
PORT_BASE = 51820 + len(EXISTING_HOSTS)
def fetch_conf_files(host):
print(f"Doing {host}\n")
os.makedirs(LOCAL_TMP_DIR, exist_ok=True)
remote_path = f"{USER}@{host}:{REMOTE_DIR}/*.conf"
subprocess.run(["scp", remote_path, LOCAL_TMP_DIR], check=True)
print(f"\n{host} retrieved successfully.")
def parse_conf_file(filepath, host):
config = {}
current_section = "Interface"
config[current_section] = {}
with open(filepath) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
current_section = line.strip("[]")
config[current_section] = {}
else:
if "=" in line:
key, val = map(str.strip, line.split("=", 1))
config[current_section][key] = val
return config
def load_all_confs(host):
all_configs = {}
for conf_file in Path(LOCAL_TMP_DIR).glob("*.conf"):
conf_name = conf_file.stem
all_configs[conf_name] = parse_conf_file(conf_file, host)
os.remove(conf_file)
return { host : all_configs }
def create_wireguard_config_pair(
new_host: str,
old_host: str,
new_host_allowed_ips: list[str],
old_host_allowed_ips,
old_host_public_key,
old_host_private_key,
output_dir: str = "./tmp"
):
# Validate input subnet
try:
ipaddress.IPv4Interface(local_address)
except ValueError as e:
raise ValueError(f"Invalid IP address: {local_address}") from e
Path(output_dir).mkdir(parents=True, exist_ok=True)
# Generate key pairs
local_private, local_public = generate_keypair()
peer_private, peer_public = generate_keypair()
MASTER_CONFIG = """[Interface]
PrivateKey = {local_private}
Address = {local_address}
[Peer]
Endpoint = {peer_endpoint}
PublicKey = {peer_public}
AllowedIPs = {", ".join(peer_allowed_ips)}
"""
PEER_CONFIG = f"""[Interface]
PrivateKey = {peer_private}
Address = {peer_address}
[Peer]
PublicKey = {local_public}
AllowedIPs = {", ".join(peer_allowed_ips)}
"""
new_host = MASTER_CONFIG.format(
local_private = local_private,
local_address = LINK_BASE,
local_public = local_public,
peer_endpoint = old_host,
peer_public = old_host_public_key,
peer_allowed_ips = [LINK_BASE_PEER, old_host_allowed_ips],
)
old_host = PEER_CONFIG.format(
peer_private = old_host_private_key,
peer_address = LINK_BASE_PEER,
local_public= local_public,
peer_allowed_ips = [LINK_BASE, new_host_subnet]
)
old_host_file = Path(output_dir) / f"{new_host}_local.conf"
new_host_file = Path(output_dir) / f"{new_host}_peer.conf"
new_host_file.write_text(new_host)
old_host_file.write_text(old_host)
return str(local_file), str(peer_file)
def generate_wireguard_keys():
private_key = subprocess.check_output(["wg", "genkey"]).strip()
public_key = subprocess.check_output(["wg", "pubkey"], input=private_key).strip()
return private_key.decode(), public_key.decode()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="WireGuard utility script")
parser.add_argument("--new-host", help="Hostname or IP of the new host")
parser.add_argument("--new-host-subnet", type=ipaddress.IPv4Network, help="Subnet of new host")
parser.add_argument("--write", action=argparse.BooleanOptionalAction, default=False)
parser.add_argument("--allow-overwrite", action=argparse.BooleanOptionalAction, default=False)
args = parser.parse_args()
configs = {}
for host in EXISTING_HOSTS:
fetch_conf_files(host)
configs |= load_all_confs(host)
# output current state #
print(json.dumps(configs, indent=2))
if args.new_host and args.new_host_subnet:
pub, priv = generate_wireguard_keys()
create_wireguard_config_pair(LINK_BASE, new_host

View File

@@ -68,10 +68,14 @@ if __name__ == "__main__":
f.write("\n") f.write("\n")
# backup # # backup #
try:
with open("./config/backup.json") as f: with open("./config/backup.json") as f:
backup.createBackupScriptStructure(json.load(f), baseDomain=MASTER_ADDRESS, backup.createBackupScriptStructure(json.load(f), baseDomain=MASTER_ADDRESS,
icingaOnly=not args.backup, icingaOnly=not args.backup,
backup_no_async_icinga=args.backup_no_async_icinga) backup_no_async_icinga=args.backup_no_async_icinga)
except json.decoder.JSONDecodeError as e:
print("WARNING: Failed loading backup.json - either empty or invalid json!", file=sys.stderr)
print(e, file=sys.stderr)
# copy nginx maps # # copy nginx maps #
if not args.backup and args.do_nginx_map_cert_manager: if not args.backup and args.do_nginx_map_cert_manager:

View File

@@ -32,14 +32,15 @@ def dump_config(vmList, masterAddress):
for vmo in vmList: for vmo in vmList:
relevant_subdomains = filter(lambda x: x.get("no-terminate-ssl"), vmo.subdomains) relevant_subdomains = filter(lambda x: x.get("no-terminate-ssl"), vmo.subdomains)
for s in relevant_subdomains: for s in relevant_subdomains:
print(s) # print(s, "ssl_target_port", s.get("ssl_target_port"))
# build the map contents # # build the map contents #
if s.get("include-subdomains"): if s.get("include-subdomains"):
match = "~.*{}".format(s.get("name")) match = "~.*{}".format(s.get("name"))
else: else:
match = s.get("name") match = s.get("name")
ssl_passthrough_map.append("{} {}:443;".format(match, vmo.ip)) ssl_target_port = s.get("ssl_target_port") or 443
ssl_passthrough_map.append("{} {}:{};".format(match, vmo.ip, ssl_target_port))
environment = jinja2.Environment(loader=jinja2.FileSystemLoader(searchpath="./templates")) environment = jinja2.Environment(loader=jinja2.FileSystemLoader(searchpath="./templates"))
template = environment.get_template("nginx_stream_ssl_map.conf.j2") template = environment.get_template("nginx_stream_ssl_map.conf.j2")
@@ -65,7 +66,7 @@ def dump_config(vmList, masterAddress):
for vmo in vmList: for vmo in vmList:
for subdomain in vmo.subdomains: for subdomain in vmo.subdomains:
if vmo.noTerminateACME: if vmo.noTerminateACME:
print("Not terminating ACME for: {}".format(subdomain)) print("Not terminating ACME for: {}".format(subdomain.get("name")))
continue continue
if type(subdomain) == dict: if type(subdomain) == dict:
domains.append(subdomain["name"]) domains.append(subdomain["name"])
@@ -94,10 +95,10 @@ def dump_config(vmList, masterAddress):
f.write(content) f.write(content)
def check_transparent_proxy_loader(): def check_transparent_proxy_loader():
retcode = os.system("systemctl is-enabled nginx-iptables.service") retcode = os.system("systemctl -q is-enabled nginx-iptables.service")
if retcode != 0: if retcode != 0:
print("############################ WARNING ###############################") print("############################ WARNING ###############################")
print("+++ You may have transparent proxy rules but the service to load +++") print("+++ You may have transparent proxy rules but the service to load +++")
print("+++ them is not enabled or missing, a restart WILL break your +++") print("+++ them is not enabled or missing, a restart WILL break your +++")
print("+++ setup! Add see nginx-iptables.service in the project root +++") print("+++ setup! Look at nginx-iptables.service in the project root +++")
print("############################ WARNING ###############################") print("############################ WARNING ###############################")

40
provisioning/mac.py Normal file
View File

@@ -0,0 +1,40 @@
import libvirt
import xml.etree.ElementTree as ET
def get_mac_address(domain):
try:
# Connect to the libvirt daemon
conn = libvirt.open()
if conn is None:
raise RuntimeError("Failed to open connection to the hypervisor.")
# Lookup the domain by name
vm = conn.lookupByName(domain)
if vm is None:
raise ValueError(f"Domain '{domain}' not found.")
# Get the XML description of the domain
xml_desc = vm.XMLDesc()
# Parse the XML to extract the MAC address
root = ET.fromstring(xml_desc)
mac_element = root.find(".//mac")
if mac_element is not None:
mac_address = mac_element.attrib.get('address')
return mac_address
else:
raise ValueError("MAC address not found in XML.")
except libvirt.libvirtError as e:
return f"A libvirt error occurred: {e}"
except Exception as e:
return f"An error occurred: {e}"
finally:
if conn:
conn.close()
# Replace 'debian' with your domain name
domain_name = "debian"
mac_address = get_mac_address(domain_name)
print(f"MAC Address: {mac_address}")

26
provisioning/master.py Normal file
View File

@@ -0,0 +1,26 @@
# get new domain name
# create a VM and clone disk
# set CPU & RAM
# get mac
virsh_vm.get_mac(domain)
# change network
network.set_ip_for_mac_domain(domain, mac)
# net destory net start ?
# start vm
# change hostname
# add vm subdomains
# run python script
# run cert script
# reload nginx

View File

@@ -74,7 +74,11 @@ server{
{% else %} {% else %}
location / { location / {
{{ proxy_pass_blob }} {{ proxy_pass_blob }}
{% if http_target_port %}
proxy_pass http://{{ targetip }}:{{ http_target_port }};
{% else %}
proxy_pass http://{{ targetip }}:80; proxy_pass http://{{ targetip }}:80;
{% endif %}
} }
{% endif %} {% endif %}

19
vm.py
View File

@@ -1,4 +1,5 @@
import libvirt import libvirt
import json
import jinja2 import jinja2
class VM: class VM:
@@ -36,6 +37,8 @@ class VM:
network = con.networkLookupByName(self.network) network = con.networkLookupByName(self.network)
leases = network.DHCPLeases() leases = network.DHCPLeases()
for l in leases: for l in leases:
if not l.get("type") == 0: # FIXME: only ipv4 for now
continue
if l.get("hostname") == self.hostname: if l.get("hostname") == self.hostname:
return l return l
@@ -69,6 +72,7 @@ class VM:
isUDP = proto == "udp" isUDP = proto == "udp"
proxy_timeout = portStruct.get("proxy_timeout") or "10s" proxy_timeout = portStruct.get("proxy_timeout") or "10s"
extra_content = portStruct.get("extra-content") extra_content = portStruct.get("extra-content")
targetportoverwrite = portStruct.get("targetportoverwrite")
compositeName = "-".join((self.hostname, name, portstring, proto)) compositeName = "-".join((self.hostname, name, portstring, proto))
@@ -78,6 +82,7 @@ class VM:
component = template.render(targetip=self.ip, udp=isUDP, portstring=portstring, component = template.render(targetip=self.ip, udp=isUDP, portstring=portstring,
transparent=transparent, proxy_timeout=proxy_timeout, transparent=transparent, proxy_timeout=proxy_timeout,
comment=compositeName, extra_content=extra_content, comment=compositeName, extra_content=extra_content,
targetportoverwrite=targetportoverwrite,
port_interfaces=port_interfaces) port_interfaces=port_interfaces)
components.append(component) components.append(component)
@@ -89,7 +94,14 @@ class VM:
components = [] components = []
template = self.environment.get_template("nginx_stream_block.conf.j2") template = self.environment.get_template("nginx_stream_block.conf.j2")
if not self.isExternal: if not self.isExternal:
try:
self.sshOutsidePort = 7000 + int(self.ip.split(".")[-1]) self.sshOutsidePort = 7000 + int(self.ip.split(".")[-1])
except ValueError as e:
print(f"Warning: {self.hostname} Invalid IP (IPv6 is not supported) {e}",
file=sys.stderr)
return []
component = template.render(targetip=self.ip, udp=False, component = template.render(targetip=self.ip, udp=False,
portstring=self.sshOutsidePort, portstring=self.sshOutsidePort,
targetportoverwrite=7000, targetportoverwrite=7000,
@@ -134,7 +146,7 @@ class VM:
for subdomain in self.subdomains: for subdomain in self.subdomains:
if subdomain.get("no-terminate-ssl"): if subdomain.get("no-terminate-ssl"):
print("Not terminating TLS for: {}".format(subdomain)) print("Not terminating TLS for: {}".format(subdomain.get("name")))
if type(subdomain) != dict: if type(subdomain) != dict:
raise ValueError("Subdomain must be object containing 'name' ") raise ValueError("Subdomain must be object containing 'name' ")
@@ -156,6 +168,10 @@ class VM:
if subdomain.get("include-subdomains") and not subdomain.get("no-terminate-ssl"): if subdomain.get("include-subdomains") and not subdomain.get("no-terminate-ssl"):
raise ValueError("Wildcard Subdomain not supported with SSL Termination") raise ValueError("Wildcard Subdomain not supported with SSL Termination")
if "port" in subdomain and "no-terminate-ssl" in subdomain:
print(json.dumps(subdomain, indent=2))
raise ValueError("'port' is not allowed with no-terminate-ssl subdomain, use http_target_port and ssl_target_port")
component = template.render(targetip=self.ip, targetport=targetport, component = template.render(targetip=self.ip, targetport=targetport,
servernames=[subdomain["name"]], comment=compositeName, servernames=[subdomain["name"]], comment=compositeName,
proxy_pass_blob=self.proxy_pass_blob, proxy_pass_blob=self.proxy_pass_blob,
@@ -166,6 +182,7 @@ class VM:
include_subdomains=subdomain.get("include-subdomains"), include_subdomains=subdomain.get("include-subdomains"),
cert_optional=cert_optional, cert_optional=cert_optional,
cert_non_optional=cert_non_optional, cert_non_optional=cert_non_optional,
http_target_port=subdomain.get("http_target_port"),
cert_header_line=header_line) cert_header_line=header_line)
components.append(component) components.append(component)