From db843ceec89e2921322af41fdab85041c15b2a7b Mon Sep 17 00:00:00 2001 From: Fredrick Amnehagen Date: Thu, 5 Feb 2026 11:29:34 +0100 Subject: [PATCH] initial commit: dynamic infra tooling cli --- bin/infra | 11 ++++++ config.yaml.example | 22 ++++++++++++ infra_cli/config.py | 30 ++++++++++++++++ infra_cli/dns.py | 50 +++++++++++++++++++++++++++ infra_cli/ingress.py | 27 +++++++++++++++ infra_cli/main.py | 82 ++++++++++++++++++++++++++++++++++++++++++++ infra_cli/router.py | 58 +++++++++++++++++++++++++++++++ infra_cli/ssh.py | 43 +++++++++++++++++++++++ setup.py | 16 +++++++++ 9 files changed, 339 insertions(+) create mode 100755 bin/infra create mode 100644 config.yaml.example create mode 100644 infra_cli/config.py create mode 100644 infra_cli/dns.py create mode 100644 infra_cli/ingress.py create mode 100644 infra_cli/main.py create mode 100644 infra_cli/router.py create mode 100644 infra_cli/ssh.py create mode 100644 setup.py diff --git a/bin/infra b/bin/infra new file mode 100755 index 0000000..2b07aaa --- /dev/null +++ b/bin/infra @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +import sys +import os + +# Add parent directory to sys.path to allow running without installation +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from infra_cli.main import main + +if __name__ == "__main__": + main() diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..6d1d32b --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,22 @@ +# LoopAware Infra CLI Configuration + +# Proxmox/DNS Management +proxmox: + host: "10.32.2.1" + user: "root" + dnsmasq_lxc_id: "11209" + ssh_key: "~/.ssh/id_ed25519_no_pass" + +# Ingress Management (HAProxy) +haproxy: + host: "10.32.1.20" + user: "root" + ssh_key: "~/.ssh/id_ed25519_no_pass" + config_dir: "/etc/haproxy/conf.d" + dynamic_dir: "/etc/haproxy/dynamic" + +# Router Management (OpenWrt) +router: + host: "10.32.0.1" + user: "root" + ssh_key: "~/.ssh/id_ed25519_no_pass" diff --git a/infra_cli/config.py b/infra_cli/config.py new file mode 100644 index 0000000..59f9fcd --- /dev/null +++ b/infra_cli/config.py @@ -0,0 +1,30 @@ +import os +import yaml + +DEFAULT_CONFIG_PATH = os.path.expanduser("~/.config/loopaware/infra-cli.yaml") + +class Config: + def __init__(self, config_path=None): + self.path = config_path or os.environ.get("INFRA_CONFIG") or DEFAULT_CONFIG_PATH + self.data = self._load() + + def _load(self): + if not os.path.exists(self.path): + # Fallback to local config if exists + if os.path.exists("config.yaml"): + self.path = "config.yaml" + else: + raise FileNotFoundError(f"Config file not found at {self.path}. Please create it based on config.yaml.example") + + with open(self.path, 'r') as f: + return yaml.safe_vars(f) if hasattr(yaml, 'safe_vars') else yaml.safe_load(f) + + def get(self, key, default=None): + parts = key.split('.') + val = self.data + for part in parts: + if isinstance(val, dict) and part in val: + val = val[part] + else: + return default + return val diff --git a/infra_cli/dns.py b/infra_cli/dns.py new file mode 100644 index 0000000..11d0a3d --- /dev/null +++ b/infra_cli/dns.py @@ -0,0 +1,50 @@ +from .ssh import SSHClient + +class DNSManager: + def __init__(self, config): + self.pve_host = config.get('proxmox.host') + self.pve_user = config.get('proxmox.user', 'root') + self.lxc_id = config.get('proxmox.dnsmasq_lxc_id') + self.ssh_key = config.get('proxmox.ssh_key') + self.client = SSHClient(self.pve_host, self.pve_user, self.ssh_key) + + self.hosts_file = "/etc/dnsmasq.d/dynamic-hosts.conf" + self.dns_file = "/etc/dnsmasq.d/dynamic-dns.conf" + + def exec_lxc(self, cmd): + return self.client.run(f"pct exec {self.lxc_id} -- {cmd}") + + def add_host(self, mac, ip, hostname): + self.exec_lxc(f"touch {self.hosts_file}") + # Check for duplicates + res = self.exec_lxc(f"grep -q -F '{mac}' {self.hosts_file}") + if res.returncode == 0: + raise ValueError(f"MAC {mac} already exists") + + self.client.run(f"pct exec {self.lxc_id} -- sh -c \"echo 'dhcp-host={mac},{hostname},{ip}' >> {self.hosts_file}\") + self.reload() + + def remove_host(self, mac): + self.exec_lxc(f"sed -i '/{mac}/d' {self.hosts_file}") + self.reload() + + def add_dns(self, domain, ip): + self.exec_lxc(f"touch {self.dns_file}") + self.client.run(f"pct exec {self.lxc_id} -- sh -c \"echo 'address=/{domain}/{ip}' >> {self.dns_file}\") + self.reload() + + def remove_dns(self, domain): + self.client.run(f"pct exec {self.lxc_id} -- sh -c \"sed -i '\#address=/{domain}/#d' {self.dns_file}\") + self.reload() + + def reload(self): + res = self.exec_lxc("dnsmasq --test") + if res.returncode == 0: + self.exec_lxc("systemctl reload dnsmasq") + else: + raise RuntimeError(f"Dnsmasq config test failed: {res.stderr}") + + def list(self): + hosts = self.exec_lxc(f"cat {self.hosts_file}").stdout + dns = self.exec_lxc(f"cat {self.dns_file}").stdout + return {"hosts": hosts, "dns": dns} diff --git a/infra_cli/ingress.py b/infra_cli/ingress.py new file mode 100644 index 0000000..bbfbc7a --- /dev/null +++ b/infra_cli/ingress.py @@ -0,0 +1,27 @@ +from .ssh import SSHClient + +class IngressManager: + def __init__(self, config): + self.host = config.get('haproxy.host') + self.user = config.get('haproxy.user', 'root') + self.ssh_key = config.get('haproxy.ssh_key') + self.client = SSHClient(self.host, self.user, self.ssh_key) + + def add(self, domain, ip, port, https=False): + cmd = f"ingress-manager add {domain} {ip} {port}" + if https: + cmd += " --https" + res = self.client.run(cmd) + if res.returncode != 0: + raise RuntimeError(f"Failed to add ingress: {res.stderr}") + return res.stdout + + def remove(self, domain): + res = self.client.run(f"ingress-manager remove {domain}") + if res.returncode != 0: + raise RuntimeError(f"Failed to remove ingress: {res.stderr}") + return res.stdout + + def list(self): + res = self.client.run("ingress-manager list") + return res.stdout diff --git a/infra_cli/main.py b/infra_cli/main.py new file mode 100644 index 0000000..4dcbdb3 --- /dev/null +++ b/infra_cli/main.py @@ -0,0 +1,82 @@ +import click +from .config import Config +from .dns import DNSManager +from .ingress import IngressManager +from .router import RouterManager +import sys + +@click.group() +@click.option('--config', help='Path to config file') +@click.pass_context +def cli(ctx, config): + """LoopAware Infrastructure Management CLI""" + try: + ctx.obj = Config(config) + except Exception as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + +@cli.group() +def dns(): + """Manage DNS and DHCP""" + pass + +@dns.command(name='add-host') +@click.argument('mac') +@click.argument('ip') +@click.argument('hostname') +@click.pass_obj +def dns_add_host(config, mac, ip, hostname): + mgr = DNSManager(config) + mgr.add_host(mac, ip, hostname) + click.echo(f"Added host {hostname} ({ip})") + +@dns.command(name='remove-host') +@click.argument('mac') +@click.pass_obj +def dns_remove_host(config, mac): + mgr = DNSManager(config) + mgr.remove_host(mac) + click.echo(f"Removed host {mac}") + +@dns.command(name='list') +@click.pass_obj +def dns_list(config): + mgr = DNSManager(config) + data = mgr.list() + click.echo(data['hosts']) + click.echo(data['dns']) + +@cli.group() +def ingress(): + """Manage HAProxy Ingress""" + pass + +@ingress.command(name='add') +@click.argument('domain') +@click.argument('ip') +@click.argument('port', type=int) +@click.option('--https', is_flag=True, help='Target uses HTTPS') +@click.pass_obj +def ingress_add(config, domain, ip, port, https): + mgr = IngressManager(config) + mgr.add(domain, ip, port, https) + click.echo(f"Added ingress for {domain}") + +@cli.group() +def router(): + """Manage Router Port Forwards""" + pass + +@router.command(name='list') +@click.pass_obj +def router_list(config): + mgr = RouterManager(config) + for rule in mgr.list(): + click.echo(f"[{rule['section']}] {rule['name']}: {rule['proto']} {rule['port']} -> {rule['dest']}") + +def main(): + cli(obj={}) + +if __name__ == "__main__": + main() diff --git a/infra_cli/router.py b/infra_cli/router.py new file mode 100644 index 0000000..988f415 --- /dev/null +++ b/infra_cli/router.py @@ -0,0 +1,58 @@ +from .ssh import SSHClient +import os + +class RouterManager: + def __init__(self, config): + self.host = config.get('router.host') + self.user = config.get('router.user', 'root') + self.ssh_key = config.get('router.ssh_key') + self.password = os.environ.get("ROUTER_PASS") + self.client = SSHClient(self.host, self.user, self.ssh_key, self.password) + + def run_uci(self, cmds): + res = self.client.run(cmds) + if res.returncode != 0: + raise RuntimeError(f"UCI command failed: {res.stderr}") + return res + + def add_forward(self, name, proto, ext_port, int_ip, int_port): + config_name = name.lower().replace(" ", "_").replace("-", "_") + + cmds = f"uci set firewall.{config_name}=redirect; " \ + f"uci set firewall.{config_name}.name='{name}'; " \ + f"uci set firewall.{config_name}.proto='{proto}'; " \ + f"uci set firewall.{config_name}.src='wan'; " \ + f"uci set firewall.{config_name}.dest='lan'; " \ + f"uci set firewall.{config_name}.src_dport='{ext_port}'; " \ + f"uci set firewall.{config_name}.dest_ip='{int_ip}'; " \ + f"uci set firewall.{config_name}.dest_port='{int_port}'; " \ + f"uci set firewall.{config_name}.target='DNAT'; " \ + f"uci commit firewall; " \ + f"/etc/init.d/firewall reload" + + self.run_uci(cmds) + + def remove_forward(self, section_name): + cmds = f"uci delete firewall.{section_name}; uci commit firewall; /etc/init.d/firewall reload" + self.run_uci(cmds) + + def list(self): + # Get sections + res = self.client.run("uci show firewall | grep '=redirect' | cut -d. -f2 | cut -d= -f1 | sort | uniq") + sections = res.stdout.strip().split('\n') + + results = [] + for section in sections: + if not section: continue + name = self.client.run(f"uci get firewall.{section}.name", capture=True).stdout.strip() + proto = self.client.run(f"uci get firewall.{section}.proto", capture=True).stdout.strip() + port = self.client.run(f"uci get firewall.{section}.src_dport", capture=True).stdout.strip() + dest = self.client.run(f"uci get firewall.{section}.dest_ip", capture=True).stdout.strip() + results.append({ + "section": section, + "name": name, + "proto": proto, + "port": port, + "dest": dest + }) + return results diff --git a/infra_cli/ssh.py b/infra_cli/ssh.py new file mode 100644 index 0000000..1461aaa --- /dev/null +++ b/infra_cli/ssh.py @@ -0,0 +1,43 @@ +import subprocess +import os + +class SSHClient: + def __init__(self, host, user="root", key_path=None, password=None): + self.host = host + self.user = user + self.key_path = os.path.expanduser(key_path) if key_path else None + self.password = password + + def run(self, cmd, capture=True): + ssh_cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"] + + if self.key_path: + ssh_cmd += ["-i", self.key_path] + + target = f"{self.user}@{self.host}" + + if self.password: + full_cmd = ["sshpass", "-p", self.password] + ssh_cmd + [target, cmd] + else: + full_cmd = ssh_cmd + [target, cmd] + + result = subprocess.run( + full_cmd, + capture_output=capture, + text=True + ) + return result + + def scp_to(self, local_path, remote_path): + scp_cmd = ["scp", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"] + if self.key_path: + scp_cmd += ["-i", self.key_path] + + target = f"{self.user}@{self.host}:{remote_path}" + + if self.password: + full_cmd = ["sshpass", "-p", self.password] + scp_cmd + [local_path, target] + else: + full_cmd = scp_cmd + [local_path, target] + + return subprocess.run(full_cmd, capture_output=True) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5b6fafb --- /dev/null +++ b/setup.py @@ -0,0 +1,16 @@ +from setuptools import setup, find_packages + +setup( + name="loopaware-infra-cli", + version="0.1.0", + packages=find_packages(), + install_requires=[ + "click", + "pyyaml", + ], + entry_points={ + "console_scripts": [ + "infra=infra_cli.main:main", + ], + }, +)