commit c04a9a752a47fc84eba389a8e292f339f7896b11 Author: Benjamin Tayehanpour Date: Tue Jun 16 22:47:04 2026 +0200 works enough for now diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..697f12b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Configuration files +*.yml diff --git a/compiler/__init__.py b/compiler/__init__.py new file mode 100644 index 0000000..7f5a7ef --- /dev/null +++ b/compiler/__init__.py @@ -0,0 +1,5 @@ +from .parser import load_services, load_globals +from .nat import build_nat_rules +from .firewall import build_firewall_rules +from .dns import build_dns +from .render import render_nat diff --git a/compiler/dns.py b/compiler/dns.py new file mode 100644 index 0000000..fdd19ec --- /dev/null +++ b/compiler/dns.py @@ -0,0 +1,6 @@ +def build_dns(services, cfg): + return([ + {"name": s.name, "ip": s.internal_ip} + for s in services + if "dns" in s.exposure + ]) diff --git a/compiler/firewall.py b/compiler/firewall.py new file mode 100644 index 0000000..d2fe184 --- /dev/null +++ b/compiler/firewall.py @@ -0,0 +1,9 @@ +def build_firewall_rules(services, cfg): + rules = [] + for s in services: + if "nat" in s.exposure: + rules.append({ + "service": s.name, + "allow_port": s.public_port, + }) + return(rules) diff --git a/compiler/models.py b/compiler/models.py new file mode 100644 index 0000000..d756248 --- /dev/null +++ b/compiler/models.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass + +@dataclass(frozen=True) +class Service: + name: str + internal_ip: str + internal_port: int + public_port: int + protocol: str + exposure: set[str] + +@dataclass(frozen=True) +class GlobalConfig: + wan_interface: str + public_ip: str + lan_interface: str + internal_cidr: str diff --git a/compiler/nat.py b/compiler/nat.py new file mode 100644 index 0000000..53f53b7 --- /dev/null +++ b/compiler/nat.py @@ -0,0 +1,29 @@ +import hashlib + +def rule_id(service_name: str, kind: str) -> int: + h = hashlib.sha1(f"{service_name}:{kind}".encode()).hexdigest() + return(int(h[:4], 16)) # here's hoping + +def build_nat_rules(services, cfg): + rules = [] + + for s in services: + rid_wan = rule_id(s.name, "dnat_wan") + rid_hairpin = rule_id(s.name, "dnat_hairpin") + + rules.append({ + "id": rid_wan, + "service": s, + "cfg": cfg, + "kind": "dnat_wan", + }) + + if "nat" in s.exposure: + rules.append({ + "id": rid_hairpin, + "service": s, + "cfg": cfg, + "kind": "dnat_hairpin", + }) + + return(rules) diff --git a/compiler/parser.py b/compiler/parser.py new file mode 100644 index 0000000..be8b3b4 --- /dev/null +++ b/compiler/parser.py @@ -0,0 +1,27 @@ +import yaml +from .models import GlobalConfig, Service + +def load_services(path: str) -> list[Service]: + raw = yaml.safe_load(open(path))["services"] + + services = [] + for name, s in raw.items(): + ip, port = s["endpoint"].split(":") + services.append(Service( + name=name, + internal_ip=ip, + internal_port=int(port), + public_port=int(s["public_port"]), + protocol=s["protocol"], + exposure=set(s.get("exposure", [])), + )) + return(services) + +def load_globals(path: str) -> GlobalConfig: + g = yaml.safe_load(open(path))["wan"] + return GlobalConfig( + wan_interface=g["wan_interface"], + public_ip=g["public_ip"], + lan_interface=g["lan_interface"], + internal_cidr=g["internal_cidr"], + ) diff --git a/compiler/render.py b/compiler/render.py new file mode 100644 index 0000000..45512df --- /dev/null +++ b/compiler/render.py @@ -0,0 +1,7 @@ +from jinja2 import Environment, FileSystemLoader + +env = Environment(loader=FileSystemLoader("templates")) + +def render_nat(rules): + tmpl = env.get_template("vyos_nat.j2") + return(tmpl.render(rules=rules)) diff --git a/config/globals.yml.example b/config/globals.yml.example new file mode 100644 index 0000000..68d1a01 --- /dev/null +++ b/config/globals.yml.example @@ -0,0 +1,5 @@ +wan: + wan_interface: eth0 + public_ip: 192.0.2.1 + lan_interface: eth1 + internal_cidr: 192.168.10.0/24 diff --git a/config/services.yml.example b/config/services.yml.example new file mode 100644 index 0000000..50a2525 --- /dev/null +++ b/config/services.yml.example @@ -0,0 +1,13 @@ +# config/services.yml +services: + immich: + endpoint: 192.168.10.20:2283 + public_port: 443 + protocol: tcp + exposure: [nat, dns] + + nextcloud: + endpoint: 192.168.10.30:443 + public_port: 443 + protocol: tcp + exposure: [nat] diff --git a/main.py b/main.py new file mode 100644 index 0000000..0fea222 --- /dev/null +++ b/main.py @@ -0,0 +1,33 @@ +import argparse +from compiler import ( + load_services, + load_globals, + build_nat_rules, + build_firewall_rules, + build_dns, + render_nat, +) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=["plan", "render"]) + args = parser.parse_args() + + services = load_services("config/services.yml") + globals_config = load_globals("config/globals.yml") + + nat_rules = build_nat_rules(services, globals_config) + fw_rules = build_firewall_rules(services, globals_config) + dns_entries = build_dns(services, globals_config) + + if args.command == "plan": + print("Services:", len(services)) + print("NAT rules:", len(nat_rules)) + print("Firewall rules:", len(fw_rules)) + print("DNS entries:", len(dns_entries)) + + elif args.command == "render": + print(render_nat(nat_rules)) + +if __name__ == "__main__": + main() diff --git a/templates/vyos_nat.j2 b/templates/vyos_nat.j2 new file mode 100644 index 0000000..3718fdf --- /dev/null +++ b/templates/vyos_nat.j2 @@ -0,0 +1,20 @@ +{% for r in rules %} +{% if r.kind == "dnat_wan" %} +set nat destination rule {{ r.id }} description "{{ r.service.name }}" +set nat destination rule {{ r.id }} destination port {{ r.service.public_port }} +set nat destination rule {{ r.id }} inbound-interface name {{ r.cfg.wan_interface }} +set nat destination rule {{ r.id }} protocol {{ r.service.protocol }} +set nat destination rule {{ r.id }} translation address {{ r.service.internal_ip }} +set nat destination rule {{ r.id }} translation port {{ r.service.internal_port }} + +{% elif r.kind == "dnat_hairpin" %} +set nat destination rule {{ r.id }} description "{{ r.service.name }} hairpin" +set nat destination rule {{ r.id }} inbound-interface name {{ r.cfg.lan_interface }} +set nat destination rule {{ r.id }} destination address {{ r.cfg.public_ip }} +set nat destination rule {{ r.id }} destination port {{ r.service.public_port }} +set nat destination rule {{ r.id }} protocol {{ r.service.protocol }} +set nat destination rule {{ r.id }} translation address {{ r.service.internal_ip }} +set nat destination rule {{ r.id }} translation port {{ r.service.internal_port }} + +{% endif %} +{% endfor %}