import click from .config import Config from .dns import DNSManager from .ingress import IngressManager from .router import RouterManager from .proxmox import ProxmoxManager from .samba import SambaManager from .cloudflare import CloudflareManager from .database import DatabaseManager from .cert import CertificateManager import sys import os @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.command() @click.option('--domain', help='Public domain to remove from HAProxy and DNS') @click.option('--mac', help='MAC address to remove from DHCP') @click.option('--vmid', type=int, help='LXC VMID to destroy') @click.option('--node', help='Proxmox node for the VMID') @click.option('--port-name', help='Router port forward section name to delete') @click.pass_obj def decommission(config, domain, mac, vmid, node, port_name): """Orchestrated removal of a service from all infrastructure layers""" if domain: click.echo(f"Removing Ingress/DNS for {domain}...") try: IngressManager(config).remove(domain) DNSManager(config).remove_dns(domain) except Exception as e: click.echo(f"Warning: Ingress/DNS cleanup failed: {e}") if mac: click.echo(f"Removing DHCP reservation for {mac}...") try: DNSManager(config).remove_host(mac) except Exception as e: click.echo(f"Warning: DHCP cleanup failed: {e}") if port_name: click.echo(f"Removing Router Port Forward {port_name}...") try: RouterManager(config).remove_forward(port_name) except Exception as e: click.echo(f"Warning: Router cleanup failed: {e}") if vmid: click.echo(f"Destroying LXC {vmid} on {node or 'default node'}...") try: ProxmoxManager(config, node).delete_lxc(vmid) except Exception as e: click.echo(f"Warning: Proxmox cleanup failed: {e}") click.echo("Decommission process complete.") @cli.group() def db(): """Manage PostgreSQL Databases and Users""" pass @db.command(name='list-dbs') @click.pass_obj def db_list_dbs(config): mgr = DatabaseManager(config) click.echo(mgr.list_databases()) @db.command(name='list-users') @click.pass_obj def db_list_users(config): mgr = DatabaseManager(config) click.echo(mgr.list_users()) @db.command(name='provision') @click.argument('project_name') @click.option('--password', help='Database user password') @click.pass_obj def db_provision(config, project_name, password): """Create a database and user for a project""" import secrets import string db_name = project_name.lower().replace("-", "_") username = f"{db_name}_user" pwd = password or ''.join(secrets.choice(string.ascii_letters + string.digits) for i in range(16)) mgr = DatabaseManager(config) click.echo(f"Creating user {username}...") mgr.create_user(username, pwd) click.echo(f"Creating database {db_name} owned by {username}...") mgr.create_database(db_name, owner=username) click.echo("\nProvisioning Complete:") click.echo(f" DB Name: {db_name}") click.echo(f" Username: {username}") click.echo(f" Password: {pwd}") @cli.group() def cloudflare(): """Manage Cloudflare DNS and DDNS""" pass @cloudflare.command(name='list-ddns') @click.pass_obj def cf_list_ddns(config): mgr = CloudflareManager(config) for domain in mgr.list_domains(): click.echo(domain) @cloudflare.command(name='add-ddns') @click.argument('domain') @click.pass_obj def cf_add_ddns(config, domain): mgr = CloudflareManager(config) if mgr.add_domain(domain): click.echo(f"Added {domain} to DDNS update list") else: click.echo(f"{domain} already in DDNS update list") @cloudflare.command(name='remove-ddns') @click.argument('domain') @click.pass_obj def cf_remove_ddns(config, domain): mgr = CloudflareManager(config) if mgr.remove_domain(domain): click.echo(f"Removed {domain} from DDNS update list") else: click.echo(f"{domain} not found in DDNS update list") @cloudflare.command(name='update-ddns') @click.option('--force', is_flag=True, help='Force update even if IP matches') @click.pass_obj def cf_update_ddns(config, force): mgr = CloudflareManager(config) click.echo("Updating Cloudflare DDNS records...") click.echo(mgr.update_ddns(force)) @cli.group() def proxmox(): """Manage Proxmox VMs and Containers""" pass @proxmox.command(name='list-lxcs') @click.option('--node', help='Proxmox node name') @click.pass_obj def proxmox_list_lxcs(config, node): mgr = ProxmoxManager(config, node) click.echo(mgr.list_lxcs()) @proxmox.command(name='create-lxc') @click.argument('vmid') @click.argument('template') @click.argument('hostname') @click.argument('ip') @click.argument('gateway') @click.option('--node', help='Proxmox node name') @click.option('--password', help='Root password for LXC') @click.pass_obj def proxmox_create_lxc(config, vmid, template, hostname, ip, gateway, node, password): mgr = ProxmoxManager(config, node) mgr.create_lxc(vmid, template, hostname, ip, gateway, password=password) click.echo(f"LXC {vmid} ({hostname}) created on {mgr.node_name or 'default node'}") @cli.group() def samba(): """Manage Samba AD Identity""" pass @samba.command(name='list-users') @click.pass_obj def samba_list_users(config): mgr = SambaManager(config) click.echo(mgr.list_users()) @samba.command(name='add-user') @click.argument('username') @click.argument('password') @click.pass_obj def samba_add_user(config, username, password): mgr = SambaManager(config) mgr.add_user(username, password) click.echo(f"User {username} created") @samba.command(name='add-to-group') @click.argument('group') @click.argument('username') @click.pass_obj def samba_add_to_group(config, group, username): mgr = SambaManager(config) mgr.add_to_group(group, username) click.echo(f"User {username} added to group {group}") @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='add-dns') @click.argument('domain') @click.argument('ip') @click.pass_obj def dns_add_dns(config, domain, ip): mgr = DNSManager(config) mgr.add_dns(domain, ip) click.echo(f"Added DNS record for {domain} -> {ip}") @dns.command(name='remove-dns') @click.argument('domain') @click.pass_obj def dns_remove_dns(config, domain): mgr = DNSManager(config) mgr.remove_dns(domain) click.echo(f"Removed DNS record for {domain}") @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 ip(): """Manage and discover available IP addresses""" pass @ip.command(name='list-free') @click.option('--count', default=5, help='Number of free IPs to show') @click.pass_obj def ip_list_free(config, count): mgr = DNSManager(config) free = mgr.get_free_ips() for ip in free[:count]: click.echo(ip) @ip.command(name='next-free') @click.pass_obj def ip_next_free(config): mgr = DNSManager(config) free = mgr.get_free_ips() if free: click.echo(free[0]) else: click.echo("Error: No free IPs in agent pool!", err=True) sys.exit(1) @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}") @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): import ipaddress # Validate IP and Ports in CLI layer for better error messages try: ipaddress.ip_address(int_ip) except ValueError: raise click.BadParameter(f"Invalid internal IP address: {int_ip}") for p in [ext_port, int_port]: if not (1 <= int(p) <= 65535): raise click.BadParameter(f"Port {p} out of range (1-65535)") 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) try: mgr.remove_forward(section) click.echo(f"Removed port forward {section}") except ValueError as e: click.echo(f"Error: {e}", err=True) sys.exit(1) @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']}") @cli.group() def cert(): """Manage SSL/TLS Certificates""" pass @cert.command(name='list') @click.pass_obj def cert_list(config): mgr = CertificateManager(config) click.echo(mgr.list_certs()) @cert.command(name='status') @click.pass_obj def cert_status(config): mgr = CertificateManager(config) click.echo(f"Main Certificate Expiry: {mgr.check_expiry()}") @cert.command(name='renew') @click.option('--force', is_flag=True, help='Force full SAN discovery and renewal') @click.pass_obj def cert_renew(config, force): mgr = CertificateManager(config) click.echo("Running dynamic SAN manager...") click.echo(mgr.renew(force)) @cert.command(name='resolve') @click.argument('domain') @click.pass_obj def cert_resolve(config, domain): """Find the certificate file covering a specific domain""" mgr = CertificateManager(config) cert_file = mgr.resolve_cert_for_domain(domain) if cert_file: click.echo(cert_file) else: click.echo(f"Error: No certificate found covering {domain}", err=True) sys.exit(1) def main(): cli(obj={}) if __name__ == "__main__": main()