2026-02-05 11:29:34 +01:00
|
|
|
import click
|
|
|
|
|
from .config import Config
|
|
|
|
|
from .dns import DNSManager
|
|
|
|
|
from .ingress import IngressManager
|
|
|
|
|
from .router import RouterManager
|
2026-02-05 17:13:40 +01:00
|
|
|
from .proxmox import ProxmoxManager
|
|
|
|
|
from .samba import SambaManager
|
2026-02-05 19:15:50 +01:00
|
|
|
from .cloudflare import CloudflareManager
|
2026-02-05 19:48:16 +01:00
|
|
|
from .database import DatabaseManager
|
2026-02-05 11:29:34 +01:00
|
|
|
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)
|
|
|
|
|
|
2026-02-05 19:48:16 +01:00
|
|
|
@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}")
|
|
|
|
|
|
2026-02-05 19:15:50 +01:00
|
|
|
@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)
|
|
|
|
|
|
2026-02-05 19:17:54 +01:00
|
|
|
@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")
|
|
|
|
|
|
2026-02-05 19:15:50 +01:00
|
|
|
@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))
|
|
|
|
|
|
2026-02-05 17:13:40 +01:00
|
|
|
@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}")
|
|
|
|
|
|
2026-02-05 11:29:34 +01:00
|
|
|
@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}")
|
|
|
|
|
|
2026-02-05 19:06:07 +01:00
|
|
|
@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}")
|
|
|
|
|
|
2026-02-05 11:29:34 +01:00
|
|
|
@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'])
|
|
|
|
|
|
2026-02-05 19:24:04 +01:00
|
|
|
@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)
|
|
|
|
|
|
2026-02-05 11:29:34 +01:00
|
|
|
@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}")
|
|
|
|
|
|
2026-02-05 11:37:29 +01:00
|
|
|
@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())
|
|
|
|
|
|
2026-02-05 11:29:34 +01:00
|
|
|
@cli.group()
|
|
|
|
|
def router():
|
|
|
|
|
"""Manage Router Port Forwards"""
|
|
|
|
|
pass
|
|
|
|
|
|
2026-02-05 11:37:29 +01:00
|
|
|
@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):
|
2026-02-05 19:06:07 +01:00
|
|
|
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)")
|
|
|
|
|
|
2026-02-05 11:37:29 +01:00
|
|
|
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)
|
2026-02-05 19:06:07 +01:00
|
|
|
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)
|
2026-02-05 11:37:29 +01:00
|
|
|
|
2026-02-05 11:29:34 +01:00
|
|
|
@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()
|