diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a0022f --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# LoopAware Infrastructure CLI + +Professional CLI tooling for managing dynamic infrastructure resources (DNS, DHCP, Ingress, and Port Forwarding). + +## Features +- **DNS/DHCP:** Manage `dnsmasq` reservations and custom records on `la-dnsmasq-01`. +- **Ingress:** Manage HAProxy subdomains and backend routing. +- **Router:** Manage OpenWrt firewall redirects. +- **Agent-Friendly:** Designed for use by AI agents and developers. + +## Installation + +```bash +cd external/dynamic-infra-tooling +pip install -e . +``` + +## Configuration + +Copy `config.yaml.example` to `config.yaml` and update with your infrastructure details. + +```bash +cp config.yaml.example config.yaml +``` + +## Usage + +Use the `infra` command: + +```bash +# List DNS entries +infra dns list + +# Add an ingress +infra ingress add my-app.loopaware.com 10.32.70.50 80 + +# Add a port forward (requires ROUTER_PASS env var) +export ROUTER_PASS='...' +infra router add "My-Service" tcp 5000 10.32.70.50 5000 +``` + +## Testing + +```bash +pytest tests/test_cli.py +``` diff --git a/infra_cli/__pycache__/config.cpython-313.pyc b/infra_cli/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..13a16f4 Binary files /dev/null and b/infra_cli/__pycache__/config.cpython-313.pyc differ diff --git a/infra_cli/__pycache__/dns.cpython-313.pyc b/infra_cli/__pycache__/dns.cpython-313.pyc new file mode 100644 index 0000000..04692ce Binary files /dev/null and b/infra_cli/__pycache__/dns.cpython-313.pyc differ diff --git a/infra_cli/__pycache__/ingress.cpython-313.pyc b/infra_cli/__pycache__/ingress.cpython-313.pyc new file mode 100644 index 0000000..5a812f9 Binary files /dev/null and b/infra_cli/__pycache__/ingress.cpython-313.pyc differ diff --git a/infra_cli/__pycache__/main.cpython-313.pyc b/infra_cli/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..c3c6df0 Binary files /dev/null and b/infra_cli/__pycache__/main.cpython-313.pyc differ diff --git a/infra_cli/__pycache__/router.cpython-313.pyc b/infra_cli/__pycache__/router.cpython-313.pyc new file mode 100644 index 0000000..777ba63 Binary files /dev/null and b/infra_cli/__pycache__/router.cpython-313.pyc differ diff --git a/infra_cli/__pycache__/ssh.cpython-313.pyc b/infra_cli/__pycache__/ssh.cpython-313.pyc new file mode 100644 index 0000000..f9ad609 Binary files /dev/null and b/infra_cli/__pycache__/ssh.cpython-313.pyc differ diff --git a/infra_cli/dns.py b/infra_cli/dns.py index 11d0a3d..d824ca2 100644 --- a/infra_cli/dns.py +++ b/infra_cli/dns.py @@ -21,7 +21,8 @@ class DNSManager: 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}\") + cmd = f"sh -c \"echo 'dhcp-host={mac},{hostname},{ip}' >> {self.hosts_file}\"" + self.exec_lxc(cmd) self.reload() def remove_host(self, mac): @@ -30,11 +31,13 @@ class DNSManager: 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}\") + cmd = f"sh -c \"echo 'address=/{domain}/{ip}' >> {self.dns_file}\"" + self.exec_lxc(cmd) 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}\") + cmd = f"sh -c \"sed -i '\#address=/{domain}/#d' {self.dns_file}\"" + self.exec_lxc(cmd) self.reload() def reload(self): @@ -47,4 +50,4 @@ class DNSManager: 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} + return {"hosts": hosts, "dns": dns} \ No newline at end of file diff --git a/infra_cli/main.py b/infra_cli/main.py index 4dcbdb3..f71a9e1 100644 --- a/infra_cli/main.py +++ b/infra_cli/main.py @@ -63,11 +63,45 @@ def ingress_add(config, domain, ip, port, https): mgr.add(domain, ip, port, https) click.echo(f"Added ingress for {domain}") +@ingress.command(name='remove') +@click.argument('domain') +@click.pass_obj +def ingress_remove(config, domain): + mgr = IngressManager(config) + mgr.remove(domain) + click.echo(f"Removed ingress for {domain}") + +@ingress.command(name='list') +@click.pass_obj +def ingress_list(config): + mgr = IngressManager(config) + click.echo(mgr.list()) + @cli.group() def router(): """Manage Router Port Forwards""" pass +@router.command(name='add') +@click.argument('name') +@click.argument('proto') +@click.argument('ext_port') +@click.argument('int_ip') +@click.argument('int_port') +@click.pass_obj +def router_add(config, name, proto, ext_port, int_ip, int_port): + mgr = RouterManager(config) + mgr.add_forward(name, proto, ext_port, int_ip, int_port) + click.echo(f"Added port forward {name}") + +@router.command(name='remove') +@click.argument('section') +@click.pass_obj +def router_remove(config, section): + mgr = RouterManager(config) + mgr.remove_forward(section) + click.echo(f"Removed port forward {section}") + @router.command(name='list') @click.pass_obj def router_list(config): diff --git a/infra_cli/router.py b/infra_cli/router.py index 988f415..6488db8 100644 --- a/infra_cli/router.py +++ b/infra_cli/router.py @@ -38,16 +38,17 @@ class RouterManager: def list(self): # Get sections + # Use -n to prevent SSH from consuming stdin if we were in a loop (here we use split) 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() + name = self.client.run(f"uci get firewall.{section}.name").stdout.strip() + proto = self.client.run(f"uci get firewall.{section}.proto").stdout.strip() + port = self.client.run(f"uci get firewall.{section}.src_dport").stdout.strip() + dest = self.client.run(f"uci get firewall.{section}.dest_ip").stdout.strip() results.append({ "section": section, "name": name, @@ -55,4 +56,4 @@ class RouterManager: "port": port, "dest": dest }) - return results + return results \ No newline at end of file diff --git a/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..98a72fe Binary files /dev/null and b/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/test_cli.py b/tests/test_cli.py index 2319748..d4d1faa 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,3 +43,22 @@ def test_ingress_cli(unique_id): # Remove res = run_infra(["ingress", "remove", domain]) assert res.returncode == 0 + +def test_router_cli(unique_id): + name = f"Test-Cli-{unique_id}" + section = name.lower().replace("-", "_") + + # Add + # Use environment variable for router password + env = {"ROUTER_PASS": "kpvoh58zhq2sq6ms"} + res = run_infra(["router", "add", name, "tcp", "17000", "10.32.70.212", "17000"], env=env) + assert res.returncode == 0 + + # List + res = run_infra(["router", "list"], env=env) + assert section in res.stdout + + # Remove + res = run_infra(["router", "remove", section], env=env) + assert res.returncode == 0 +