first commit

This commit is contained in:
Stefan Hacker
2026-02-02 09:46:35 +01:00
commit 6901dc369b
98 changed files with 13030 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
"""Configuration generator for mGuard routers."""
from dataclasses import dataclass
from typing import Optional
import json
@dataclass
class GatewayConfig:
"""Gateway configuration data."""
name: str
vpn_server: str
vpn_port: int
ca_cert: str
client_cert: str
client_key: str
ta_key: Optional[str] = None
class ConfigGenerator:
"""Generate configuration files for mGuard routers."""
@staticmethod
def generate_openvpn_config(config: GatewayConfig) -> str:
"""Generate OpenVPN client configuration.
Args:
config: Gateway configuration data
Returns:
OpenVPN config file content
"""
ovpn = f"""# OpenVPN Client Configuration
# Generated for: {config.name}
client
dev tun
proto udp
remote {config.vpn_server} {config.vpn_port}
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
cipher AES-256-GCM
auth SHA256
verb 3
<ca>
{config.ca_cert}
</ca>
<cert>
{config.client_cert}
</cert>
<key>
{config.client_key}
</key>
"""
if config.ta_key:
ovpn += f"""
<tls-auth>
{config.ta_key}
</tls-auth>
key-direction 1
"""
return ovpn
@staticmethod
def generate_atv_vpn_section(config: GatewayConfig) -> str:
"""Generate ATV configuration section for VPN.
Note: This is a simplified version. Real ATV files have
a more complex structure that should be merged with existing config.
Args:
config: Gateway configuration data
Returns:
ATV config section
"""
# ATV is essentially a key-value format
# This is a simplified representation
atv_section = f"""
[vpn_client_1]
enabled = 1
name = {config.name}
type = openvpn
remote = {config.vpn_server}
port = {config.vpn_port}
protocol = udp
cipher = AES-256-GCM
"""
return atv_section
@staticmethod
def generate_mguard_script(
gateway_name: str,
endpoints: list[dict]
) -> str:
"""Generate mGuard CLI script for firewall configuration.
Args:
gateway_name: Name of the gateway
endpoints: List of endpoint configurations
Returns:
Shell script for mGuard CLI
"""
script = f"""#!/bin/bash
# Firewall configuration script for {gateway_name}
# Run this on the mGuard via SSH
MBIN="/Packages/mguard-api_0/mbin"
echo "Configuring firewall rules for {gateway_name}..."
"""
for i, ep in enumerate(endpoints):
rule_name = f"endpoint_{i}_{ep['name'].replace(' ', '_')}"
script += f"""
# Rule for {ep['name']}
$MBIN/action fwrules/add \\
--name "{rule_name}" \\
--source "any" \\
--destination "{ep['internal_ip']}" \\
--port "{ep['port']}" \\
--protocol "{ep['protocol']}" \\
--action "accept"
"""
script += """
echo "Firewall rules configured."
$MBIN/action config/save
echo "Configuration saved."
"""
return script
def create_provisioning_package(
gateway_name: str,
vpn_server: str,
vpn_port: int,
ca_cert: str,
client_cert: str,
client_key: str,
endpoints: list[dict],
output_dir: str = "."
) -> dict:
"""Create a complete provisioning package.
Args:
gateway_name: Name of the gateway
vpn_server: VPN server address
vpn_port: VPN server port
ca_cert: CA certificate content
client_cert: Client certificate content
client_key: Client private key content
endpoints: List of endpoint configurations
output_dir: Output directory for files
Returns:
Dictionary with file paths
"""
from pathlib import Path
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
config = GatewayConfig(
name=gateway_name,
vpn_server=vpn_server,
vpn_port=vpn_port,
ca_cert=ca_cert,
client_cert=client_cert,
client_key=client_key
)
# Generate OpenVPN config
ovpn_content = ConfigGenerator.generate_openvpn_config(config)
ovpn_file = output_path / f"{gateway_name}.ovpn"
ovpn_file.write_text(ovpn_content)
# Generate firewall script
fw_script = ConfigGenerator.generate_mguard_script(gateway_name, endpoints)
fw_file = output_path / f"{gateway_name}_firewall.sh"
fw_file.write_text(fw_script)
# Generate info JSON
info = {
"gateway_name": gateway_name,
"vpn_server": vpn_server,
"vpn_port": vpn_port,
"endpoints": endpoints
}
info_file = output_path / f"{gateway_name}_info.json"
info_file.write_text(json.dumps(info, indent=2))
return {
"ovpn": str(ovpn_file),
"firewall_script": str(fw_file),
"info": str(info_file)
}
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""mGuard Provisioning Tool - CLI for gateway provisioning."""
import click
from rich.console import Console
from rich.table import Table
from mguard_api import MGuardAPIClient
from config_generator import ConfigGenerator
console = Console()
@click.group()
@click.option('--server', '-s', default='http://localhost:8000', help='API server URL')
@click.option('--username', '-u', prompt=True, help='Admin username')
@click.option('--password', '-p', prompt=True, hide_input=True, help='Admin password')
@click.pass_context
def cli(ctx, server, username, password):
"""mGuard Gateway Provisioning Tool.
Use this tool to provision and configure mGuard routers.
"""
ctx.ensure_object(dict)
# Login to API
import httpx
client = httpx.Client(timeout=30.0)
try:
response = client.post(
f"{server}/api/auth/login",
json={"username": username, "password": password}
)
response.raise_for_status()
tokens = response.json()
ctx.obj['server'] = server
ctx.obj['token'] = tokens['access_token']
ctx.obj['client'] = client
console.print("[green]Successfully authenticated[/green]")
except httpx.HTTPError as e:
console.print(f"[red]Authentication failed: {e}[/red]")
raise click.Abort()
@cli.command()
@click.pass_context
def list_gateways(ctx):
"""List all gateways."""
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
response = client.get(
f"{server}/api/gateways",
headers={"Authorization": f"Bearer {token}"}
)
gateways = response.json()
table = Table(title="Gateways")
table.add_column("ID", style="cyan")
table.add_column("Name", style="green")
table.add_column("Type")
table.add_column("Status")
table.add_column("Provisioned")
for gw in gateways:
status = "[green]Online[/green]" if gw['is_online'] else "[red]Offline[/red]"
prov = "[green]Yes[/green]" if gw['is_provisioned'] else "[yellow]No[/yellow]"
table.add_row(
str(gw['id']),
gw['name'],
gw['router_type'],
status,
prov
)
console.print(table)
@cli.command()
@click.argument('gateway_id', type=int)
@click.option('--output', '-o', default=None, help='Output file path')
@click.pass_context
def download_config(ctx, gateway_id, output):
"""Download provisioning config for a gateway."""
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
response = client.get(
f"{server}/api/gateways/{gateway_id}/provision",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code != 200:
console.print(f"[red]Error: {response.text}[/red]")
return
config = response.text
if output:
with open(output, 'w') as f:
f.write(config)
console.print(f"[green]Config saved to {output}[/green]")
else:
output_file = f"gateway-{gateway_id}.ovpn"
with open(output_file, 'w') as f:
f.write(config)
console.print(f"[green]Config saved to {output_file}[/green]")
@cli.command()
@click.argument('gateway_id', type=int)
@click.argument('router_ip')
@click.option('--router-user', '-u', default='admin', help='Router username')
@click.option('--router-pass', '-p', prompt=True, hide_input=True, help='Router password')
@click.pass_context
def provision_online(ctx, gateway_id, router_ip, router_user, router_pass):
"""Provision a gateway via network (REST API or SSH).
GATEWAY_ID: ID of the gateway in the server database
ROUTER_IP: IP address of the mGuard router
"""
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
console.print(f"[yellow]Connecting to router at {router_ip}...[/yellow]")
# Get gateway info
response = client.get(
f"{server}/api/gateways/{gateway_id}",
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code != 200:
console.print(f"[red]Error: Gateway not found[/red]")
return
gateway = response.json()
firmware = gateway.get('firmware_version', '')
# Determine provisioning method
if firmware and firmware.startswith('10.'):
console.print("[cyan]Using REST API provisioning (Firmware 10.x)[/cyan]")
_provision_rest_api(ctx, gateway, router_ip, router_user, router_pass)
else:
console.print("[cyan]Using SSH provisioning (Legacy firmware)[/cyan]")
_provision_ssh(ctx, gateway, router_ip, router_user, router_pass)
def _provision_rest_api(ctx, gateway, router_ip, router_user, router_pass):
"""Provision via mGuard REST API."""
mguard = MGuardAPIClient(router_ip, router_user, router_pass)
try:
# Test connection
if not mguard.test_connection():
console.print("[red]Cannot connect to router REST API[/red]")
return
# Download VPN config from server
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
response = client.get(
f"{server}/api/gateways/{gateway['id']}/provision",
headers={"Authorization": f"Bearer {token}"}
)
vpn_config = response.text
# Apply VPN configuration
console.print("[yellow]Applying VPN configuration...[/yellow]")
if mguard.configure_vpn(vpn_config):
console.print("[green]VPN configuration applied successfully![/green]")
else:
console.print("[red]Failed to apply VPN configuration[/red]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
def _provision_ssh(ctx, gateway, router_ip, router_user, router_pass):
"""Provision via SSH (legacy routers)."""
import paramiko
try:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(router_ip, username=router_user, password=router_pass, timeout=10)
console.print("[green]SSH connection established[/green]")
# Download VPN config from server
client = ctx.obj['client']
server = ctx.obj['server']
token = ctx.obj['token']
response = client.get(
f"{server}/api/gateways/{gateway['id']}/provision",
headers={"Authorization": f"Bearer {token}"}
)
vpn_config = response.text
# Upload config file
sftp = ssh.open_sftp()
with sftp.file('/tmp/vpn.ovpn', 'w') as f:
f.write(vpn_config)
console.print("[yellow]VPN config uploaded[/yellow]")
# Apply configuration (mGuard-specific commands)
stdin, stdout, stderr = ssh.exec_command(
'/Packages/mguard-api_0/mbin/action vpn/import /tmp/vpn.ovpn'
)
result = stdout.read().decode()
if 'error' in result.lower():
console.print(f"[red]Error: {result}[/red]")
else:
console.print("[green]VPN configuration applied![/green]")
ssh.close()
except paramiko.AuthenticationException:
console.print("[red]SSH authentication failed[/red]")
except paramiko.SSHException as e:
console.print(f"[red]SSH error: {e}[/red]")
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
@cli.command()
@click.argument('config_file')
@click.argument('router_ip')
@click.option('--router-user', '-u', default='admin', help='Router username')
@click.option('--router-pass', '-p', prompt=True, hide_input=True, help='Router password')
def provision_offline(config_file, router_ip, router_user, router_pass):
"""Provision a gateway using a downloaded config file.
CONFIG_FILE: Path to the .ovpn or .atv config file
ROUTER_IP: IP address of the mGuard router (must be on same network)
"""
import os
from pathlib import Path
config_path = Path(config_file)
if not config_path.exists():
console.print(f"[red]Config file not found: {config_file}[/red]")
return
console.print(f"[yellow]Loading config from {config_file}...[/yellow]")
config_content = config_path.read_text()
# Determine file type
if config_file.endswith('.ovpn'):
console.print("[cyan]OpenVPN config detected[/cyan]")
elif config_file.endswith('.atv'):
console.print("[cyan]mGuard ATV config detected[/cyan]")
else:
console.print("[yellow]Unknown config format, attempting generic upload[/yellow]")
# Try REST API first, then SSH
mguard = MGuardAPIClient(router_ip, router_user, router_pass)
if mguard.test_connection():
console.print("[cyan]Using REST API...[/cyan]")
if mguard.upload_config(config_content):
console.print("[green]Configuration uploaded successfully![/green]")
else:
console.print("[red]REST API upload failed[/red]")
else:
console.print("[cyan]REST API not available, trying SSH...[/cyan]")
import paramiko
try:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(router_ip, username=router_user, password=router_pass, timeout=10)
sftp = ssh.open_sftp()
remote_path = f'/tmp/{config_path.name}'
with sftp.file(remote_path, 'w') as f:
f.write(config_content)
console.print(f"[green]Config uploaded to {remote_path}[/green]")
# Apply based on file type
if config_file.endswith('.atv'):
stdin, stdout, stderr = ssh.exec_command(
f'/Packages/mguard-api_0/mbin/action config/restore {remote_path}'
)
else:
stdin, stdout, stderr = ssh.exec_command(
f'/Packages/mguard-api_0/mbin/action vpn/import {remote_path}'
)
result = stdout.read().decode()
console.print(f"Result: {result}")
ssh.close()
console.print("[green]Provisioning complete![/green]")
except Exception as e:
console.print(f"[red]SSH provisioning failed: {e}[/red]")
if __name__ == '__main__':
cli(obj={})
+175
View File
@@ -0,0 +1,175 @@
"""mGuard REST API client for router configuration."""
import httpx
from typing import Optional
import urllib3
# Disable SSL warnings for self-signed certificates
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class MGuardAPIClient:
"""Client for mGuard REST API (Firmware 10.x+)."""
def __init__(self, host: str, username: str, password: str, port: int = 443):
"""Initialize mGuard API client.
Args:
host: Router IP address or hostname
username: Admin username
password: Admin password
port: HTTPS port (default 443)
"""
self.base_url = f"https://{host}:{port}"
self.username = username
self.password = password
self.client = httpx.Client(
timeout=30.0,
verify=False, # mGuard uses self-signed certs
auth=(username, password)
)
def test_connection(self) -> bool:
"""Test connection to mGuard REST API."""
try:
response = self.client.get(f"{self.base_url}/api/v1/info")
return response.status_code == 200
except httpx.HTTPError:
return False
def get_system_info(self) -> Optional[dict]:
"""Get system information."""
try:
response = self.client.get(f"{self.base_url}/api/v1/info")
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
def get_configuration(self) -> Optional[dict]:
"""Get current configuration."""
try:
response = self.client.get(f"{self.base_url}/api/v1/configuration")
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
def configure_vpn(self, vpn_config: str) -> bool:
"""Configure OpenVPN client.
Args:
vpn_config: OpenVPN configuration content
Returns:
True if successful
"""
try:
# The actual API endpoint depends on mGuard firmware version
# This is an example - actual implementation may vary
response = self.client.post(
f"{self.base_url}/api/v1/vpn/openvpn/client",
json={
"enabled": True,
"config": vpn_config
}
)
return response.status_code in (200, 201, 204)
except httpx.HTTPError:
return False
def upload_config(self, config_content: str) -> bool:
"""Upload configuration file.
Args:
config_content: Configuration file content
Returns:
True if successful
"""
try:
response = self.client.post(
f"{self.base_url}/api/v1/configuration",
content=config_content,
headers={"Content-Type": "application/octet-stream"}
)
return response.status_code in (200, 201, 204)
except httpx.HTTPError:
return False
def add_firewall_rule(
self,
name: str,
source: str,
destination: str,
port: int,
protocol: str = "tcp",
action: str = "accept"
) -> bool:
"""Add a firewall rule.
Args:
name: Rule name
source: Source IP/network
destination: Destination IP/network
port: Destination port
protocol: tcp or udp
action: accept or drop
Returns:
True if successful
"""
try:
response = self.client.post(
f"{self.base_url}/api/v1/firewall/rules",
json={
"name": name,
"source": source,
"destination": destination,
"port": port,
"protocol": protocol,
"action": action,
"enabled": True
}
)
return response.status_code in (200, 201, 204)
except httpx.HTTPError:
return False
def remove_firewall_rule(self, rule_id: str) -> bool:
"""Remove a firewall rule.
Args:
rule_id: Rule ID to remove
Returns:
True if successful
"""
try:
response = self.client.delete(
f"{self.base_url}/api/v1/firewall/rules/{rule_id}"
)
return response.status_code in (200, 204)
except httpx.HTTPError:
return False
def reboot(self) -> bool:
"""Reboot the router."""
try:
response = self.client.post(f"{self.base_url}/api/v1/actions/reboot")
return response.status_code in (200, 202, 204)
except httpx.HTTPError:
return False
def get_vpn_status(self) -> Optional[dict]:
"""Get VPN connection status."""
try:
response = self.client.get(f"{self.base_url}/api/v1/vpn/status")
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
def close(self):
"""Close the HTTP client."""
self.client.close()
+14
View File
@@ -0,0 +1,14 @@
# HTTP Client
httpx==0.26.0
requests==2.31.0
# SSH for legacy routers
paramiko==3.4.0
# CLI Interface
click==8.1.7
rich==13.7.0
# Configuration
python-dotenv==1.0.0
pyyaml==6.0.1