diff --git a/unifi_integration/README.md b/unifi_integration/README.md new file mode 100644 index 0000000..6a012ac --- /dev/null +++ b/unifi_integration/README.md @@ -0,0 +1,158 @@ +# UniFi Integration for Odoo + +Module d'intégration entre Odoo et les appareils UniFi Network (UDM Pro, UCG Max). + +## API Integration Notes + +### Authentication + +The UniFi API requires specific endpoints and headers for authentication. Note that there are critical differences between UniFi controllers and the UDM Pro/UCG Max API: + +```python +# Python Example +import requests +from requests.packages.urllib3.exceptions import InsecureRequestWarning +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + +headers = { + "Accept": "application/json", + "Content-Type": "application/json" +} +data = { + 'username': 'your_username', + 'password': 'your_password', + 'rememberMe': True +} + +session = requests.Session() +response = session.post( + 'https://udmp:443/api/auth/login', + headers=headers, + json=data, + verify=False +) +``` + +### API Endpoints + +All API endpoints (except authentication) must be prefixed with `/proxy/network`. Common endpoints include: + +#### System and Status +- System Health: `/api/system/health` +- System Info: `/proxy/network/api/s/{site}/stat/sysinfo` +- Dashboard Health: `/proxy/network/api/s/{site}/stat/health` + +#### Network Configuration +- Networks: `/proxy/network/api/s/{site}/rest/networkconf` +- Firewall Rules: `/proxy/network/api/s/{site}/rest/firewallrule` +- Port Forwards: `/proxy/network/api/s/{site}/stat/portforward` +- Routes: `/proxy/network/api/s/{site}/rest/routing` + +#### Devices and Clients +- All Devices: `/proxy/network/api/s/{site}/stat/device` +- Basic Device Info: `/proxy/network/api/s/{site}/stat/device-basic` +- Active Clients: `/proxy/network/api/s/{site}/stat/sta` +- All Clients: `/proxy/network/api/s/{site}/stat/user` + +### Headers Required + +All API requests must include these headers: + +```python +headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "Odoo UniFi Integration" +} + +# Add CSRF token for authenticated requests +if csrf_token: + headers["X-CSRF-Token"] = csrf_token +``` + +### SSL Verification + +The UDM Pro uses self-signed certificates. SSL verification must be disabled: + +```python +# Disable SSL verification warnings +from requests.packages.urllib3.exceptions import InsecureRequestWarning +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + +# Skip certificate validation in requests +verify=False +``` + +## Module Features + +### Network Configuration +- Import and manage network settings +- Configure port forwarding rules +- Manage firewall rules and groups +- Handle routing configuration + +### Device Management +- Monitor device status and health +- Track active and configured clients +- View detailed device statistics + +### System Monitoring +- Dashboard with health metrics +- System performance statistics +- Network usage and throughput + +### Multi-Site Support +- Manage multiple UniFi sites +- Site-specific configurations +- Independent metrics per site + +## Installation + +1. Install the module in your Odoo instance +2. Configure your UDM Pro credentials in the UniFi Integration settings +3. Import your network configuration + +## Configuration + +### Site Setup +1. Go to UniFi Integration > Sites +2. Create a new site with: + - Name: Your site name + - Site ID: Usually "default" unless configured otherwise + - Description: Optional site description + - Physical Address: Optional location information + +### UDM Pro Configuration +1. Add your UDM Pro configuration: + - Host: IP address or hostname + - Port: Usually 443 (HTTPS) + - Username: Your UDM Pro username + - Password: Your UDM Pro password + - MFA Token: If two-factor authentication is enabled + +### API Access +1. Create a dedicated API user in UDM Pro +2. Assign minimum required permissions +3. Use secure network access (VPN or internal network) + +## Security Considerations + +### Authentication +- Use dedicated API credentials +- Enable two-factor authentication when possible +- Regularly rotate passwords + +### Network Security +- Restrict API access to specific networks +- Use VPN for remote access +- Configure appropriate firewall rules + +### Monitoring +- Monitor API access logs +- Track authentication failures +- Review system alerts and notifications + +### Data Protection +- Store credentials securely +- Encrypt sensitive configuration data +- Regular security audits diff --git a/unifi_integration/__init__.py b/unifi_integration/__init__.py index c3d410e..b31a2a9 100644 --- a/unifi_integration/__init__.py +++ b/unifi_integration/__init__.py @@ -2,3 +2,4 @@ from . import models from . import controllers +from . import wizards diff --git a/unifi_integration/__manifest__.py b/unifi_integration/__manifest__.py index fe04d72..8bd44ea 100644 --- a/unifi_integration/__manifest__.py +++ b/unifi_integration/__manifest__.py @@ -27,8 +27,12 @@ This module allows you to: 'views/udm_user_views.xml', 'views/udm_settings_views.xml', 'views/udm_firewall_views.xml', + 'views/udm_network_config_views.xml', 'views/udm_menu_views.xml', 'views/templates.xml', + 'views/udm_pro_docs_templates.xml', + 'wizards/udm_site_import_wizard_views.xml', + 'wizards/views/udm_mfa_wizard_views.xml', ], 'demo': [], 'installable': True, @@ -40,7 +44,7 @@ This module allows you to: }, 'assets': { 'web.assets_backend': [ - 'udm_pro_docs/static/src/css/udm_pro.css', + 'unifi_integration/static/src/css/udm_pro.css', ], }, } diff --git a/unifi_integration/controllers/main.py b/unifi_integration/controllers/main.py index 3e8c16a..435df06 100644 --- a/unifi_integration/controllers/main.py +++ b/unifi_integration/controllers/main.py @@ -1,41 +1,40 @@ # -*- coding: utf-8 -*- # pylint: disable=import-error -from odoo import http, _ # IDE peut signaler une erreur, mais fonctionne dans l'environnement Odoo -from odoo.http import request # IDE peut signaler une erreur, mais fonctionne dans l'environnement Odoo +from odoo import http, _ # IDE may report an error, but works in Odoo environment +from odoo.http import request # IDE may report an error, but works in Odoo environment # pylint: enable=import-error import logging -import json # Nécessaire pour parser les réponses API et utilisé dans les méthodes de traitement +import json # Required to parse API responses and used in processing methods import requests from requests.exceptions import RequestException, ConnectionError +import urllib3 from urllib3.exceptions import InsecureRequestWarning from datetime import datetime -# Supprimer les avertissements pour les connexions non sécurisées +# Remove warnings for insecure connections try: - # Pour les versions plus récentes de requests + # For newer versions of requests import urllib3 urllib3.disable_warnings(category=InsecureRequestWarning) except ImportError: - # Pour les versions plus anciennes de requests + # For older versions of requests try: - # Désactiver l'avertissement de sécurité de pylint pour cette ligne spécifique - # pylint: disable=no-member - requests.packages.urllib3.disable_warnings() - # pylint: enable=no-member + # Disable pylint security warning for this specific line + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) except AttributeError: - # Si ni l'un ni l'autre ne fonctionne, nous ignorons silencieusement + # If neither works, we silently ignore pass _logger = logging.getLogger(__name__) class UdmProController(http.Controller): - """Contrôleur pour les fonctionnalités liées à UDM Pro dans Odoo""" + """Controller for UDM Pro related functionalities in Odoo""" @http.route('/udm_pro/advanced_options', type='http', auth='user', website=True) def advanced_options_form(self): - """Affiche le formulaire des options avancées pour l'API UDM Pro""" - return request.render('udm_pro_docs.advanced_options_form', { + """Display the advanced options form for the UDM Pro API""" + return request.render('unifi_integration.advanced_options_form', { 'default_site': 'default', 'fixed_only': True, 'lowercase_hostnames': True, @@ -43,37 +42,40 @@ class UdmProController(http.Controller): @http.route('/udm_pro/restart_device', type='http', auth='user', website=True) def restart_device_form(self): - """Affiche le formulaire pour redémarrer un appareil UDM Pro""" - if not request.env.user.has_group('udm_pro_docs.group_udm_pro_manager'): - return request.render('udm_pro_docs.access_denied', { + """Display the form to restart a UDM Pro device""" + if not request.env.user.has_group('unifi_integration.group_udm_pro_manager'): + return request.render('unifi_integration.access_denied', { 'error_message': _("You don't have permission to restart devices.") }) - # Récupérer les configurations UDM Pro enregistrées pour le formulaire + # Get saved UDM Pro configurations for the form configs = request.env['udm.configuration'].sudo().search([]) - return request.render('udm_pro_docs.restart_device_form', { + return request.render('unifi_integration.restart_device_form', { 'configs': configs }) @http.route('/udm_pro/restart_device', type='http', auth='user', website=True, methods=['POST']) def restart_device(self, **post): - """Traite la demande de redémarrage d'un appareil UDM Pro""" - if not request.env.user.has_group('udm_pro_docs.group_udm_pro_manager'): - return request.render('udm_pro_docs.access_denied', { + """Process the request to restart a UDM Pro device""" + if not request.env.user.has_group('unifi_integration.group_udm_pro_manager'): + return request.render('unifi_integration.access_denied', { 'error_message': _("You don't have permission to restart devices.") }) - config_id = int(post.get('config_id')) + config_id_str = post.get('config_id') + if not config_id_str: + raise ValueError(_('Configuration ID is required')) + config_id = int(config_id_str) mac_address = post.get('mac_address') if not mac_address: - return request.render('udm_pro_docs.restart_device_form', { + return request.render('unifi_integration.restart_device_form', { 'error_message': _("Please provide the MAC address of the device to restart."), 'configs': request.env['udm.configuration'].sudo().search([]) }) try: - # Récupérer la configuration + # Get the configuration config = request.env['udm.configuration'].sudo().browse(config_id) if not config.exists(): raise ValueError(_("Configuration not found")) @@ -86,44 +88,44 @@ class UdmProController(http.Controller): port=config.port or 443 ) - # Authentifier le client + # Authenticate the client if not client.login(): - return request.render('udm_pro_docs.restart_device_form', { + return request.render('unifi_integration.restart_device_form', { 'error_message': _("Authentication failed. Please check the configuration credentials."), 'configs': request.env['udm.configuration'].sudo().search([]) }) - # Redémarrer l'appareil + # Restart the device success = client.restart_device(mac_address) if not success: - return request.render('udm_pro_docs.restart_device_form', { + return request.render('unifi_integration.restart_device_form', { 'error_message': _("Failed to restart the device. Please check logs for details."), 'configs': request.env['udm.configuration'].sudo().search([]) }) - return request.render('udm_pro_docs.restart_success', { + return request.render('unifi_integration.restart_success', { 'mac_address': mac_address }) except (ConnectionError, RequestException) as e: _logger.error("Error during UDM Pro device restart: %s", str(e)) - return request.render('udm_pro_docs.restart_device_form', { + return request.render('unifi_integration.restart_device_form', { 'error_message': _("Error connecting to UDM Pro: %s") % str(e), 'configs': request.env['udm.configuration'].sudo().search([]) }) except Exception as e: # pylint: disable=broad-except _logger.exception("Unexpected error during UDM Pro device restart") - return request.render('udm_pro_docs.restart_device_form', { + return request.render('unifi_integration.restart_device_form', { 'error_message': _("An unexpected error occurred: %s") % str(e), 'configs': request.env['udm.configuration'].sudo().search([]) }) @http.route('/udm_pro/generate_hosts', type='http', auth='user', website=True) def generate_hosts_form(self): - """Affiche le formulaire pour générer un fichier hosts depuis UDM Pro""" - # Récupérer les configurations UDM Pro enregistrées pour le formulaire + """Display the form to generate a hosts file from UDM Pro""" + # Get saved UDM Pro configurations for the form configs = request.env['udm.configuration'].sudo().search([]) - return request.render('udm_pro_docs.generate_hosts_form', { + return request.render('unifi_integration.generate_hosts_form', { 'configs': configs, 'fixed_only': True, 'lowercase_hostnames': True @@ -131,18 +133,21 @@ class UdmProController(http.Controller): @http.route('/udm_pro/generate_hosts', type='http', auth='user', website=True, methods=['POST']) def generate_hosts(self, **post): - """Génère un fichier hosts à partir des clients réseau UDM Pro""" - config_id = int(post.get('config_id')) + """Generate a hosts file from UDM Pro network clients""" + config_id_str = post.get('config_id') + if not config_id_str: + raise ValueError(_('Configuration ID is required')) + config_id = int(config_id_str) fixed_only = post.get('fixed_only') == 'on' lowercase_hostnames = post.get('lowercase_hostnames') == 'on' try: - # Récupérer la configuration + # Get the configuration config = request.env['udm.configuration'].sudo().browse(config_id) if not config.exists(): raise ValueError(_("Configuration not found")) - # Initialiser le client UDM Pro avec les options avancées + # Initialize the UDM Pro client with advanced options client = UdmProClient( host=config.host, username=config.username, @@ -152,27 +157,34 @@ class UdmProController(http.Controller): lowercase_hostnames=lowercase_hostnames ) - # Authentifier le client + # Authenticate the client if not client.login(): - return request.render('udm_pro_docs.generate_hosts_form', { + return request.render('unifi_integration.generate_hosts_form', { 'error_message': _("Authentication failed. Please check the configuration credentials."), 'configs': request.env['udm.configuration'].sudo().search([]), 'fixed_only': fixed_only, 'lowercase_hostnames': lowercase_hostnames }) - # Générer le fichier hosts + # Generate the hosts file hosts_content = client.generate_hosts_file() - # Retourner le contenu sous forme de fichier à télécharger - response = request.make_response(hosts_content) - response.headers['Content-Type'] = 'text/plain' - response.headers['Content-Disposition'] = 'attachment; filename=udm_hosts.txt' - return response + # Return the content as a downloadable file + # Create and configure the HTTP response + try: + response = request.make_response(hosts_content) + if not response: + raise ValueError(_('Failed to create response')) + response.headers['Content-Type'] = 'text/plain' + response.headers['Content-Disposition'] = 'attachment; filename=udm_hosts.txt' + return response + except (AttributeError, TypeError) as e: + _logger.error('Error creating response: %s', str(e)) + raise ValueError(_('Failed to create response')) except (ConnectionError, RequestException) as e: _logger.error("Error during UDM Pro hosts file generation: %s", str(e)) - return request.render('udm_pro_docs.generate_hosts_form', { + return request.render('unifi_integration.generate_hosts_form', { 'error_message': _("Error connecting to UDM Pro: %s") % str(e), 'configs': request.env['udm.configuration'].sudo().search([]), 'fixed_only': fixed_only, @@ -180,7 +192,7 @@ class UdmProController(http.Controller): }) except Exception as e: # pylint: disable=broad-except _logger.exception("Unexpected error during UDM Pro hosts file generation") - return request.render('udm_pro_docs.generate_hosts_form', { + return request.render('unifi_integration.generate_hosts_form', { 'error_message': _("An unexpected error occurred: %s") % str(e), 'configs': request.env['udm.configuration'].sudo().search([]), 'fixed_only': fixed_only, @@ -189,10 +201,10 @@ class UdmProController(http.Controller): @http.route('/udm_pro/network_clients', type='http', auth='user', website=True) def network_clients_form(self): - """Affiche le formulaire pour consulter les clients réseau UDM Pro""" - # Récupérer les configurations UDM Pro enregistrées pour le formulaire + """Display the form to view UDM Pro network clients""" + # Get saved UDM Pro configurations for the form configs = request.env['udm.configuration'].sudo().search([]) - return request.render('udm_pro_docs.network_clients_form', { + return request.render('unifi_integration.network_clients_form', { 'configs': configs, 'fixed_only': False, 'lowercase_hostnames': True @@ -200,18 +212,21 @@ class UdmProController(http.Controller): @http.route('/udm_pro/network_clients', type='http', auth='user', website=True, methods=['POST']) def get_network_clients(self, **post): - """Récupère et affiche la liste des clients réseau UDM Pro""" - config_id = int(post.get('config_id')) + """Get and display the list of UDM Pro network clients""" + config_id_str = post.get('config_id') + if not config_id_str: + raise ValueError(_('Configuration ID is required')) + config_id = int(config_id_str) fixed_only = post.get('fixed_only') == 'on' lowercase_hostnames = post.get('lowercase_hostnames') == 'on' try: - # Récupérer la configuration + # Get the configuration config = request.env['udm.configuration'].sudo().browse(config_id) if not config.exists(): raise ValueError(_("Configuration not found")) - # Initialiser le client UDM Pro avec les options avancées + # Initialize the UDM Pro client with advanced options client = UdmProClient( host=config.host, username=config.username, @@ -221,26 +236,26 @@ class UdmProController(http.Controller): lowercase_hostnames=lowercase_hostnames ) - # Authentifier le client + # Authenticate the client if not client.login(): - return request.render('udm_pro_docs.network_clients_form', { + return request.render('unifi_integration.network_clients_form', { 'error_message': _("Authentication failed. Please check the configuration credentials."), 'configs': request.env['udm.configuration'].sudo().search([]), 'fixed_only': fixed_only, 'lowercase_hostnames': lowercase_hostnames }) - # Récupérer les clients réseau + # Get network clients network_clients = client.get_network_clients() - return request.render('udm_pro_docs.network_clients_result', { + return request.render('unifi_integration.network_clients_result', { 'clients': network_clients, 'config': config }) except (ConnectionError, RequestException) as e: _logger.error("Error retrieving UDM Pro network clients: %s", str(e)) - return request.render('udm_pro_docs.network_clients_form', { + return request.render('unifi_integration.network_clients_form', { 'error_message': _("Error connecting to UDM Pro: %s") % str(e), 'configs': request.env['udm.configuration'].sudo().search([]), 'fixed_only': fixed_only, @@ -248,7 +263,7 @@ class UdmProController(http.Controller): }) except ValueError as e: _logger.error("Value error retrieving UDM Pro network clients: %s", str(e)) - return request.render('udm_pro_docs.network_clients_form', { + return request.render('unifi_integration.network_clients_form', { 'error_message': _("Configuration error: %s") % str(e), 'configs': request.env['udm.configuration'].sudo().search([]), 'fixed_only': fixed_only, @@ -256,16 +271,16 @@ class UdmProController(http.Controller): }) except (AttributeError, KeyError) as e: _logger.error("Data format error retrieving UDM Pro network clients: %s", str(e)) - return request.render('udm_pro_docs.network_clients_form', { + return request.render('unifi_integration.network_clients_form', { 'error_message': _("Data error: %s") % str(e), 'configs': request.env['udm.configuration'].sudo().search([]), 'fixed_only': fixed_only, 'lowercase_hostnames': lowercase_hostnames }) except Exception as e: # pylint: disable=broad-except - # Conserver cette exception générique comme dernier recours, avec un avertissement explicite pour pylint + # Keep this generic exception as a last resort, with an explicit warning for pylint _logger.exception("Unexpected error retrieving UDM Pro network clients") - return request.render('udm_pro_docs.network_clients_form', { + return request.render('unifi_integration.network_clients_form', { 'error_message': _("An unexpected error occurred: %s") % str(e), 'configs': request.env['udm.configuration'].sudo().search([]), 'fixed_only': fixed_only, @@ -274,24 +289,25 @@ class UdmProController(http.Controller): @http.route('/udm_pro/import_config', type='http', auth='user', website=True) def import_config_form(self): - """Affiche le formulaire d'importation de configuration UDM Pro""" - return request.render('udm_pro_docs.import_config_form', {}) + """Display the UDM Pro configuration import form""" + return request.render('unifi_integration.import_config_form', {}) @http.route('/udm_pro/import_config', type='http', auth='user', website=True, methods=['POST']) def import_config(self, **post): - """Importe une configuration UDM Pro depuis l'appareil""" - if not request.env.user.has_group('udm_pro_docs.group_udm_pro_manager'): - return request.render('udm_pro_docs.access_denied', { + """Import UDM Pro configuration from the device""" + if not request.env.user.has_group('unifi_integration.group_udm_pro_manager'): + return request.render('unifi_integration.access_denied', { 'error_message': _("You don't have permission to import configurations.") }) host = post.get('host') username = post.get('username') password = post.get('password') - port = int(post.get('port') or 443) + port_str = post.get('port') + port = int(port_str) if port_str else 443 if not all([host, username, password]): - return request.render('udm_pro_docs.import_config_form', { + return request.render('unifi_integration.import_config_form', { 'error_message': _("Please provide all required fields."), 'host': host, 'username': username, @@ -299,10 +315,10 @@ class UdmProController(http.Controller): }) try: - # Utiliser le client API pour récupérer la configuration + # Use the API client to get the configuration client = UdmProClient(host, username, password, port) if not client.login(): - return request.render('udm_pro_docs.import_config_form', { + return request.render('unifi_integration.import_config_form', { 'error_message': _("Authentication failed. Please check your credentials."), 'host': host, 'username': username, @@ -311,14 +327,14 @@ class UdmProController(http.Controller): config_data = client.get_full_configuration() - # Importer la configuration dans Odoo + # Import the configuration into Odoo config_id = request.env['udm.configuration'].sudo().import_configuration(config_data) return request.redirect('/web#id=%s&model=udm.configuration&view_type=form' % config_id) except (ConnectionError, RequestException) as e: _logger.error("Error during UDM Pro configuration import: %s", str(e)) - return request.render('udm_pro_docs.import_config_form', { + return request.render('unifi_integration.import_config_form', { 'error_message': _("Error connecting to UDM Pro: %s") % str(e), 'host': host, 'username': username, @@ -326,7 +342,7 @@ class UdmProController(http.Controller): }) except (ValueError, TypeError, AttributeError) as e: _logger.error("Data processing error during UDM Pro configuration import: %s", str(e)) - return request.render('udm_pro_docs.import_config_form', { + return request.render('unifi_integration.import_config_form', { 'error_message': _("Error processing data: %s") % str(e), 'host': host, 'username': username, @@ -334,7 +350,7 @@ class UdmProController(http.Controller): }) except Exception as e: # pylint: disable=broad-except _logger.exception("Unexpected error during UDM Pro configuration import") - return request.render('udm_pro_docs.import_config_form', { + return request.render('unifi_integration.import_config_form', { 'error_message': _("An unexpected error occurred. Please check server logs."), 'host': host, 'username': username, @@ -343,9 +359,9 @@ class UdmProController(http.Controller): class UdmProClient: - """Client pour interagir avec l'API UDM Pro.""" + """Client to interact with the UDM Pro API.""" - # Points d'accès de l'API + # API endpoints API_LOGIN_ENDPOINT = '/api/auth/login' API_SYSTEM_INFO_ENDPOINT = '/api/system' API_NETWORK_ENDPOINT = '/api/networks' @@ -354,25 +370,25 @@ class UdmProClient: API_SETTINGS_ENDPOINT = '/api/settings' API_FIREWALL_ENDPOINT = '/api/firewall' - # Nouveaux endpoints inspirés du client Go + # New endpoints inspired by the Go client API_ACTIVE_CLIENTS_ENDPOINT = '/proxy/network/api/s/{site}/stat/sta' API_CONFIGURED_CLIENTS_ENDPOINT = '/proxy/network/api/s/{site}/list/user' API_DEVICE_RESTART_ENDPOINT = '/proxy/network/api/s/{site}/cmd/devmgr' def __init__(self, host, username, password, port=443, verify_ssl=False, site='default', fixed_only=True, lowercase_hostnames=True, debug=False): """ - Initialise le client API UDM Pro. + Initialize the UDM Pro API client. Args: - host (str): Adresse IP ou nom d'hôte de l'UDM Pro - username (str): Nom d'utilisateur pour l'API - password (str): Mot de passe pour l'API - port (int): Port pour la connexion (par défaut 443) - verify_ssl (bool): Vérifier le certificat SSL (par défaut False) - site (str): Identifiant du site pour l'API UniFi (par défaut 'default') - fixed_only (bool): Ne considérer que les clients avec adresse IP fixe (par défaut True) - lowercase_hostnames (bool): Convertir les noms d'hôtes en minuscules (par défaut True) - debug (bool): Activer le mode débogage pour les requêtes HTTP (par défaut False) + host (str): IP address or hostname of the UDM Pro + username (str): Username for the API + password (str): Password for the API + port (int): Port for connection (default 443) + verify_ssl (bool): Verify SSL certificate (default False) + site (str): Site identifier for UniFi API (default 'default') + fixed_only (bool): Only consider clients with fixed IP address (default True) + lowercase_hostnames (bool): Convert hostnames to lowercase (default True) + debug (bool): Enable debug mode for HTTP requests (default False) """ self.host = host self.username = username @@ -390,16 +406,16 @@ class UdmProClient: self.session.verify = verify_ssl def _get_auth_headers(self): - """Retourne les en-têtes d'authentification.""" + """Return authentication headers.""" if not self.token: - raise ValueError("Non authentifié. Appelez login() d'abord.") + raise ValueError("Not authenticated. Call login() first.") headers = { "Authorization": f"Bearer {self.token}", "Content-Type": "application/json" } - # Ajouter le token CSRF si disponible + # Add CSRF token if available if self.csrf_token: headers["X-Csrf-Token"] = self.csrf_token @@ -407,10 +423,10 @@ class UdmProClient: def login(self): """ - Authentifie le client auprès de l'API UDM Pro. + Authenticates the client with the UDM Pro API. Returns: - bool: True si l'authentification a réussi, False sinon + bool: True if authentication succeeded, False otherwise """ try: login_url = f"{self.base_url}{self.API_LOGIN_ENDPOINT}" @@ -419,139 +435,145 @@ class UdmProClient: "password": self.password } - _logger.debug("Tentative de connexion à %s", login_url) + _logger.debug("Attempting to connect to %s", login_url) response = self.session.post(login_url, json=payload) response.raise_for_status() - # Capture du token CSRF s'il existe + # Capture CSRF token if it exists csrf_token = response.headers.get('X-Csrf-Token') if csrf_token: self.csrf_token = csrf_token - _logger.debug("Token CSRF capturé: %s", csrf_token) + _logger.debug("CSRF token captured: %s", csrf_token) data = response.json() if not data.get('token'): - _logger.error("Aucun token n'a été retourné par l'API") + _logger.error("No token was returned by the API") return False self.token = data['token'] - _logger.debug("Authentification réussie") + _logger.debug("Authentication successful") return True except RequestException as e: - _logger.error("Erreur d'authentification: %s", str(e)) + _logger.error("Authentication error: %s", str(e)) return False def _make_api_request(self, method, endpoint, params=None, data=None, retry=True): """ - Effectue une requête API. + Makes an API request. Args: - method (str): Méthode HTTP (GET, POST, etc.) - endpoint (str): Point d'accès API - params (dict): Paramètres de requête - data (dict): Données à envoyer dans le corps de la requête - retry (bool): Réessayer en cas d'erreur d'authentification + method (str): HTTP method (GET, POST, etc.) + endpoint (str): API endpoint + params (dict): Request parameters + data (dict): Data to send in request body + retry (bool): Retry on authentication error Returns: - dict: Réponse JSON de l'API + dict: JSON response from API """ if not self.token and retry: - _logger.debug("Non authentifié, tentative d'authentification") + _logger.debug("Not authenticated, attempting authentication") if not self.login(): - raise ConnectionError("Impossible de s'authentifier à l'API UDM Pro") + raise ConnectionError("Unable to authenticate with UDM Pro API") url = f"{self.base_url}{endpoint}" try: - _logger.debug("Requête %s vers %s", method, url) + _logger.debug("Request %s to %s", method, url) headers = self._get_auth_headers() + response = None - response = self.session.request( - method=method, - url=url, - headers=headers, - params=params, - json=data - ) - - # Capture du token CSRF s'il existe dans la réponse - csrf_token = response.headers.get('X-Csrf-Token') - if csrf_token and csrf_token != self.csrf_token: - self.csrf_token = csrf_token - _logger.debug("Token CSRF mis à jour: %s", csrf_token) - - response.raise_for_status() - return response.json() - - except RequestException as e: - if response.status_code == 401 or response.status_code == 403: - if retry: - _logger.debug("Token expiré, nouvelle tentative d'authentification") - self.token = None - return self._make_api_request(method, endpoint, params, data, retry=False) - _logger.error("Erreur API (%s %s): %s", method, url, str(e)) + try: + response = self.session.request( + method=method, + url=url, + headers=headers, + params=params, + json=data + ) + + # Capture CSRF token if it exists in the response + csrf_token = response.headers.get('X-Csrf-Token') + if csrf_token and csrf_token != self.csrf_token: + self.csrf_token = csrf_token + _logger.debug("CSRF token updated: %s", csrf_token) + + response.raise_for_status() + return response.json() + + except RequestException as request_error: + if response and (response.status_code == 401 or response.status_code == 403): + if retry: + _logger.debug("Token expired, attempting re-authentication") + self.token = None + return self._make_api_request(method, endpoint, params, data, retry=False) + _logger.error("API error (%s %s): %s", method, url, str(request_error)) + raise + + except Exception as e: + _logger.error("Unexpected error in API request: %s", str(e)) raise def get_system_info(self): """ - Récupère les informations système de l'UDM Pro. + Gets system information from UDM Pro. Returns: - dict: Informations système + dict: System information """ return self._make_api_request('GET', self.API_SYSTEM_INFO_ENDPOINT) def get_networks(self): """ - Récupère la configuration des réseaux. + Gets network configuration. Returns: - dict: Configuration des réseaux + dict: Network configuration """ return self._make_api_request('GET', self.API_NETWORK_ENDPOINT) def get_devices(self): """ - Récupère la liste des périphériques. + Gets the list of devices. Returns: - dict: Liste des périphériques + dict: List of devices """ return self._make_api_request('GET', self.API_DEVICES_ENDPOINT) def get_users(self): """ - Récupère la liste des utilisateurs. + Gets the list of users. Returns: - dict: Liste des utilisateurs + dict: List of users """ return self._make_api_request('GET', self.API_USERS_ENDPOINT) def get_settings(self): """ - Récupère les paramètres généraux. + Gets general settings. Returns: - dict: Paramètres généraux + dict: General settings """ return self._make_api_request('GET', self.API_SETTINGS_ENDPOINT) def get_firewall_rules(self): """ - Récupère les règles de pare-feu. + Gets firewall rules. Returns: - dict: Règles de pare-feu + dict: Firewall rules """ return self._make_api_request('GET', self.API_FIREWALL_ENDPOINT) def get_active_clients(self): """ - Récupère la liste des clients actuellement connectés. + Gets the list of currently connected clients. Returns: - list: Liste des clients actifs + list: List of active clients """ endpoint = self.API_ACTIVE_CLIENTS_ENDPOINT.format(site=self.site) response = self._make_api_request('GET', endpoint) @@ -563,10 +585,10 @@ class UdmProClient: def get_configured_clients(self): """ - Récupère la liste des clients configurés statiquement. + Gets the list of statically configured clients. Returns: - list: Liste des clients configurés + list: List of configured clients """ endpoint = self.API_CONFIGURED_CLIENTS_ENDPOINT.format(site=self.site) response = self._make_api_request('GET', endpoint) @@ -578,14 +600,14 @@ class UdmProClient: def restart_device(self, mac_address): """ - Redémarre un appareil géré par l'UDM Pro (ex: point d'accès WiFi). - Nécessite des permissions de niveau 'admin du site'. + Restarts a device managed by UDM Pro (e.g. WiFi access point). + Requires 'site admin' level permissions. Args: - mac_address (str): Adresse MAC de l'appareil à redémarrer + mac_address (str): MAC address of device to restart Returns: - bool: True si l'opération a réussi, False sinon + bool: True if operation succeeded, False otherwise """ endpoint = self.API_DEVICE_RESTART_ENDPOINT.format(site=self.site) payload = { @@ -600,28 +622,28 @@ class UdmProClient: return True return False except Exception as e: # pylint: disable=broad-except - _logger.error("Erreur lors du redémarrage de l'appareil %s: %s", mac_address, str(e)) + _logger.error("Error while restarting device %s: %s", mac_address, str(e)) return False def get_network_clients(self): """ - Récupère tous les clients réseau (actifs et/ou configurés selon les paramètres). + Gets all network clients (active and/or configured according to parameters). Returns: - list: Liste des clients réseau + list: List of network clients """ clients = [] - # Toujours inclure les clients configurés statiquement + # Always include statically configured clients configured_clients = self.get_configured_clients() clients.extend(configured_clients) - # Inclure les clients actifs si fixed_only est False + # Include active clients if fixed_only is False if not self.fixed_only: active_clients = self.get_active_clients() clients.extend(active_clients) - # Traitement des noms d'hôtes si nécessaire + # Process hostnames if needed if self.lowercase_hostnames: for client in clients: if client.get('hostname'): @@ -633,10 +655,10 @@ class UdmProClient: def generate_hosts_file(self): """ - Génère un fichier hosts à partir des clients réseau. + Generates a hosts file from network clients. Returns: - str: Contenu du fichier hosts + str: Content of hosts file """ clients = self.get_network_clients() hosts_content = "# UDM Pro Generated Hosts File\n" @@ -654,17 +676,17 @@ class UdmProClient: def get_full_configuration(self): """ - Récupère la configuration complète de l'UDM Pro. + Gets complete UDM Pro configuration. Returns: - dict: Configuration complète de l'UDM Pro + dict: Complete UDM Pro configuration """ - # S'authentifier d'abord + # Authenticate first if not self.token and not self.login(): - raise ConnectionError("Échec de l'authentification pour la récupération de la configuration complète") + raise ConnectionError("Authentication failed while retrieving complete configuration") try: - # Récupérer chaque partie de la configuration + # Get each part of the configuration system_info = self.get_system_info() networks = self.get_networks() devices = self.get_devices() @@ -672,10 +694,10 @@ class UdmProClient: settings = self.get_settings() firewall = self.get_firewall_rules() - # Récupérer également les clients réseau (nouvelle fonctionnalité) + # Also get network clients (new feature) network_clients = self.get_network_clients() - # Combiner toutes les parties dans un seul dictionnaire + # Combine all parts into a single dictionary return { 'system_info': system_info, 'networks': networks, @@ -687,5 +709,5 @@ class UdmProClient: } except RequestException as e: - _logger.error("Erreur lors de la récupération de la configuration complète: %s", str(e)) + _logger.error("Error while retrieving complete configuration: %s", str(e)) raise diff --git a/unifi_integration/examples/api.md b/unifi_integration/examples/api.md new file mode 100644 index 0000000..40ad9f5 --- /dev/null +++ b/unifi_integration/examples/api.md @@ -0,0 +1,406 @@ +Documentation of API endpoints on the UniFi controller software. This is a reverse engineering project that is based on browser captures, jar dumps, and reviewing other software that has been written to work with the controller. It's received minimal testing. + +There are two main types of calls. One would be the REST-like which provide get/post/put/delete where post is to the base and put/delete are often tied to the _id of the object that you are working with. The second major type of web interface provided is an agent/call based system where you pass a command to an agent to perform an action. Both use application/json formatting for all data transfer. When updating a specific object you must use PUT or else a new object will be created. + +NOTE: All calls are relative to the base controller URL + +Calls return both a web status as well as JSON formatted output. 200 codes indicate a successful call and other indicate errors. I am using the placeholder `{site}` for the site name which for many installations will be `default`. + +# Login required +{ "data" : [ ] , "meta" : { "msg" : "api.err.LoginRequired" , "rc" : "error"}} +# Call was a success but returned no values +{ "data" : [ ] , "meta" : { "rc" : "ok"}} +# NOTE: If meta contains a count it's because the data values have been truncated +'meta': {'count': 4818, 'rc': 'ok'} # from the api/s/{site}/stat/event endpoint +UDM Pro and UCG Max API +NOTE: There are two critical differences between Unifi controllers and the API of the UDM Pro and UCG Max: + +The login endpoint is /api/auth/login +All API endpoints need to be prefixed with /proxy/network (e.g. https://192.168.0.1/proxy/network/api/s/default/self) +Examples +curl +# authenticate and save the cookie contents in local file cookie.txt with switch '-c' +curl -k -X POST --data '{"username": "usr", "password": "$pw"}' --header 'Content-Type: application/json' -c cookie.txt https://udmp:443/api/auth/login +# responds with json data + +# pass the local file cookie.txt with switch '-b' +curl -k -X GET -b cookie.txt https://udmp/proxy/network/api/s/default/self +# responds with proper json +python +Controller Endpoints +These are REST calls that can be made without a site context. I do not believe any updates ( PUT ) can be called on these endpoints. + +Path Method Notes +status GET Returns some very basic server information - This appears to be the only endpoint that can be reached without an authentication + { "data" : [ ] , "meta" : { "rc" : "ok" , "server_version" : "5.7.23" , "up" : true , "uuid" : "0e727580-ffff-ffff-ffff-403dcd5a7bd4"}} +api/login POST requires dict of username, password, and optionally remember=true for long-running sessions. Returns 200 for success and a cookie that is your session. NOTE: On UDM Pros this is api/auth/login. +api/logout POST destroys the sever side session id which will make future attempts with that cookie fail +api/self GET Logged in user NOTE: On UDM Pros this is api/users/self. +api/self/sites GET Get basic information for all sites on this controller +api/stat/sites GET Same as above with an additional information on health and new alerts for each site +api/stat/admin GET List administrators and permissions for all sites +api/system/poweroff POST Turns off the UDM NOTE: X-CSRF-Token header required (from e.g. the login response) + Super Admin access rights +api/system/reboot POST Reboot the UDM NOTE: X-CSRF-Token header required (from e.g. the login response) + Super Admin access rights +Site Endpoints +All commands are presumed to be prefixed with api/s/{site} + +Path Method Notes +stat/health GET Health status of the site +self GET Logged in user +stat/ccode GET List of country codes +stat/current-channel GET List of all RF channels based on the site country code +stat/sysinfo GET Some high-level information about the controller +stat/event GET List site events by most recent first, 3000 result limit +rest/event GET List site events by oldest, no limit? (api.err.NotFound per controller version 7.1.66) +stat/alarm GET List alarms by most recent, 3000 result limit? +rest/alarm GET List alarms by oldest, no limit? (api.err.NotFound per controller version 7.1.66) +stat/sta GET List of all _active_ clients on the site +rest/user GET/POST/PUT List of all configured/known clients on the site +stat/device-basic GET List of site devices with only 'adopted', 'disabled', 'mac', 'state', 'type' keys, useful for filtering on type +stat/device GET/POST Detailed list of all devices on site. (Controller only) Can be filtered by POSTing {"macs": ["mac1", ... ]} +stat/device/{mac} GET (UDM only) Detailed list of device filtered by mac address +rest/device/{_id} PUT Updates to devices get PUT here, why? +rest/setting GET/PUT Detailed site settings, updating requires adding key and _id to path for PUT ../setting/{key}/{_id} +stat/routing GET All active routes on the device +rest/routing GET/PUT User defined routes (HTTP response 500 per controller version 7.1.66) +rest/firewallrule GET/PUT User defined firewall rules. This does not show auto-generated rules +rest/firewallgroup GET/PUT ​User defined firewall groups. +rest/wlanconf GET/PUT List WLANs, edit current WLANs and create new WLANs +rest/wlanconf/{_id} GET Get details of WLAN designated by '_id' +rest/wlanconf/{_id} PUT Update configuration of current WLAN designated by '_id' +rest/tag GET/PUT? Tagged macs (api.err.Invalid per controller version 7.1.66) +stat/rogueap GET/POST Neighboring APs optional json post 'within' = seen in the last x hours +stat/sitedpi GET/POST DPI stats requires type="by_app" or "by_cat" +stat/stadpi GET/POST DPI stats requires type="by_app" or "by_cat" optionally filtered macs=[…, ] +stat/dynamicdns GET DynamicDNS information and status like current ip, last changed, and status +rest/dynamicdns GET/PUT DynamicDNS configuration +rest/portconf GET Switch port profiles +stat/spectrumscan GET Get RF scan results, can be for a specific mac by appending to endpoint +rest/radiusprofile GET/POST/PUT Radius profiles +rest/account GET/POST/PUT Radius accounts +rest/portforward GET List all port forwards configured on the site +stat/report/{interval}.{type} POST Intervals are '5minutes', 'hourly', and 'daily'. Report types are 'site', 'user', and 'ap'. Must specify attributes to be returned 'bytes', 'wan-tx_bytes', 'wan-rx_bytes', 'wlan_bytes', 'num_sta', 'lan-num_sta', 'wlan-num_sta', 'time', 'rx_bytes', 'tx_bytes'. Can be filtered with 'macs': […] +stat/authorization POST JSON as "{"start": "START TIMESTAMP", "end": "END TIMESTAMP"}" and you will get the code that have been used between the Timestams NOTE: X-CSRF-Token header required (from e.g. the login response) for UDM +stat/sdn GET Return values as if the site is connected with the Unifi Cloud or the SSO +Callable commands +Posting to the endpoint api/s/{site}/cmd/ with the json {"cmd": "command"} you can invoke commands on the controller. + +Manager Call Notes +evtmgt archive-all-alarms +sitemgr add-site desc = Descriptive name ( required ), name = shortname ( in the URL ) +sitemgr delete-site name = short name ( required ) +sitemgr update-site desc = Descriptive name ( required ) +sitemgr get-admins List all administrators and permission for this site +sitemgr move-device mac = device mac ( required ), site_id = 24 digit id ( required ) +sitemgr delete-device mac = device mac ( required ) +stamgr block-sta mac = client mac ( required ) +stamgr unblock-sta mac = client mac ( required ) +stamgr kick-sta Disconnect: mac = client mac (required ) +stamgr forget-sta Forget a client ( controller 5.9.x only ) +stamgr unauthorize-guest Unauthorize a client device, mac = client mac (required) +devmgr adopt mac = device mac ( required ) +devmgr restart mac = device mac ( required ) +devmgr force-provision mac = device mac ( required ) +devmgr power-cycle mac = switch mac ( required ), port_idx = PoE port to cycle ( required ) +devmgr speedtest Start a speed test +devmgr speedtest-status get the current state of the speed test +devmgr set-locate mac = device mac ( required ) blink unit to locate +devmgr unset-locate mac = device mac ( required ) led to normal state +devmgr upgrade mac = device mac ( required ) upgrade firmware +devmgr upgrade-external mac = device mac ( required ), url = firmware URL ( required ) +devmgr migrate mac = device mac ( required ), inform_url = New Inform URL to push to device (required) +devmgr cancel-migrate mac = device mac ( required ) +devmgr spectrum-scan mac = device mac ( ap only, required ) trigger RF scan +backup list-backups list of autobackup files +backup delete-backup filename ( required ) +system backup create a backup. This appears to backup to a fixed location in the filesystem +stat clear-dpi resets the site wide DPI counters +Data Tables +This data was extracted from the javascript of the site. + +Model Type SKU Name +BZ2 uap UAP Access Point +BZ2LR uap UAP-LR Access Point Long-Range +S216150 usw US-16-150W Switch 16 PoE (150 W) +S224250 usw US-24-250W Switch 24 PoE (250 W) +S224500 usw US-24-500W Switch 24 PoE (500 W) +S248500 usw US-48-500W Switch 48 PoE (500 W) +S248750 usw US-48-750W Switch 48 PoE (750 W) +S28150 usw US-8-150W Switch 8 PoE (150 W) +U2HSR uap UAP-Outdoor+ Access Point Outdoor+ +U2IW uap UAP-IW Access Point In-Wall +U2L48 uap UAP-LR Access Point Long-Range +U2Lv2 uap UAP-LRv2 Access Point Long-Range +U2M uap UAP-Mini Access Point Mini +U2O uap UAP-Outdoor Access Point Outdoor +U2S48 uap UAP Access Point +U2Sv2 uap UAPv2 Access Point +U5O uap UAP-Outdoor5 Access Point Outdoor 5 +U6ENT uap U6-Enterprise Access Point WiFi 6 Enterprise +U6EXT uap U6-Extender Access Point WiFi 6 Extender +U6IW uap U6-IW Access Point WiFi 6 In-Wall +U6M uap U6-Mesh Access Point WiFi 6 Mesh +U7E uap UAP-AC Access Point AC +U7EDU uap UAP-AC-EDU Access Point AC EDU +U7Ev2 uap UAP-AC Access Point AC +U7HD uap UAP-AC-HD Access Point AC HD +U7IW uap UAP-AC-IW Access Point AC In-Wall +U7IWP uap UAP-AC-IW-Pro Access Point AC In-Wall Pro +U7LR uap UAP-AC-LR Access Point AC Long-Range +U7LT uap UAP-AC-Lite Access Point AC Lite +U7MP uap UAP-AC-M-Pro Access Point AC Mesh Pro +U7MSH uap UAP-AC-M Access Point AC Mesh +U7NHD uap UAP-nanoHD Access Point nanoHD +U7O uap UAP-AC-Outdoor Access Point AC Outdoor +U7P uap UAP-AC-Pro Access Point AC Pro +U7PG2 uap UAP-AC-Pro Access Point AC Pro +U7SHD uap UAP-AC-SHD Access Point AC SHD +UAE6 uap U6-Extender-EA Access Point WiFi 6 Extender +UAIW6 uap U6-IW-EA Access Point WiFi 6 In-Wall +UAL6 uap U6-Lite Access Point WiFi 6 Lite +UALR6 uap U6-LR-EA Access Point WiFi 6 Long-Range +UALR6v2 uap U6-LR Access Point WiFi 6 Long-Range +UALR6v3 uap U6-LR Access Point WiFi 6 Long-Range +UAM6 uap U6-Mesh-EA Access Point WiFi 6 Mesh +UAP6 uap U6-LR Access Point WiFi 6 Long-Range +UAP6MP uap U6-Pro Access Point WiFi 6 Pro +UASXG uas UAS-XG Application Server XG +UBB ubb UBB Building-to-Building Bridge +UBBXG ubb UBB-XG Building-to-Building Bridge XG +UCGMAX ucg UCG-MAX Cloud Gateway Max +UCK uck UCK Cloud Key +UCK-v2 uck UCK Cloud Key +UCK-v3 uck UCK Cloud Key +UCKG2 uck UCK-G2 Cloud Key Gen2 +UCKP uck UCK-G2-Plus Cloud Key Gen2 Plus +UCMSH uap UAP-XG-Mesh Access Point Mesh XG +UCXG uap UAP-XG Access Point XG +UDC48X6 usw USW-Leaf Switch Leaf +UDM udm UDM Dream Machine +UDMB uap UAP-BeaconHD Access Point BeaconHD +UDMPRO udm UDM-Pro Dream Machine Pro +UDMPROSE udm UDM-SE Dream Machine Special Edition +UDR udm UDR Dream Router +UDW udm UDW Dream Wall +UDWPRO udm UDWPRO Dream Wall Pro +UFLHD uap UAP-FlexHD Access Point FlexHD +UGW3 ugw USG-3P Security Gateway +UGW4 ugw USG-Pro-4 Security Gateway Pro +UGWHD4 ugw USG Security Gateway +UGWXG ugw USG-XG-8 Security Gateway XG +UHDIW uap UAP-IW-HD Access Point In-Wall HD +ULTE uap U-LTE UniFi LTE +ULTEPEU uap U-LTE-Pro UniFi LTE Pro +ULTEPUS uap U-LTE-Pro UniFi LTE Pro +UP1 uap USP-Plug SmartPower Plug +UP4 uph UVP-X Phone +UP5 uph UVP Phone +UP5c uph UVP Phone +UP5t uph UVP-Pro Phone Professional +UP5tc uph UVP-Pro Phone Professional +UP6 uap USP-Strip SmartPower Strip (6 ports) +UP7 uph UVP-Executive Phone Executive +UP7c uph UVP-Executive Phone Executive +US16P150 usw US-16-150W Switch 16 PoE (150 W) +US24 usw USW-24-G1 Switch 24 +US24P250 usw US-24-250W Switch 24 PoE (250 W) +US24P500 usw US-24-500W Switch 24 PoE (500 W) +US24PL2 usw US-L2-24-PoE Switch 24 PoE +US24PRO usw USW-Pro-24-PoE Switch Pro 24 PoE +US24PRO2 usw USW-Pro-24 Switch Pro 24 +US48 usw US-48-G1 Switch 48 +US48P500 usw US-48-500W Switch 48 PoE (500 W) +US48P750 usw US-48-750W Switch 48 PoE (750 W) +US48PL2 usw US-L2-48-PoE Switch 48 PoE +US48PRO usw USW-Pro-48-PoE Switch Pro 48 PoE +US48PRO2 usw USW-Pro-48 Switch Pro 48 +US624P usw USW-Enterprise-24-PoE Switch Enterprise 24 PoE +US648P usw USW-Enterprise-48-PoE Switch Enterprise 48 PoE +US68P usw USW-Enterprise-8-PoE Switch Enterprise 8 PoE +US6XG150 usw US-XG-6PoE Switch 6 XG PoE +US8 usw US-8 Switch 8 +US8P150 usw US-8-150W Switch 8 PoE (150 W) +US8P60 usw US-8-60W Switch 8 (60 W) +USAGGPRO usw USW-Pro-Aggregation Switch Aggregation Pro +USC8 usw US-8 Switch 8 +USC8P150 usw US-8-150W Switch 8 PoE (150 W) +USC8P450 usw USW-Industrial Switch Industrial +USC8P60 usw US-8-60W Switch 8 (60 W) +USF5P usw USW-Flex Switch Flex +USFXG usw USW-Flex-XG Switch Flex XG +USL16LP usw USW-Lite-16-PoE Switch Lite 16 PoE +USL16P usw USW-16-PoE Switch 16 PoE +USL24 usw USW-24-G2 Switch 24 +USL24P usw USW-24-PoE Switch 24 PoE +USL48 usw USW-48-G2 Switch 48 +USL48P usw USW-48-PoE Switch 48 PoE +USL8A usw USW-Aggregation Switch Aggregation +USL8LP usw USW-Lite-8-PoE Switch Lite 8 PoE +USL8MP usw USW-Mission-Critical Switch Mission Critical +USMINI usw USW-Flex-Mini Switch Flex Mini +USPPDUP usw USP-PDU-Pro SmartPower PDU Pro +USPRPS usw USP-RPS SmartPower Redundant Power System +USXG usw US-16-XG Switch XG 16 +USXG24 usw USW-EnterpriseXG-24 Switch Enterprise XG 24 +UXBSDM uap UWB-XG-BK WiFi BaseStation XG +UXGPRO uxg UXG-Pro Next-Generation Gateway Pro +UXSDM uap UWB-XG WiFi BaseStation XG +p2N uap PICOM2HP PicoStation M2 HP +DPI Category Code Name +0 Instant messaging +1 P2P +3 File Transfer +4 Streaming Media +5 Mail and Collaboration +6 Voice over IP +7 Database +8 Games +9 Network Management +10 Remote Access Terminals +11 Bypass Proxies and Tunnels +12 Stock Market +13 Web +14 Security Update +15 Web IM +17 Business +18 Network Protocols +19 Network Protocols +20 Network Protocols +23 Private Protocol +24 Social Network +255 Unknown +There are 2,213 named applications in the javascript dynamic.dpi.js. See extracted cat_app.json to include for lookups and example usage. + +The application id is a compound id using bitwise shift left on the category id + application id sent from the api using list_dpi_stats_filtered + +function compoundId($cat, $app){ + return (intval($cat) << 16) + intval($app); +} +Uncategorized +Dump of found endpoints waiting for documentation + +# logged in user +api/s/{site}/self + +# Country codes +api/s/{site}/stat/ccode +# Availible WiFi channels +api/s/{site}/stat/current-channel + +# Dashboard health +api/s/{site}/stat/health + +# Active client devices +api/s/{site}/stat/sta +# Configured clients +api/s/{site}/stat/user + +# Devices +api/s/{site}/stat/device-basic - mac, type +api/s/{site}/stat/device - can be filtered with macs: [ ..., ... ] + +# Detailed site settings +api/s/{site}/stat/sysinfo + +# /rest/ endpoints also have a /cnt/ which returns the count for the data portion +# can be used for any but seems targeted towards alarms + +# Site settings +api/s/{site}/rest/setting - this is a big one with a weird mechanism for updating + +# Firewall rules +api/s/{site}/rest/firewallrule - only lists user-defined rules + +# Firewall groups +api/s/{site}/rest/firewallgroup + +# routes +api/s/{site}/rest/routing + +# Alarms +# List of alarms +api/s/{site}/rest/alarm +# list of unarchived alarms +api/s/{site}/rest/alarm?archived=false + +# User groups - bandwith settings +api/s/{site}/rest/usergroup + +# ? +api/s/{site}/rest/wlangroup + +# Wireless networks +api/s/{site}/rest/wlanconf + +# ? +api/s/{site}/rest/tag + +# Site networks +api/s/{site}/rest/networkconf + +# example backup path +dl/autobackup/autobackup_5.7.23_20180513_0000_1526169600008.unf + +# Insights - sessions +api/s/{site}/stat/session?type=all&start=1526515200&end=1526688000 + +# Insights - EDU streams +api/s/{site}/stat/stream + +# Switch port conf? +api/s/{site}/rest/portconf + +# Configured port forwards and uPNP - transfer bytes is listed but doesn't appear populated +api/s/{site}/stat/portforward + +# Update User (User are the clients) +api/s/{site}/upd/user/{UserId} +you can get the users and the userid from "/api/s/{SiteId}/stat/alluser" (All Clients) or "/api/s/{SiteId}/stat/sta" (Active Clients) which contains the client id (_id). +example: change name of user with clientid 5aca464bb79fc60200460394 to 'test-raw': +${curl_cmd} --data "json={'name':'test-raw'}" $baseurl/api/s/$site/upd/user/5aca464bb79fc60200460394 + +# Get Hotspot Configuration +guest/s/{site}/hotspotconfig +You will get in "auth" the value "none" if it is not activated, if it is activated you will get for exemple "hotspot" and many other values on the design of the page. + +# Get Hotspot Packages +guest/s/{site}/hotspotpackages + ?? + +# Get trafficrules +v2/api/site/{site}/trafficrules +also possible to add new rule with a POST request. + +# Edit trafficrules +v2/api/site/{site}/trafficrules/{id}/ +PUT or DELETE request to update or delete traffic rule +GET is not allowed on specific trafficrules. +With PUT the result code is 201 and not 200 for successful change. + + +# Possible list of all callable managers +system +devmgr +stamgr +evtmgr +cfgmgr +hotspot +sitemgr +streammgr +backup +throughput +stat +firmware +firewall +elite + + +Update of Port Forward Rules +This may apply to other configurations, but initial testing shows that port forward rules can be enabled/disabled using PUT against the endpoint /api/s/{site}/rest/portforward/{rule-id} with a body such as: + +{ + "enabled": true +} +The rule ID can be retrieved using the above described port forwarding GET request and is found in the "_id" key. + +New rules can be created using POST, but be aware that there seems to be very little validation (it's possible to create entries with no information other than the fact that they're enabled, for example). \ No newline at end of file diff --git a/unifi_integration/examples/credential.txt b/unifi_integration/examples/credential.txt new file mode 100644 index 0000000..5b51b43 --- /dev/null +++ b/unifi_integration/examples/credential.txt @@ -0,0 +1,5 @@ +UDMP_HOST=https://10.10.1.10 +UDMP_USERNAME=api +UDMP_PASSWORD='o=7Jd&WDZ.T,u6t?' +UDMP_PORT=443 +VERIFY_SSL=false diff --git a/unifi_integration/examples/models.md b/unifi_integration/examples/models.md new file mode 100644 index 0000000..c10110e --- /dev/null +++ b/unifi_integration/examples/models.md @@ -0,0 +1,164 @@ +# Documentation des Modèles d'Intégration UniFi + +Ce document décrit les modèles de données utilisés dans le module d'intégration UniFi pour Odoo 17.0. + +## Modèles Principaux + +### Site UniFi (`udm.site`) + +Représente un site UniFi géré par un UDM/UDR ou un contrôleur logiciel. + +**Champs:** +- `name` (Char): Nom du site +- `site_id` (Char): Identifiant du site dans UniFi (toujours 'default') +- `description` (Text): Description du site +- `address` (Text): Adresse physique +- `active` (Boolean): Statut actif +- `controller_type` (Selection): Type de contrôleur (UDM/UDR, Software) +- `host` (Char): Adresse IP ou nom d'hôte du contrôleur +- `port` (Integer): Numéro de port (par défaut: 443) +- `username` (Char): Nom d'utilisateur pour l'authentification +- `password` (Char): Mot de passe pour l'authentification +- `mfa_token` (Char): Code d'authentification à deux facteurs +- `timestamp` (Datetime): Horodatage de la dernière mise à jour +- `raw_data` (Text): Données de configuration brutes au format JSON + +**Relations:** +- `system_info_id` (Many2one): Informations système +- `network_ids` (One2many): Configurations réseau +- `vlan_ids` (One2many): Configurations VLAN +- `device_ids` (One2many): Appareils connectés +- `user_ids` (One2many): Comptes utilisateur +- `settings_id` (Many2one): Paramètres généraux +- `firewall_rule_ids` (One2many): Règles de pare-feu +- `port_forward_ids` (One2many): Règles de redirection de port +- `dns_config_id` (Many2one): Configuration DNS +- `routing_config_id` (Many2one): Configuration de routage +- `dashboard_ids` (One2many): Métriques du tableau de bord +- `statistic_ids` (One2many): Statistiques + +## Modèles de Réseau + +### Network (`udm.network`) + +Représente une configuration réseau. + +**Champs:** +- `name` (Char): Nom du réseau +- `purpose` (Selection): Objectif du réseau (entreprise, invité, etc.) +- `subnet` (Char): Sous-réseau +- `vlan_enabled` (Boolean): Statut VLAN +- `vlan_id` (Integer): Identifiant VLAN +- `dhcp_enabled` (Boolean): Statut DHCP +- `dhcp_start` (Char): Adresse de début DHCP +- `dhcp_stop` (Char): Adresse de fin DHCP +- `domain_name` (Char): Nom de domaine du réseau +- `raw_data` (Text): Données réseau brutes + +### VLAN (`udm.vlan`) + +Stocke la configuration VLAN. + +**Champs:** +- `vlan_id` (Integer): Identifiant VLAN +- `name` (Char): Nom du VLAN +- `raw_data` (Text): Données VLAN brutes + +## Modèles de Sécurité + +### Firewall Rule (`udm.firewall.rule`) + +Définit les règles du pare-feu. + +**Champs:** +- `name` (Char): Nom de la règle +- `description` (Text): Description de la règle +- `action` (Selection): Action (accepter, rejeter, bloquer) +- `protocol` (Char): Protocole réseau +- `source` (Char): Adresse/réseau source +- `destination` (Char): Adresse/réseau de destination +- `enabled` (Boolean): Statut de la règle +- `raw_data` (Text): Données de règle brutes + +### Port Forward (`udm.port.forward`) + +Définit les règles de redirection de port. + +**Champs:** +- `name` (Char): Nom de la règle +- `enabled` (Boolean): Statut de la règle +- `src_port` (Char): Port source +- `dst_port` (Char): Port de destination +- `protocol` (Selection): Protocole (TCP, UDP, les deux) +- `dst_address` (Char): Adresse de destination +- `raw_data` (Text): Données de règle brutes + +## Modèles Système + +### System Info (`udm.system.info`) + +Stocke les informations système de l'UDM Pro. + +**Champs:** +- `hostname` (Char): Nom d'hôte du système +- `version` (Char): Version du firmware +- `model` (Char): Modèle de l'appareil +- `uptime` (Integer): Temps de fonctionnement +- `serial` (Char): Numéro de série +- `mac_address` (Char): Adresse MAC +- `raw_data` (Text): Données système brutes + +### Settings (`udm.settings`) + +Stocke les paramètres généraux de l'UDM Pro. + +**Champs:** +- `timezone` (Char): Fuseau horaire du système +- `ntp_servers` (Char): Liste des serveurs NTP +- `dns_servers` (Char): Liste des serveurs DNS +- `raw_data` (Text): Données de paramètres brutes + +### DNS Config (`udm.dns.config`) + +Stocke la configuration DNS. + +**Champs:** +- `enabled` (Boolean): Statut du service DNS +- `filters_enabled` (Boolean): Statut du filtrage de contenu +- `custom_dns` (Char): Serveurs DNS personnalisés +- `raw_data` (Text): Configuration DNS brute + +### Routing Config (`udm.routing.config`) + +Stocke la configuration de routage. + +**Champs:** +- `ospf_enabled` (Boolean): Statut OSPF +- `static_routes` (Text): Liste des routes statiques +- `raw_data` (Text): Données de routage brutes + +## Modèles Utilisateur + +### User (`udm.user`) + +Représente un compte utilisateur UniFi. + +**Champs:** +- `name` (Char): Nom d'utilisateur +- `email` (Char): Adresse email +- `role` (Char): Rôle de l'utilisateur +- `enabled` (Boolean): Statut du compte +- `raw_data` (Text): Données utilisateur brutes + +### Device (`udm.device`) + +Représente un appareil réseau. + +**Champs:** +- `name` (Char): Nom de l'appareil +- `mac` (Char): Adresse MAC +- `ip` (Char): Adresse IP +- `device_type` (Char): Type d'appareil +- `model` (Char): Modèle de l'appareil +- `last_seen` (Datetime): Dernier horodatage de connexion +- `raw_data` (Text): Données de l'appareil brutes diff --git a/unifi_integration/examples/requirements.txt b/unifi_integration/examples/requirements.txt new file mode 100644 index 0000000..2b1be2a --- /dev/null +++ b/unifi_integration/examples/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +python-dotenv>=1.0.0 diff --git a/unifi_integration/examples/show_clients.py b/unifi_integration/examples/show_clients.py new file mode 100644 index 0000000..64b28a8 --- /dev/null +++ b/unifi_integration/examples/show_clients.py @@ -0,0 +1,98 @@ +""" +Script to display UniFi UDM Pro clients in a clear format +""" +from datetime import datetime +from unifi_client import UnifiClient + +def format_uptime(uptime_seconds): + """Format uptime in a readable format""" + if not uptime_seconds: + return "N/A" + + days = uptime_seconds // (24 * 3600) + hours = (uptime_seconds % (24 * 3600)) // 3600 + minutes = (uptime_seconds % 3600) // 60 + + if days > 0: + return f"{days}j {hours}h {minutes}m" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m" + +def format_bytes(bytes_value): + """Format bytes in human readable format""" + if not bytes_value: + return "0 B" + + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes_value < 1024: + return f"{bytes_value:.1f} {unit}" + bytes_value /= 1024 + return f"{bytes_value:.1f} PB" + +def get_client_type(client): + """Determine client type based on various fields""" + if client.get('is_wired', False): + return 'Filaire' + elif client.get('is_wireless', False): + return 'WiFi' + return 'Inconnu' + +def main(): + """Main function to display clients""" + with UnifiClient() as client: + print("Récupération des clients connectés...") + + # Get clients + clients = client.get_clients() + if not clients or 'data' not in clients: + print("Aucun client trouvé ou erreur de connexion") + return + + # Get networks for VLAN mapping + networks = client.get_networks() + vlan_names = { + str(net.get('vlan', '')): net.get('name', 'N/A') + for net in networks.get('data', []) + if net.get('vlan') is not None + } + + # Group clients by connection type + wired_clients = [] + wireless_clients = [] + + for c in clients['data']: + if c.get('is_wired', False): + wired_clients.append(c) + elif c.get('is_wireless', False): + wireless_clients.append(c) + + # Display clients + def print_client_list(client_list, title): + if not client_list: + return + + print(f"\n=== Clients {title} ===") + print(f"{'Nom':<25} {'IP':<15} {'MAC':<18} {'VLAN':<15} {'Uptime':<15} {'Trafic TX/RX'}") + print("-" * 100) + + for c in sorted(client_list, key=lambda x: x.get('name', '')): + name = c.get('name', c.get('hostname', 'N/A')) + ip = c.get('ip', 'N/A') + mac = c.get('mac', 'N/A') + vlan_id = str(c.get('vlan', '')) + vlan = f"{vlan_id} ({vlan_names.get(vlan_id, 'N/A')})" + uptime = format_uptime(c.get('uptime', 0)) + tx = format_bytes(c.get('tx_bytes', 0)) + rx = format_bytes(c.get('rx_bytes', 0)) + + print(f"{name[:25]:<25} {ip:<15} {mac:<18} {vlan[:15]:<15} {uptime:<15} {tx}/{rx}") + + print_client_list(wired_clients, "Filaires") + print_client_list(wireless_clients, "WiFi") + + print(f"\nTotal: {len(wired_clients)} clients filaires, {len(wireless_clients)} clients WiFi") + +if __name__ == "__main__": + main() diff --git a/unifi_integration/examples/show_device_details.py b/unifi_integration/examples/show_device_details.py new file mode 100644 index 0000000..4e4eea3 --- /dev/null +++ b/unifi_integration/examples/show_device_details.py @@ -0,0 +1,163 @@ +""" +Script to display detailed information about a UniFi device +""" +from datetime import datetime +from unifi_client import UnifiClient + +def format_bytes(bytes_value): + """Format bytes in human readable format""" + if not bytes_value: + return "0 B" + + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes_value < 1024: + return f"{bytes_value:.1f} {unit}" + bytes_value /= 1024 + return f"{bytes_value:.1f} PB" + +def format_temperature(temp_celsius): + """Format temperature in Celsius""" + if not temp_celsius: + return "N/A" + return f"{temp_celsius}°C" + +def format_radio_stats(radio): + """Format radio statistics""" + channel = radio.get('channel', 'N/A') + width = radio.get('channel_width', 'N/A') + tx_power = radio.get('tx_power', 'N/A') + tx_power_str = f"{tx_power}dBm" if tx_power != 'N/A' else 'N/A' + utilization = radio.get('utilization', 'N/A') + util_str = f"{utilization}%" if utilization != 'N/A' else 'N/A' + + return f"Canal {channel} ({width}MHz), Puissance: {tx_power_str}, Utilisation: {util_str}" + +def format_client_info(client): + """Format client information""" + name = client.get('name', client.get('hostname', 'Inconnu')) + ip = client.get('ip', 'N/A') + signal = client.get('signal', 0) + uptime = client.get('uptime', 0) + uptime_str = f"{uptime//3600}h {(uptime%3600)//60}m" if uptime else 'N/A' + tx_rate = client.get('tx_rate', 0) + rx_rate = client.get('rx_rate', 0) + + return f"{name:<20} IP: {ip:<15} Signal: {signal}dBm, Up: {uptime_str}, Vitesse: ↓{rx_rate}Mbps ↑{tx_rate}Mbps" + +def format_port_status(port): + """Format port status information""" + if not port: + return "N/A" + + speed = port.get('speed', 0) + if speed == 0: + return "Déconnecté" + return f"{speed}Mbps {'(PoE)' if port.get('poe_enable', False) else ''}" + +def show_device_details(device): + """Display detailed information about a device""" + print(f"\n=== Détails de l'appareil : {device.get('name', 'N/A')} ===") + + # Informations de base + print("\nInformations de base:") + print(f" Modèle : {device.get('model', 'N/A')}") + print(f" Version : {device.get('version', 'N/A')}") + print(f" IP : {device.get('ip', 'N/A')}") + print(f" MAC : {device.get('mac', 'N/A')}") + print(f" État : {'En ligne' if device.get('state', 0) == 1 else 'Hors ligne'}") + + # Statistiques système + print("\nStatistiques système:") + print(f" CPU : {device.get('system_stats', {}).get('cpu', 'N/A')}%") + print(f" Mémoire : {device.get('system_stats', {}).get('mem', 'N/A')}%") + print(f" Température : {format_temperature(device.get('general_temperature', 0))}") + + # Statistiques réseau + stats = device.get('stat', {}) + print("\nStatistiques réseau:") + print(f" Bytes reçus : {format_bytes(stats.get('rx_bytes', 0))}") + print(f" Bytes envoyés : {format_bytes(stats.get('tx_bytes', 0))}") + print(f" Paquets reçus : {stats.get('rx_packets', 0)}") + print(f" Paquets envoyés: {stats.get('tx_packets', 0)}") + + # Information sur les ports (pour les switches) + if 'port_table' in device: + print("\nPorts:") + for port in device['port_table']: + port_name = port.get('name', f"Port {port.get('port_idx', 'N/A')}") + status = format_port_status(port) + print(f" {port_name:<12} : {status}") + + # Information WiFi (pour les points d'accès) + if device.get('radio_table'): + print("\nRadios WiFi:") + for radio in device['radio_table']: + band = "5 GHz" if radio.get('radio', '') == 'na' else "2.4 GHz" + channel = radio.get('channel', 'N/A') + tx_power = radio.get('tx_power', 'N/A') + print(f" {band:<12} : Canal {channel}, Puissance {tx_power}dBm") + +def format_poe_status(port): + """Format PoE status information""" + if not port or not port.get('poe_enable'): + return '' + + try: + power = float(port.get('poe_power', 0)) + voltage = float(port.get('poe_voltage', 0)) + current = float(port.get('poe_current', 0)) + + if power > 0: + return f"PoE: {power:.1f}W ({voltage:.1f}V, {current:.0f}mA)" + except (ValueError, TypeError): + pass + + return 'PoE activé' + +def main(): + """Main function to display device details""" + with UnifiClient() as client: + print("Récupération des appareils UniFi...") + + # Get devices + devices = client.get_devices() + if not devices or 'data' not in devices: + print("Aucun appareil trouvé ou erreur de connexion") + return + + # Find all APs + aps = [device for device in devices['data'] if device.get('model') in ['U7PG2', 'U7LR', 'U7IW']] + + if aps: + for i, ap in enumerate(aps, 1): + if i > 1: + print("\n" + "=" * 80 + "\n") + + # Show basic details + show_device_details(ap) + + # Show WiFi details + if 'radio_table' in ap: + print("\nRadios WiFi:") + for radio in ap['radio_table']: + band = "5 GHz" if radio.get('radio') == 'na' else "2.4 GHz" + print(f" {band}: {format_radio_stats(radio)}") + + # Show connected clients + if 'vap_table' in ap: + print("\nClients connectés:") + for vap in ap['vap_table']: + if vap.get('sta_count', 0) > 0: + ssid = vap.get('essid', 'N/A') + print(f"\nSSID: {ssid}") + print(" Nom IP Signal Temps Vitesse") + print(" " + "-" * 75) + + for client in vap.get('sta_table', []): + print(f" {format_client_info(client)}") + + else: + print("Aucun point d'accès trouvé") + +if __name__ == "__main__": + main() diff --git a/unifi_integration/examples/show_devices.py b/unifi_integration/examples/show_devices.py new file mode 100644 index 0000000..15219dc --- /dev/null +++ b/unifi_integration/examples/show_devices.py @@ -0,0 +1,86 @@ +""" +Script to display UniFi UDM Pro devices in a clear format +""" +from datetime import datetime +from unifi_client import UnifiClient + +def format_uptime(uptime_seconds): + """Format uptime in a readable format""" + if not uptime_seconds: + return "N/A" + + days = uptime_seconds // (24 * 3600) + hours = (uptime_seconds % (24 * 3600)) // 3600 + minutes = (uptime_seconds % 3600) // 60 + + if days > 0: + return f"{days}j {hours}h {minutes}m" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m" + +def get_device_type(device): + """Get device type in French""" + model = device.get('model', '').lower() + if 'udm' in model: + return 'Routeur' + elif 'usw' in model: + return 'Switch' + elif 'uap' in model: + return 'Point d\'accès' + return 'Autre' + +def get_device_status(device): + """Get device status in French""" + if device.get('state', 0) == 1: + return '✓ En ligne' + return '✗ Hors ligne' + +def format_device(device): + """Format device information""" + return { + 'name': device.get('name', 'N/A'), + 'model': device.get('model', 'N/A'), + 'type': get_device_type(device), + 'ip': device.get('ip', 'N/A'), + 'mac': device.get('mac', 'N/A'), + 'version': device.get('version', 'N/A'), + 'status': get_device_status(device), + 'uptime': format_uptime(device.get('uptime', 0)) + } + +def main(): + """Main function to display devices""" + with UnifiClient() as client: + print("Récupération des appareils UniFi...") + + # Get devices + devices = client.get_devices() + if not devices or 'data' not in devices: + print("Aucun appareil trouvé ou erreur de connexion") + return + + # Group devices by type + devices_by_type = {} + for device in devices['data']: + device_type = get_device_type(device) + if device_type not in devices_by_type: + devices_by_type[device_type] = [] + devices_by_type[device_type].append(device) + + # Display devices by type + for device_type, device_list in devices_by_type.items(): + print(f"\n=== {device_type}s ===") + print(f"{'Nom':<20} {'Modèle':<15} {'IP':<15} {'Version':<10} {'État':<15} {'Uptime'}") + print("-" * 90) + + for device in sorted(device_list, key=lambda x: x.get('name', '')): + d = format_device(device) + print(f"{d['name'][:20]:<20} {d['model'][:15]:<15} {d['ip']:<15} " + f"{d['version'][:10]:<10} {d['status']:<15} {d['uptime']}") + + print(f"\nTotal: {len(devices['data'])} appareils UniFi") + +if __name__ == "__main__": + main() diff --git a/unifi_integration/examples/show_firewall.py b/unifi_integration/examples/show_firewall.py new file mode 100644 index 0000000..1e4e794 --- /dev/null +++ b/unifi_integration/examples/show_firewall.py @@ -0,0 +1,68 @@ +""" +Script to display UniFi UDM Pro firewall rules in a clear format +""" +import json +from unifi_client import UnifiClient + +def format_rule(rule): + """Format a firewall rule for better readability""" + direction = "IN" if rule.get('ruleset', '').endswith('_IN') else "OUT" + src = rule.get('src_address', 'any') + dst = rule.get('dst_address', 'any') + + if direction == "IN": + from_to = f"FROM {src} TO {dst if dst != '' else 'any'}" + else: + from_to = f"FROM {src if src != '' else 'any'} TO {dst}" + + return { + 'name': rule.get('name', 'N/A'), + 'action': rule.get('action', 'N/A').upper(), + 'direction': direction, + 'protocol': rule.get('protocol', 'all').upper(), + 'ports': f"{rule.get('src_port', 'any')} -> {rule.get('dst_port', 'any')}", + 'addresses': from_to, + 'enabled': '✓' if rule.get('enabled', True) else '✗' + } + +def print_rules_by_type(rules, ruleset_type): + """Print rules filtered by ruleset type""" + filtered_rules = [r for r in rules if ruleset_type in r.get('ruleset', '')] + if filtered_rules: + print(f"\n=== Règles {ruleset_type} ===") + for rule in filtered_rules: + r = format_rule(rule) + print(f"\n{r['name']}") + print(f" État : {r['enabled']}") + print(f" Action : {r['action']}") + print(f" Protocol : {r['protocol']}") + print(f" Ports : {r['ports']}") + print(f" Adresses : {r['addresses']}") + +def main(): + """Main function to display firewall rules""" + with UnifiClient() as client: + print("Récupération des règles de pare-feu...") + + # Get firewall rules + rules = client.get_firewall_rules() + if not rules or 'data' not in rules: + print("Aucune règle trouvée ou erreur de connexion") + return + + # Display rules by type + print_rules_by_type(rules['data'], 'WAN_IN') + print_rules_by_type(rules['data'], 'WAN_OUT') + print_rules_by_type(rules['data'], 'LAN_IN') + print_rules_by_type(rules['data'], 'LAN_OUT') + + # Get and display firewall groups + groups = client.get_firewall_groups() + if groups and 'data' in groups: + print("\n=== Groupes de Pare-feu ===") + for group in groups['data']: + print(f"\n{group.get('name', 'N/A')} ({group.get('group_type', 'N/A')})") + print(f" Membres : {', '.join(group.get('group_members', []))}") + +if __name__ == "__main__": + main() diff --git a/unifi_integration/examples/show_networks.py b/unifi_integration/examples/show_networks.py new file mode 100644 index 0000000..80ea24a --- /dev/null +++ b/unifi_integration/examples/show_networks.py @@ -0,0 +1,58 @@ +""" +Script to display UniFi UDM Pro networks in a clear format +""" +import json +from unifi_client import UnifiClient + +def format_network(network): + """Format network information for better readability""" + purpose = network.get('purpose', 'corporate') + if purpose == 'corporate': + purpose = 'Entreprise' + elif purpose == 'guest': + purpose = 'Invité' + elif purpose == 'vlan-only': + purpose = 'VLAN uniquement' + + dhcp_enabled = network.get('dhcpd_enabled', False) + dhcp_info = "" + if dhcp_enabled: + start = network.get('dhcpd_start', 'N/A') + stop = network.get('dhcpd_stop', 'N/A') + dhcp_info = f"({start} - {stop})" + + return { + 'nom': network.get('name', 'N/A'), + 'vlan': network.get('vlan', 'Non'), + 'purpose': purpose, + 'subnet': network.get('subnet', 'N/A'), + 'dhcp': 'Activé ' + dhcp_info if dhcp_enabled else 'Désactivé', + 'dns': network.get('dns_nameservers', ['N/A']), + 'enabled': network.get('enabled', True) + } + +def main(): + """Main function to display networks""" + with UnifiClient() as client: + print("Récupération des réseaux configurés...") + + # Get networks + networks = client.get_networks() + if not networks or 'data' not in networks: + print("Aucun réseau trouvé ou erreur de connexion") + return + + # Display networks + print("\n=== Réseaux Configurés ===") + for network in networks['data']: + net = format_network(network) + print(f"\n{net['nom']}") + print(f" État : {'Actif' if net['enabled'] else 'Inactif'}") + print(f" Type : {net['purpose']}") + print(f" VLAN : {net['vlan']}") + print(f" Subnet : {net['subnet']}") + print(f" DHCP : {net['dhcp']}") + print(f" DNS : {', '.join(net['dns'])}") + +if __name__ == "__main__": + main() diff --git a/unifi_integration/examples/show_vlans.py b/unifi_integration/examples/show_vlans.py new file mode 100644 index 0000000..40ae95e --- /dev/null +++ b/unifi_integration/examples/show_vlans.py @@ -0,0 +1,54 @@ +""" +Script to display UniFi UDM Pro VLANs in a clear format +""" +from unifi_client import UnifiClient + +def get_vlan_purpose(network): + """Get the purpose/usage of a VLAN""" + purpose = network.get('purpose', 'corporate') + if purpose == 'corporate': + return 'Entreprise' + elif purpose == 'guest': + return 'Invité' + elif purpose == 'vlan-only': + return 'VLAN uniquement' + return purpose + +def main(): + """Main function to display VLANs""" + with UnifiClient() as client: + print("Récupération des VLANs configurés...") + + # Get networks + networks = client.get_networks() + if not networks or 'data' not in networks: + print("Aucun VLAN trouvé ou erreur de connexion") + return + + # Filter and sort networks with VLANs + vlan_networks = [n for n in networks['data'] if n.get('vlan', None) is not None] + vlan_networks.sort(key=lambda x: x.get('vlan', 0)) + + # Display VLANs + print("\n=== VLANs Configurés ===") + print(f"{'VLAN ID':<8} {'Nom':<20} {'Type':<15} {'Plage DHCP':<35} {'État'}") + print("-" * 85) + + for network in vlan_networks: + vlan_id = network.get('vlan', 'N/A') + name = network.get('name', 'N/A') + purpose = get_vlan_purpose(network) + + # Format DHCP range + dhcp_range = 'DHCP désactivé' + if network.get('dhcpd_enabled', False): + start = network.get('dhcpd_start', '') + stop = network.get('dhcpd_stop', '') + dhcp_range = f"{start} - {stop}" + + state = 'Actif' if network.get('enabled', True) else 'Inactif' + + print(f"{vlan_id:<8} {name[:20]:<20} {purpose[:15]:<15} {dhcp_range[:35]:<35} {state}") + +if __name__ == "__main__": + main() diff --git a/unifi_integration/examples/test_firewall.py b/unifi_integration/examples/test_firewall.py new file mode 100644 index 0000000..bcc880d --- /dev/null +++ b/unifi_integration/examples/test_firewall.py @@ -0,0 +1,74 @@ +""" +Test script for UniFi UDM Pro Firewall Management +This script demonstrates the firewall management capabilities of the UnifiClient class. +""" +import json +from unifi_client import UnifiClient + +def format_firewall_rule(rule): + """Format a firewall rule for display + + Args: + rule (dict): Firewall rule data + + Returns: + dict: Formatted rule data + """ + return { + 'name': rule.get('name', 'N/A'), + 'ruleset': rule.get('ruleset', 'N/A'), + 'action': rule.get('action', 'N/A'), + 'protocol': rule.get('protocol', 'all'), + 'src_address': rule.get('src_address', 'any'), + 'dst_address': rule.get('dst_address', 'any'), + 'enabled': rule.get('enabled', True) + } + +def main(): + """ + Main test function to demonstrate firewall management + """ + with UnifiClient() as client: + print("Connexion à l'UDM Pro...") + + # Get existing firewall rules + print("\nRègles de pare-feu existantes:") + rules = client.get_firewall_rules() + if rules and rules.get('data'): + for rule in rules['data']: + formatted_rule = format_firewall_rule(rule) + print(json.dumps(formatted_rule, indent=2)) + + # Get firewall groups + print("\nGroupes de pare-feu:") + groups = client.get_firewall_groups() + if groups and groups.get('data'): + for group in groups['data']: + print(json.dumps({ + 'name': group.get('name', 'N/A'), + 'type': group.get('group_type', 'N/A'), + 'members': group.get('group_members', []) + }, indent=2)) + + # Example: Create a new firewall rule + # Note: Commented out to prevent accidental rule creation + """ + new_rule = { + 'name': 'Test SSH Access', + 'enabled': True, + 'action': 'accept', + 'ruleset': 'WAN_IN', + 'protocol': 'tcp', + 'dst_port': '22', + 'description': 'Allow SSH access from WAN' + } + + print("\nCréation d'une nouvelle règle:") + result = client.create_firewall_rule(new_rule) + if result: + print("Règle créée avec succès:") + print(json.dumps(format_firewall_rule(result['data'][0]), indent=2)) + """ + +if __name__ == "__main__": + main() diff --git a/unifi_integration/examples/test_unifi.py b/unifi_integration/examples/test_unifi.py new file mode 100644 index 0000000..e5dad01 --- /dev/null +++ b/unifi_integration/examples/test_unifi.py @@ -0,0 +1,91 @@ +""" +Test script for UniFi UDM Pro API Client +This script demonstrates the basic usage of the UnifiClient class. +""" +import json +from unifi_client import UnifiClient + +def format_response(data, max_items=5): + """Format API response data to show only essential information + + Args: + data (dict): API response data + max_items (int): Maximum number of items to show + + Returns: + dict: Formatted data + """ + if not data or 'data' not in data: + return data + + result = {'meta': data.get('meta', {}), 'data': []} + + # Take only the first max_items + items = data['data'][:max_items] + + for item in items: + # For devices, show only essential info + if 'model' in item: + result['data'].append({ + 'name': item.get('name', 'N/A'), + 'model': item.get('model', 'N/A'), + 'ip': item.get('ip', 'N/A'), + 'status': item.get('state', 'N/A') + }) + # For clients, show only essential info + elif 'hostname' in item: + result['data'].append({ + 'name': item.get('hostname', 'N/A'), + 'ip': item.get('ip', 'N/A'), + 'mac': item.get('mac', 'N/A'), + 'network': item.get('network', 'N/A') + }) + # For other types, keep all fields + else: + result['data'].append(item) + + if len(data['data']) > max_items: + print(f"\nNote: Showing {max_items} of {len(data['data'])} items") + + return result + +def main(): + """ + Main test function to demonstrate UnifiClient usage + """ + # Using context manager for automatic login/logout + with UnifiClient() as client: + # Get system information + system_info = client.get_system_info() + + if system_info: + print("Successfully connected to UDM Pro!") + print("\nSystem Information:") + system_info = format_response(system_info) + print(json.dumps(system_info, indent=2)) + + # Get network health status + health = client.get_health() + if health: + print("\nNetwork Health:") + health = format_response(health) + print(json.dumps(health, indent=2)) + + # Get connected devices + devices = client.get_devices() + if devices: + print("\nConnected Devices:") + devices = format_response(devices) + print(json.dumps(devices, indent=2)) + + # Get active clients + clients = client.get_clients() + if clients: + print("\nActive Clients:") + clients = format_response(clients) + print(json.dumps(clients, indent=2)) + else: + print("Failed to retrieve system information") + +if __name__ == "__main__": + main() diff --git a/unifi_integration/examples/unifi_client.py b/unifi_integration/examples/unifi_client.py new file mode 100644 index 0000000..cb4d7eb --- /dev/null +++ b/unifi_integration/examples/unifi_client.py @@ -0,0 +1,306 @@ +""" +UniFi UDM Pro API Client +This module provides a client to interact with the UniFi Dream Machine Pro API. +""" +import os +import json +import requests +from urllib3.exceptions import InsecureRequestWarning +import dotenv + +# Suppress only the single warning from urllib3 needed. +requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) + +class UnifiClient: + """ + A client for interacting with the UniFi Dream Machine Pro API. + Handles authentication and basic API operations. + """ + def __init__(self, config_file=None): + """ + Initialize the UniFi client with configuration from environment file. + + Args: + config_file (str): Path to the configuration file (default: Doc/credential.txt) + """ + if config_file is None: + config_file = os.path.join(os.path.dirname(__file__), 'Doc/credential.txt') + + # Load configuration from file + dotenv.load_dotenv(config_file) + + self.host = os.getenv('UDMP_HOST').rstrip('/') + self.username = os.getenv('UDMP_USERNAME') + self.password = os.getenv('UDMP_PASSWORD') + self.verify_ssl = os.getenv('VERIFY_SSL', 'false').lower() == 'true' + + self.session = requests.Session() + self.session.verify = self.verify_ssl + self.csrf_token = None + + def login(self): + """ + Authenticate with the UDM Pro. + + Returns: + bool: True if login successful, False otherwise + """ + login_url = f"{self.host}/api/auth/login" + headers = {'Content-Type': 'application/json'} + data = { + 'username': self.username, + 'password': self.password + } + + try: + response = self.session.post( + login_url, + headers=headers, + json=data, + timeout=10 # 10 seconds timeout + ) + response.raise_for_status() + + # Store CSRF token if present + if 'X-CSRF-Token' in response.headers: + self.csrf_token = response.headers['X-CSRF-Token'] + + return True + + except requests.exceptions.RequestException as e: + print(f"Login failed: {str(e)}") + return False + + def get_system_info(self): + """ + Get basic system information. + + Returns: + dict: System information or None if request fails + """ + url = f"{self.host}/proxy/network/api/s/default/self" + + try: + response = self.session.get(url, timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + print(f"Failed to get system info: {str(e)}") + return None + + def logout(self): + """ + Logout from the UDM Pro. + + Returns: + bool: True if logout successful, False otherwise + """ + logout_url = f"{self.host}/api/auth/logout" + + # Add required headers for logout + headers = { + 'Content-Type': 'application/json', + } + if self.csrf_token: + headers['X-CSRF-Token'] = self.csrf_token + + try: + # Send empty JSON body as required by the API + response = self.session.post(logout_url, headers=headers, json={}, timeout=10) + + # Note: UDM Pro returns 200 even if session is already expired + if response.status_code == 200: + return True + + response.raise_for_status() + return True + + except requests.exceptions.RequestException as e: + # If we get a 403, it might mean we're already logged out + if isinstance(e, requests.exceptions.HTTPError) and e.response.status_code == 403: + return True + + print(f"Logout failed: {str(e)}") + return False + + def __enter__(self): + """Context manager entry point""" + self.login() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit point""" + self.logout() + + def get_devices(self): + """Get all devices connected to the UDM Pro. + + Returns: + dict: List of devices or None if request fails + """ + url = f"{self.host}/proxy/network/api/s/default/stat/device" + + try: + response = self.session.get(url, timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + print(f"Failed to get devices: {str(e)}") + return None + + def get_clients(self): + """Get all clients (users) connected to the network. + + Returns: + dict: List of clients or None if request fails + """ + url = f"{self.host}/proxy/network/api/s/default/stat/sta" + + try: + response = self.session.get(url, timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + print(f"Failed to get clients: {str(e)}") + return None + + def get_health(self): + """Get the health status of the UDM Pro and network. + + Returns: + dict: Health information or None if request fails + """ + url = f"{self.host}/proxy/network/api/s/default/stat/health" + + try: + response = self.session.get(url, timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + print(f"Failed to get health status: {str(e)}") + return None + + def get_firewall_rules(self): + """Get all firewall rules. + + Returns: + dict: List of firewall rules or None if request fails + """ + url = f"{self.host}/proxy/network/api/s/default/rest/firewallrule" + + try: + response = self.session.get(url, timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + print(f"Failed to get firewall rules: {str(e)}") + return None + + def get_firewall_groups(self): + """Get all firewall groups (address groups, port groups). + + Returns: + dict: List of firewall groups or None if request fails + """ + url = f"{self.host}/proxy/network/api/s/default/rest/firewallgroup" + + try: + response = self.session.get(url, timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + print(f"Failed to get firewall groups: {str(e)}") + return None + + def create_firewall_rule(self, rule_data): + """Create a new firewall rule. + + Args: + rule_data (dict): Rule configuration with the following fields: + - name: Rule name + - action: 'accept' or 'drop' or 'reject' + - ruleset: 'WAN_IN', 'WAN_OUT', 'WAN_LOCAL', 'LAN_IN', etc. + - protocol: 'all', 'tcp', 'udp', etc. + - src_address: Source address/network (optional) + - dst_address: Destination address/network (optional) + - src_port: Source port (optional) + - dst_port: Destination port (optional) + - enabled: True/False + + Returns: + dict: Created rule data or None if request fails + """ + url = f"{self.host}/proxy/network/api/s/default/rest/firewallrule" + + try: + response = self.session.post(url, json=rule_data, timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + print(f"Failed to create firewall rule: {str(e)}") + return None + + def update_firewall_rule(self, rule_id, rule_data): + """Update an existing firewall rule. + + Args: + rule_id (str): ID of the rule to update + rule_data (dict): Updated rule configuration + + Returns: + dict: Updated rule data or None if request fails + """ + url = f"{self.host}/proxy/network/api/s/default/rest/firewallrule/{rule_id}" + + try: + response = self.session.put(url, json=rule_data, timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + print(f"Failed to update firewall rule: {str(e)}") + return None + + def delete_firewall_rule(self, rule_id): + """Delete a firewall rule. + + Args: + rule_id (str): ID of the rule to delete + + Returns: + bool: True if successful, False otherwise + """ + url = f"{self.host}/proxy/network/api/s/default/rest/firewallrule/{rule_id}" + + try: + response = self.session.delete(url, timeout=10) + response.raise_for_status() + return True + + except requests.exceptions.RequestException as e: + print(f"Failed to delete firewall rule: {str(e)}") + return False + + def get_networks(self): + """Get all networks configuration. + + Returns: + dict: List of networks or None if request fails + """ + url = f"{self.host}/proxy/network/api/s/default/rest/networkconf" + + try: + response = self.session.get(url, timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + print(f"Failed to get networks: {str(e)}") + return None diff --git a/unifi_integration/i18n/fr_CA.po b/unifi_integration/i18n/fr_CA.po new file mode 100644 index 0000000..d5078ea --- /dev/null +++ b/unifi_integration/i18n/fr_CA.po @@ -0,0 +1,157 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * unifi_integration +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-03-11 12:34+0000\n" +"PO-Revision-Date: 2025-03-11 12:34+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_port_forward__name +msgid "Name" +msgstr "Nom" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_port_forward__enabled +msgid "Enabled" +msgstr "Activé" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_port_forward__src_port +msgid "Source Port" +msgstr "Port source" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_port_forward__dst_port +msgid "Destination Port" +msgstr "Port de destination" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_port_forward__dst_address +msgid "Destination Address" +msgstr "Adresse de destination" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_dns_config__enabled +msgid "Enabled" +msgstr "Activé" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_dns_config__filters_enabled +msgid "Content Filtering Enabled" +msgstr "Filtrage de contenu activé" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_dns_config__custom_dns +msgid "Custom DNS Servers" +msgstr "Serveurs DNS personnalisés" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_routing_config__ospf_enabled +msgid "OSPF Enabled" +msgstr "OSPF activé" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_routing_config__static_routes +msgid "Static Routes" +msgstr "Routes statiques" + +#. module: unifi_integration +#: model:ir.model.fields,field_description:unifi_integration.field_udm_port_forward__protocol +msgid "Protocol" +msgstr "Protocole" + +#. module: unifi_integration +#: model:ir.model.fields.selection,name:unifi_integration.selection__udm_port_forward__protocol__tcp +msgid "TCP" +msgstr "TCP" + +#. module: unifi_integration +#: model:ir.model.fields.selection,name:unifi_integration.selection__udm_port_forward__protocol__udp +msgid "UDP" +msgstr "UDP" + +#. module: unifi_integration +#: model:ir.model.fields.selection,name:unifi_integration.selection__udm_port_forward__protocol__both +msgid "TCP & UDP" +msgstr "TCP et UDP" + +#. module: unifi_integration +#: model:ir.actions.act_window,name:unifi_integration.action_udm_port_forward +#: model:ir.ui.menu,name:unifi_integration.menu_udm_port_forward +msgid "Port Forwards" +msgstr "Redirections de ports" + +#. module: unifi_integration +#: model:ir.actions.act_window,name:unifi_integration.action_udm_dns_config +#: model:ir.ui.menu,name:unifi_integration.menu_udm_dns_config +msgid "DNS Configuration" +msgstr "Configuration DNS" + +#. module: unifi_integration +#: model:ir.actions.act_window,name:unifi_integration.action_udm_routing_config +#: model:ir.ui.menu,name:unifi_integration.menu_udm_routing_config +msgid "Routing Configuration" +msgstr "Configuration du routage" + +#. module: unifi_integration +#: model:ir.model,name:unifi_integration.model_udm_port_forward +msgid "UDM Pro Port Forward Rule" +msgstr "Règle de redirection de port UDM Pro" + +#. module: unifi_integration +#: model:ir.model,name:unifi_integration.model_udm_dns_config +msgid "UDM Pro DNS Configuration" +msgstr "Configuration DNS UDM Pro" + +#. module: unifi_integration +#: model:ir.model,name:unifi_integration.model_udm_routing_config +msgid "UDM Pro Routing Configuration" +msgstr "Configuration du routage UDM Pro" + +#. module: unifi_integration +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_port_forward_form +msgid "Identification" +msgstr "Identification" + +#. module: unifi_integration +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_port_forward_form +msgid "Redirection" +msgstr "Redirection" + +#. module: unifi_integration +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_dns_config_form +msgid "État" +msgstr "État" + +#. module: unifi_integration +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_dns_config_form +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_routing_config_form +msgid "Configuration" +msgstr "Configuration" + +#. module: unifi_integration +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_routing_config_form +msgid "Protocoles de routage" +msgstr "Protocoles de routage" + +#. module: unifi_integration +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_routing_config_form +msgid "Routes" +msgstr "Routes" + +#. module: unifi_integration +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_port_forward_form +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_dns_config_form +#: model_terms:ir.ui.view,arch_db:unifi_integration.view_udm_routing_config_form +msgid "Technical" +msgstr "Technique" diff --git a/unifi_integration/models/__init__.py b/unifi_integration/models/__init__.py index 39c9d38..7f9691d 100644 --- a/unifi_integration/models/__init__.py +++ b/unifi_integration/models/__init__.py @@ -1,9 +1,25 @@ # -*- coding: utf-8 -*- +# Import module files +from . import udm_site from . import udm_config -from . import udm_system_info from . import udm_network from . import udm_device from . import udm_user from . import udm_settings from . import udm_firewall +from . import udm_port_forward +from . import udm_dns +from . import udm_routing + +# Import all models to ensure they are registered with Odoo +from .udm_site import UdmSite +from .udm_config import UdmConfiguration +from .udm_network import UdmNetwork, UdmVlan +from .udm_device import UdmDevice +from .udm_user import UdmUser +from .udm_settings import UdmSettings +from .udm_firewall import UdmFirewallRule +from .udm_port_forward import UdmPortForward +from .udm_dns import UdmDnsConfig +from .udm_routing import UdmRoutingConfig diff --git a/unifi_integration/models/dashboard.py b/unifi_integration/models/dashboard.py new file mode 100644 index 0000000..34ea40e --- /dev/null +++ b/unifi_integration/models/dashboard.py @@ -0,0 +1,316 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import json +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta + +class UdmDashboardMetric(models.Model): + """Model for storing and managing UDM Pro dashboard metrics. + + This model stores real-time and near-real-time metrics from UDM Pro devices, + such as bandwidth usage, CPU/memory utilization, connected clients count, + and security threat counts. Each metric is associated with a specific site + and includes both raw and formatted values for display. + + The model supports: + - Multiple metric types with appropriate formatting + - Status computation based on thresholds + - Historical data storage for trending + - Automatic value formatting based on metric type + + Metrics are ordered by last update time to show most recent data first. + """ + _name = 'udm.dashboard.metric' + _description = 'UDM Dashboard Metric' + _order = 'last_update desc' # Most recent metrics first + + # Site this metric belongs to, cascade deletion if site is deleted + site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade') + + # Type of metric being tracked + metric_type = fields.Selection([ + ('bandwidth_usage', 'Bandwidth Usage'), # Network bandwidth utilization + ('cpu_usage', 'CPU Usage'), # CPU utilization percentage + ('memory_usage', 'Memory Usage'), # Memory utilization percentage + ('clients_count', 'Connected Clients'), # Number of connected network clients + ('wan_status', 'WAN Status'), # WAN connection status (up/down) + ('threat_count', 'Security Threats'), # Number of security threats detected + ('device_status', 'Device Status'), # Status of network devices (online/offline) + ], string='Metric Type', required=True) + + # Raw metric values + current_value = fields.Char(string='Current Value', required=True, + help="Current raw value of the metric") + max_value = fields.Char(string='Maximum Value', + help="Maximum allowed value for this metric, used for threshold calculations") + history_data = fields.Text(string='Historical Data', + help="JSON data containing historical values for graphing") + last_update = fields.Datetime(string='Last Update', default=fields.Datetime.now, + help="Timestamp of the last metric update") + + # Computed fields for display + formatted_value = fields.Char(compute='_compute_formatted_value', string='Formatted Value', + help="Human-readable formatted value with appropriate units") + status = fields.Selection([ + ('normal', 'Normal'), # Operating within normal parameters + ('warning', 'Warning'), # Approaching critical thresholds + ('critical', 'Critical'), # Exceeded critical thresholds + ], compute='_compute_status', string='Status', + help="Status indicator based on metric thresholds") + + @api.depends('current_value', 'metric_type') + def _compute_formatted_value(self): + """Compute a human-readable formatted value based on the metric type. + + This method formats the raw value into a user-friendly string with appropriate units: + - For bandwidth: Converts to Mbps or Gbps with 1 decimal place + - For CPU/Memory: Adds percentage sign with 1 decimal place + - For other metrics: Uses the raw value as is + + The formatted value is used in the UI to display metrics in a consistent + and readable format. + """ + for record in self: + if not record.current_value: + record.formatted_value = '' + continue + + if record.metric_type == 'bandwidth_usage': + try: + value = float(record.current_value) + if value >= 1000: + record.formatted_value = f"{value/1000:.1f} Gbps" + else: + record.formatted_value = f"{value:.1f} Mbps" + except (ValueError, TypeError): + record.formatted_value = record.current_value + elif record.metric_type in ['cpu_usage', 'memory_usage']: + try: + value = float(record.current_value) + record.formatted_value = f"{value:.1f}%" + except (ValueError, TypeError): + record.formatted_value = record.current_value + else: + record.formatted_value = record.current_value + + @api.depends('current_value', 'metric_type', 'max_value') + def _compute_status(self): + """Compute the status of the metric based on predefined thresholds. + + This method evaluates the current value against thresholds to determine + if the metric is in a normal, warning, or critical state. The thresholds + vary by metric type: + + CPU/Memory Usage: + - Critical: >= 90% + - Warning: >= 75% + + Bandwidth Usage: + - Critical: >= 90% of max + - Warning: >= 75% of max + + Security Threats: + - Critical: >= 10 threats + - Warning: >= 5 threats + + WAN Status: + - Critical: not 'up' + + Device Status (format: 'online/total'): + - Critical: > 2 devices offline + - Warning: > 0 devices offline + + The status is used in the UI to highlight metrics that need attention, + using color-coded badges (green/yellow/red). + """ + for record in self: + status = 'normal' + + try: + if record.metric_type in ['cpu_usage', 'memory_usage']: + value = float(record.current_value) + if value >= 90: + status = 'critical' + elif value >= 75: + status = 'warning' + elif record.metric_type == 'bandwidth_usage': + if record.max_value: + value = float(record.current_value) + max_val = float(record.max_value) + usage_pct = (value / max_val) * 100 + if usage_pct >= 90: + status = 'critical' + elif usage_pct >= 75: + status = 'warning' + elif record.metric_type == 'threat_count': + value = int(record.current_value) + if value >= 10: + status = 'critical' + elif value >= 5: + status = 'warning' + elif record.metric_type == 'wan_status': + if record.current_value != 'up': + status = 'critical' + elif record.metric_type == 'device_status': + if '/' in record.current_value: + online, total = map(int, record.current_value.split('/')) + offline = total - online + if offline > 2: + status = 'critical' + elif offline > 0: + status = 'warning' + except (ValueError, TypeError): + # If we can't parse the value, assume normal status + status = 'normal' + + record.status = status + +class UdmDashboardStat(models.Model): + """Model for storing and analyzing historical UDM Pro statistics. + + This model stores historical statistical data from UDM Pro devices for + long-term trend analysis and reporting. It supports both raw data points + and aggregated statistics (e.g., daily averages, totals). + + Key features: + - Multiple statistic types (bandwidth, clients, threats, uptime) + - Support for different units of measurement + - Time-based statistics with start/end times + - Aggregation capabilities (sum, average, min, max) + - Built-in graphing support + + Statistics are ordered by date (descending) to show most recent data first. + Raw and aggregated statistics are stored separately to maintain data + integrity while allowing flexible reporting. + """ + _name = 'udm.dashboard.stat' + _description = 'UDM Dashboard Statistic' + _order = 'date desc' # Most recent statistics first + + # Site this statistic belongs to, cascade deletion if site is deleted + site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade') + + # Date of the statistic record + date = fields.Date(string='Date', required=True, default=fields.Date.today, + help="Date this statistic was recorded") + + # Type of statistic being tracked + stat_type = fields.Selection([ + ('bandwidth_usage', 'Bandwidth Usage'), # Total bandwidth used + ('client_count', 'Client Count'), # Number of clients over time + ('threat_blocked', 'Threats Blocked'), # Number of security threats blocked + ('device_uptime', 'Device Uptime'), # Device uptime duration + ], string='Statistic Type', required=True, + help="Type of statistical data being recorded") + + # Numerical value and its unit + value = fields.Float(string='Value', required=True, + help="Numerical value of the statistic") + unit = fields.Selection([ + ('bytes', 'Bytes'), # For bandwidth measurements + ('count', 'Count'), # For counting items (clients, threats) + ('percentage', 'Percentage'), # For utilization metrics + ('hours', 'Hours'), # For time-based metrics + ], string='Unit', required=True, + help="Unit of measurement for the value") + + # Time range for detailed statistics + time_start = fields.Datetime(string='Start Time', + help="Start time for time-based statistics (e.g. hourly bandwidth usage)") + time_end = fields.Datetime(string='End Time', + help="End time for time-based statistics") + + # Aggregation fields + is_aggregate = fields.Boolean(string='Is Aggregate', default=False, + help="Indicates if this record represents aggregated data") + aggregate_type = fields.Selection([ + ('sum', 'Sum'), # Total over the period + ('avg', 'Average'), # Average over the period + ('min', 'Minimum'), # Minimum value in the period + ('max', 'Maximum'), # Maximum value in the period + ], string='Aggregate Type', + help="Type of aggregation used for this record") + + @api.model + def aggregate_stats(self, site_id, stat_type, start_date, end_date, aggregate_type='avg'): + """Aggregate statistics for a specific site and type over a date range. + + This method calculates aggregate values (sum, average, min, max) for + non-aggregated statistics within the specified date range. + + Args: + site_id (int): ID of the site to aggregate stats for + stat_type (str): Type of statistic to aggregate + start_date (date): Start date of the range (inclusive) + end_date (date): End date of the range (inclusive) + aggregate_type (str): Type of aggregation to perform + 'sum': Total of all values + 'avg': Average of all values + 'min': Minimum value + 'max': Maximum value + + Returns: + float: The aggregated value, or 0.0 if no stats found + """ + domain = [ + ('site_id', '=', site_id), + ('stat_type', '=', stat_type), + ('date', '>=', start_date), + ('date', '<=', end_date), + ('is_aggregate', '=', False) # Only aggregate raw statistics + ] + + stats = self.search(domain) + if not stats: + return 0.0 + + values = stats.mapped('value') + + if aggregate_type == 'sum': + return sum(values) + elif aggregate_type == 'avg': + return sum(values) / len(values) + elif aggregate_type == 'min': + return min(values) + elif aggregate_type == 'max': + return max(values) + else: + return 0.0 + + def action_view_graph(self): + """Open a graph view showing statistics over time. + + This method creates an action to display a line graph of the statistic + values over time. The graph shows raw (non-aggregated) values grouped + by day. + + The graph view is configured to: + - Show values on the y-axis + - Use a line graph for trend visualization + - Group data points by day on the x-axis + - Filter for the same site and statistic type + - Exclude aggregated records + + Returns: + dict: An action dictionary that Odoo uses to open the graph view + """ + self.ensure_one() + action = { + 'name': _('Statistics Graph'), + 'view_mode': 'graph', + 'res_model': 'udm.dashboard.stat', + 'type': 'ir.actions.act_window', + 'domain': [ + ('site_id', '=', self.site_id.id), + ('stat_type', '=', self.stat_type), + ('is_aggregate', '=', False) # Show only raw values + ], + 'context': { + 'graph_measure': 'value', # Y-axis measurement + 'graph_mode': 'line', # Line graph for trends + 'graph_groupbys': ['date:day'] # Group by day on X-axis + } + } + return action diff --git a/unifi_integration/models/udm_config.py b/unifi_integration/models/udm_config.py index 7cd2c8b..564a157 100644 --- a/unifi_integration/models/udm_config.py +++ b/unifi_integration/models/udm_config.py @@ -1,59 +1,60 @@ # -*- coding: utf-8 -*- -# Ces importations fonctionneront dans un environnement Odoo, même si votre IDE les signale comme non trouvées +# These imports will work in an Odoo environment, even if your IDE marks them as not found # pylint: disable=import-error from odoo import models, fields, api, _ from odoo.exceptions import UserError # pylint: enable=import-error -import logging + import json -from datetime import datetime -import random # Pour générer des données de test/démonstration +import logging +import random +import requests +import urllib3 +from datetime import datetime, timedelta +from requests.exceptions import RequestException _logger = logging.getLogger(__name__) class UdmSite(models.Model): - """Représente un site UniFi géré par un ou plusieurs UDM Pro""" + """Represents a UniFi site managed by one or more UDM Pro""" _name = 'udm.site' _description = 'UDM Site' _order = 'name' name = fields.Char(string='Name', required=True) - site_id = fields.Char(string='Site ID', help="Identifiant du site dans UniFi (généralement 'default' sauf si configuré autrement)", default='default') + site_id = fields.Char(string='Site ID', help="Site identifier in UniFi (always 'default')", default='default', readonly=True, required=True) description = fields.Text(string='Description') address = fields.Text(string='Physical Address') active = fields.Boolean(string='Active', default=True) # Relations configuration_ids = fields.One2many('udm.configuration', 'site_id', string='Configurations') - dashboard_ids = fields.One2many('udm.dashboard.metric', 'site_id', string='Dashboard Metrics') + dashboard_metric_ids = fields.One2many('udm.dashboard.metric', 'site_id', string='Dashboard Metrics') + dashboard_stat_ids = fields.One2many('udm.dashboard.stat', 'site_id', string='Statistics') - # Compteurs + # Counters config_count = fields.Integer(compute='_compute_counts', string='Configuration Count') device_count = fields.Integer(compute='_compute_device_count', string='Total Devices') client_count = fields.Integer(compute='_compute_client_count', string='Connected Clients') - @api.depends('configuration_ids') + @api.depends('network_ids') def _compute_counts(self): + """Compute the number of networks in this site""" for record in self: - record.config_count = len(record.configuration_ids) + record.config_count = len(record.network_ids) - @api.depends('configuration_ids.device_ids') + @api.depends('device_ids') def _compute_device_count(self): + """Compute the total number of devices in this site""" for record in self: - count = 0 - for config in record.configuration_ids: - count += len(config.device_ids) - record.device_count = count + record.device_count = len(record.device_ids) - @api.depends('dashboard_ids') + @api.depends('user_ids') def _compute_client_count(self): + """Compute the number of connected clients""" for record in self: - client_metric = record.dashboard_ids.filtered(lambda m: m.metric_type == 'clients_count') - if client_metric and len(client_metric) > 0: - record.client_count = int(client_metric[0].current_value) - else: - record.client_count = 0 + record.client_count = len(record.user_ids.filtered(lambda u: u.status == 'connected')) def action_view_configurations(self): self.ensure_one() @@ -76,15 +77,18 @@ class UdmSite(models.Model): } def action_refresh_metrics(self): - """Rafraîchit les métriques du tableau de bord pour ce site""" + """Refreshes the dashboard metrics and statistics for this site""" self.ensure_one() configs = self.configuration_ids.filtered(lambda c: c.active) if not configs: raise UserError(_('No active UDM Pro configuration found for this site')) - # Dans une implémentation réelle, vous appelleriez l'API UDM Pro ici - # Pour le moment, nous simulons les métriques - self._generate_sample_metrics() + # Call the UDM Pro API to get real metrics + try: + for config in configs: + config._fetch_metrics() + except Exception as e: + raise UserError(_('Failed to fetch metrics: %s') % str(e)) return { 'type': 'ir.actions.client', @@ -92,22 +96,22 @@ class UdmSite(models.Model): } def _generate_sample_metrics(self): - """Génère des métriques de démonstration pour le tableau de bord""" - # Supprimer les anciennes métriques - self.dashboard_ids.unlink() + """Generates demo metrics for the dashboard""" + # Delete old metrics + self.dashboard_metric_ids.unlink() - # Types de métriques à générer + # Types of metrics to generate metric_types = [ 'bandwidth_usage', 'cpu_usage', 'memory_usage', 'clients_count', 'wan_status', 'threat_count', 'device_status' ] - # Générer les nouvelles métriques + # Generate new metrics metrics_vals = [] now = fields.Datetime.now() for metric_type in metric_types: - # Générer la valeur actuelle + # Generate current value current_value = '' max_value = '' history = '' @@ -153,9 +157,9 @@ class UdmSite(models.Model): self.env['udm.dashboard.metric'].create(vals) def _generate_history_data(self, min_val, max_val, points, integer=False): - """Génère des données historiques pour les graphiques""" + """Generates historical data for graphs""" data = [] - for _ in range(points): # Utilisation de _ pour indiquer une variable non utilisée + for _ in range(points): # Using _ to indicate an unused variable if integer: value = random.randint(min_val, max_val) else: @@ -163,138 +167,456 @@ class UdmSite(models.Model): value = round(value, 2) data.append(value) return json.dumps(data) + + def _generate_sample_statistics(self): + """Generates demo statistics for historical data""" + # Delete old statistics + self.dashboard_stat_ids.unlink() + + # Types of statistics to generate + stat_types = [ + 'bandwidth_usage', # Total bandwidth used + 'client_count', # Number of clients over time + 'threat_blocked', # Number of security threats blocked + 'device_uptime' # Device uptime duration + ] + + # Generate statistics for the last 30 days + today = fields.Date.today() + start_date = today - timedelta(days=30) + + # Generate new statistics + stats_vals = [] + + for day in range(31): # 31 days of data + date = start_date + timedelta(days=day) + + for stat_type in stat_types: + # Generate value and unit based on type + stat_value = 0.0 + stat_unit = '' + + if stat_type == 'bandwidth_usage': + stat_value = random.uniform(100, 1000) # GB per day + stat_unit = 'bytes' + elif stat_type == 'client_count': + stat_value = random.randint(10, 100) + stat_unit = 'count' + elif stat_type == 'threat_blocked': + stat_value = random.randint(0, 20) + stat_unit = 'count' + elif stat_type == 'device_uptime': + stat_value = random.uniform(20, 24) # Hours per day + stat_unit = 'hours' + + # Add daily statistic + stats_vals.append({ + 'site_id': self.id, + 'date': date, + 'stat_type': stat_type, + 'value': stat_value, + 'unit': stat_unit, + 'is_aggregate': False + }) + + # Add weekly aggregate every 7 days + if day % 7 == 0: + stats_vals.append({ + 'site_id': self.id, + 'date': date, + 'stat_type': stat_type, + 'value': stat_value * 7, # Simple multiplication for demo + 'unit': stat_unit, + 'is_aggregate': True, + 'aggregate_type': 'sum', + 'time_start': fields.Datetime.to_datetime(date), + 'time_end': fields.Datetime.to_datetime(date + timedelta(days=7)) + }) + + # Create the statistics + for vals in stats_vals: + self.env['udm.dashboard.stat'].create(vals) -class UdmDashboardMetric(models.Model): - """Stocke les métriques pour le tableau de bord UDM Pro""" - _name = 'udm.dashboard.metric' - _description = 'UDM Dashboard Metric' - _order = 'last_update desc' +class UdmPortForward(models.Model): + """Port forwarding rules for the UDM Pro""" + _name = 'udm.port.forward' + _description = 'UDM Pro Port Forward Rule' - site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade') - metric_type = fields.Selection([ - ('bandwidth_usage', 'Bandwidth Usage'), - ('cpu_usage', 'CPU Usage'), - ('memory_usage', 'Memory Usage'), - ('clients_count', 'Connected Clients'), - ('wan_status', 'WAN Status'), - ('threat_count', 'Security Threats'), - ('device_status', 'Device Status'), - ], string='Metric Type', required=True) + name = fields.Char(string='Name', required=True) + enabled = fields.Boolean(string='Enabled', default=True) + src_port = fields.Char(string='Source Port') + dst_port = fields.Char(string='Destination Port') + protocol = fields.Selection([ + ('tcp', 'TCP'), + ('udp', 'UDP'), + ('both', 'TCP & UDP'), + ], string='Protocol', default='tcp') + dst_address = fields.Char(string='Destination Address') + raw_data = fields.Text(string='Raw Data') - current_value = fields.Char(string='Current Value', required=True) - max_value = fields.Char(string='Maximum Value') - history_data = fields.Text(string='Historical Data', help="Données JSON pour les graphiques d'historique") - last_update = fields.Datetime(string='Last Update', default=fields.Datetime.now) - - # Champs calculés pour l'affichage - formatted_value = fields.Char(compute='_compute_formatted_value', string='Formatted Value') - status = fields.Selection([ - ('normal', 'Normal'), - ('warning', 'Warning'), - ('critical', 'Critical'), - ], compute='_compute_status', string='Status') - - @api.depends('current_value', 'metric_type') - def _compute_formatted_value(self): - for record in self: - if record.metric_type == 'bandwidth_usage': - try: - value = float(record.current_value) - if value >= 1000: - record.formatted_value = f"{value/1000:.2f} Gbps" - else: - record.formatted_value = f"{value:.2f} Mbps" - except (ValueError, TypeError): - record.formatted_value = record.current_value - elif record.metric_type in ['cpu_usage', 'memory_usage']: - try: - value = float(record.current_value) - record.formatted_value = f"{value:.1f}%" - except (ValueError, TypeError): - record.formatted_value = record.current_value - elif record.metric_type == 'wan_status': - if record.current_value == 'up': - record.formatted_value = _('Online') - else: - record.formatted_value = _('Offline') - else: - record.formatted_value = record.current_value - - @api.depends('current_value', 'max_value', 'metric_type') - def _compute_status(self): - for record in self: - if record.metric_type in ['cpu_usage', 'memory_usage']: - try: - value = float(record.current_value) - if value > 90: - record.status = 'critical' - elif value > 70: - record.status = 'warning' - else: - record.status = 'normal' - except (ValueError, TypeError): - record.status = 'normal' - elif record.metric_type == 'bandwidth_usage': - try: - value = float(record.current_value) - max_value = float(record.max_value) if record.max_value else 1000 - if value > max_value * 0.9: - record.status = 'critical' - elif value > max_value * 0.7: - record.status = 'warning' - else: - record.status = 'normal' - except (ValueError, TypeError): - record.status = 'normal' - elif record.metric_type == 'threat_count': - try: - value = int(record.current_value) - if value > 5: - record.status = 'critical' - elif value > 0: - record.status = 'warning' - else: - record.status = 'normal' - except (ValueError, TypeError): - record.status = 'normal' - elif record.metric_type == 'wan_status': - if record.current_value == 'up': - record.status = 'normal' - else: - record.status = 'critical' - else: - record.status = 'normal' + # Relations + config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade', required=True) +class UdmDnsConfig(models.Model): + """DNS configuration for the UDM Pro""" + _name = 'udm.dns.config' + _description = 'UDM Pro DNS Configuration' + + enabled = fields.Boolean(string='Enabled', default=True) + filters_enabled = fields.Boolean(string='Content Filtering Enabled', default=False) + custom_dns = fields.Char(string='Custom DNS Servers') + raw_data = fields.Text(string='Raw Data') + + # Relations + config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade', required=True) + +class UdmRoutingConfig(models.Model): + """Routing configuration for the UDM Pro""" + _name = 'udm.routing.config' + _description = 'UDM Pro Routing Configuration' + + ospf_enabled = fields.Boolean(string='OSPF Enabled', default=False) + static_routes = fields.Text(string='Static Routes') + raw_data = fields.Text(string='Raw Data') + + # Relations + config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade', required=True) class UdmConfiguration(models.Model): - """Configuration complète d'un UDM Pro stockée dans Odoo""" + def _get_base_url(self): + """Get the base URL for the UDM Pro API""" + return f'https://{self.host}:{self.port}' + + def _get_api_url(self, endpoint): + """Get the full URL for a UDM Pro API endpoint + + This method handles the URL generation for different API endpoints. + Authentication and system endpoints don't need the /proxy/network prefix, + while all other endpoints require it. + + Args: + endpoint (str): API endpoint path (e.g. '/api/auth/login') + + Returns: + str: Complete URL for the API endpoint + """ + # Add /proxy/network prefix for all endpoints except auth + if not endpoint.startswith('/api/auth/'): + endpoint = f'/proxy/network{endpoint}' + + url = f'{self._get_base_url()}{endpoint}' + _logger.debug('Generated URL: %s', url) + return url + + def _get_api_headers(self, csrf_token=None): + """Get headers for UniFi API requests + + Generates the necessary headers for API requests to the UDM Pro. + All requests use JSON format and identify themselves as the Odoo UniFi Integration. + For authenticated endpoints, a CSRF token is included in the headers. + + Args: + csrf_token (str, optional): CSRF token required for authenticated requests + + Returns: + dict: Dictionary containing the required headers for the API request + """ + # Basic headers required for all requests + headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'Odoo UniFi Integration/1.0' + } + + # Add CSRF token for authenticated requests + if csrf_token: + headers['X-CSRF-Token'] = csrf_token + _logger.debug('Adding CSRF token to headers') + + _logger.debug('Generated API headers: %s', headers) + return headers + + def _login(self): + """Login to UniFi OS and get authentication cookie and CSRF token + + This method handles the authentication process with the UDM Pro: + 1. Validates login credentials + 2. Gets CSRF token + 3. Performs login with CSRF token + 4. Handles MFA if required + + Returns: + dict: A dictionary containing the session cookies and CSRF token + + Raises: + UserError: If authentication fails or MFA is required but not provided + """ + _logger.info('Starting connection to UDM Pro') + _logger.debug('Connection parameters - Host: %s, Port: %s, User: %s', + self.host, self.port, self.username) + + # Validate required credentials + if not self.host or not self.username or not self.password: + _logger.error('Missing connection information: host=%s, username=%s, password=%s', + bool(self.host), bool(self.username), bool(self.password)) + raise UserError(_('Please provide host, username and password')) + + # Disable SSL warnings - UDM Pro often uses self-signed certificates + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + # Create session and disable SSL verification + session = requests.Session() + session.verify = False + + try: + _logger.info('Attempting to connect to UniFi OS API...') + + # Prepare login data + login_data = { + 'username': self.username, + 'password': self.password, + 'rememberMe': True + } + + # Add MFA token if present + if self.mfa_token: + login_data['token'] = self.mfa_token + _logger.debug('Adding MFA token to login data') + + # Set required headers for UniFi OS + headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'Odoo UniFi Integration/1.0' + } + + _logger.debug('Connection details: %s', { + 'url': self._get_api_url('/api/auth/login'), + 'username': self.username, + 'host': self.host, + 'port': self.port + }) + + # Attempt login + response = session.post( + self._get_api_url('/api/auth/login'), + json=login_data, + headers=headers, + verify=False, + timeout=10 + ) + + # Check response status + response.raise_for_status() + + # Check if MFA is required + if response.status_code == 403 and 'x-factor-required' in response.headers: + _logger.info('Two-factor authentication required') + raise UserError(_('Please provide two-factor authentication code')) + + # Get CSRF token from response headers + csrf_token = response.headers.get('X-CSRF-Token', '') + + _logger.info('Successfully connected to UDM Pro') + _logger.debug('Received cookies: %s', dict(session.cookies)) + + # Return session and CSRF token + return { + 'session': session, + 'csrf_token': csrf_token + } + + except RequestException as e: + error_msg = str(e) + if hasattr(e, 'response') and e.response is not None: + error_msg = e.response.text + try: + error_data = json.loads(error_msg) + if e.response.status_code == 401: + error_message = error_data.get('error', {}).get('message', '') + raise UserError(_('Authentication failed: %s\n\nPlease check:\n1. Username and password are correct\n2. User exists in UniFi OS\n3. User has sufficient permissions') % error_message) + elif e.response.status_code == 403: + raise UserError(_('Access denied. Please verify your permissions.')) + except json.JSONDecodeError: + pass + _logger.error('Login failed: %s', error_msg) + raise UserError(_('Connection to UDM Pro failed: %s') % error_msg) + + def _fetch_metrics(self): + """Fetch real-time metrics from the UDM Pro + + Returns: + dict: System and network metrics + + Raises: + UserError: If connection fails or metrics are inaccessible + """ + try: + # Get authenticated session + auth = self._login() + if not auth: + raise UserError(_('Unable to authenticate with UDM Pro')) + + session = auth.get('session') + csrf_token = auth.get('csrf_token') + if not session or not csrf_token: + raise UserError(_('Missing session or CSRF token')) + + # Set headers for API requests + headers = self._get_api_headers(csrf_token) + + # Get system statistics + system_response = session.get( + self._get_api_url('/api/system/stats'), + headers=headers + ) + system_response.raise_for_status() + system_stats = system_response.json() + + # Get client information + clients_response = session.get( + self._get_api_url('/api/site/default/clients'), + headers=headers + ) + clients_response.raise_for_status() + clients = clients_response.json() + + # Get network statistics + network_response = session.get( + self._get_api_url('/api/site/default/devices'), + headers=headers + ) + network_response.raise_for_status() + network_stats = network_response.json() + + # Prepare metrics + metrics_vals = [ + { + 'site_id': self.site_id.id, + 'metric_type': 'cpu_usage', + 'current_value': str(system_stats.get('data', {}).get('cpu', {}).get('usage', 0)), + 'max_value': '100', + 'last_update': fields.Datetime.now(), + 'description': 'CPU Usage' + }, + { + 'site_id': self.site_id.id, + 'metric_type': 'memory_usage', + 'current_value': str(system_stats.get('data', {}).get('memory', {}).get('used_percentage', 0)), + 'max_value': '100', + 'last_update': fields.Datetime.now(), + 'description': 'Memory Usage' + }, + { + 'site_id': self.site_id.id, + 'metric_type': 'client_count', + 'current_value': str(len(clients.get('data', []))), + 'max_value': '1000', + 'last_update': fields.Datetime.now(), + 'description': 'Connected Clients' + }, + { + 'site_id': self.site_id.id, + 'metric_type': 'network_throughput', + 'current_value': str(sum(d.get('tx_bytes', 0) + d.get('rx_bytes', 0) + for d in network_stats.get('data', []))), + 'max_value': str(1e9), # 1 Gbps + 'last_update': fields.Datetime.now(), + 'description': 'Total Network Throughput' + } + ] + + # Get historical statistics + history_response = session.get( + self._get_api_url('/api/site/default/statistics/5minutes'), + headers=headers + ) + history_response.raise_for_status() + history_stats = history_response.json() + + # Create historical statistics + stats_vals = [] + now = fields.Datetime.now() + + for stat in history_stats.get('data', []): + # Get timestamp and ensure it's valid + try: + timestamp = fields.Datetime.from_string(stat.get('time')) or now + except (ValueError, TypeError): + timestamp = now + _logger.warning('Invalid timestamp detected in historical statistics') + + stats_vals.append({ + 'site_id': self.site_id.id, + 'timestamp': fields.Datetime.to_string(timestamp), + 'rx_bytes': stat.get('rx_bytes', 0), + 'tx_bytes': stat.get('tx_bytes', 0), + 'num_sta': stat.get('num_sta', 0), + 'cpu_usage': system_stats.get('data', {}).get('cpu', {}).get('usage', 0), + 'memory_usage': system_stats.get('data', {}).get('memory', {}).get('used_percentage', 0) + }) + + # Update metrics and statistics + self.env['udm.dashboard.metric'].search([('site_id', '=', self.site_id.id)]).unlink() + self.env['udm.dashboard.metric'].create(metrics_vals) + + self.env['udm.dashboard.stat'].search([('site_id', '=', self.site_id.id)]).unlink() + self.env['udm.dashboard.stat'].create(stats_vals) + + _logger.info('Metrics and statistics updated successfully') + + except RequestException as e: + _logger.error('Error fetching metrics: %s', str(e)) + raise UserError(_('Unable to fetch metrics from UDM Pro: %s') % str(e)) + """Complete UDM Pro configuration stored in Odoo""" _name = 'udm.configuration' _description = 'UDM Pro Configuration' _order = 'timestamp desc' name = fields.Char(string='Name', compute='_compute_name', store=True) timestamp = fields.Datetime(string='Timestamp', default=fields.Datetime.now, required=True) - raw_data = fields.Text(string='Raw Data', help="Les données brutes de configuration en format JSON") - active = fields.Boolean(string='Active', default=True, help="Indique si cette configuration est actuellement active") + raw_data = fields.Text(string='Raw Data', help="Raw configuration data in JSON format") + active = fields.Boolean(string='Active', default=True, help="Indicates if this configuration is currently active") - # Connexion à l'UDM Pro - host = fields.Char(string='Host', help="Adresse IP ou nom d'hôte de l'UDM Pro") + # UDM Pro Connection + host = fields.Char(string='Host', help="IP address or hostname of the UDM Pro") port = fields.Integer(string='Port', default=443) username = fields.Char(string='Username') password = fields.Char(string='Password') + mfa_token = fields.Char(string='MFA Code', help="Two-factor authentication code received by email") # Relations - site_id = fields.Many2one('udm.site', string='Site', ondelete='restrict') - system_info_id = fields.Many2one('udm.system.info', string='System Info', ondelete='cascade') - network_ids = fields.One2many('udm.network', 'config_id', string='Networks') - vlan_ids = fields.One2many('udm.vlan', 'config_id', string='VLANs') - device_ids = fields.One2many('udm.device', 'config_id', string='Devices') - user_ids = fields.One2many('udm.user', 'config_id', string='Users') - settings_id = fields.Many2one('udm.settings', string='Settings', ondelete='cascade') - firewall_rule_ids = fields.One2many('udm.firewall.rule', 'config_id', string='Firewall Rules') + site_id = fields.Many2one('udm.site', string='Site', required=True, + ondelete='restrict', + help='Site this configuration belongs to') + system_info_id = fields.Many2one('udm.system.info', string='System Info', + ondelete='cascade', + help='System information snapshot') + network_ids = fields.One2many('udm.network', 'site_id', string='Networks', + help='Networks in this site') + vlan_ids = fields.One2many('udm.vlan', 'site_id', string='VLANs', + help='VLANs in this site') + device_ids = fields.One2many('udm.device', 'site_id', string='Devices', + help='Devices in this site') + user_ids = fields.One2many('udm.user', 'site_id', string='Users', + help='Users in this site') + settings_id = fields.Many2one('udm.settings', string='Settings', + ondelete='cascade', + help='Site settings') + firewall_rule_ids = fields.One2many('udm.firewall.rule', 'site_id', + string='Firewall Rules', + help='Firewall rules for this site') + port_forward_ids = fields.One2many('udm.port.forward', 'site_id', + string='Port Forwards', + help='Port forwarding rules for this site') + dns_config_id = fields.Many2one('udm.dns.config', string='DNS Configuration', + ondelete='cascade', + help='DNS settings') + routing_config_id = fields.Many2one('udm.routing.config', + string='Routing Configuration', + ondelete='cascade', + help='Routing settings') - # Statistiques + # Statistics network_count = fields.Integer(compute='_compute_counts', string='Network Count') device_count = fields.Integer(compute='_compute_counts', string='Device Count') user_count = fields.Integer(compute='_compute_counts', string='User Count') @@ -316,60 +638,363 @@ class UdmConfiguration(models.Model): record.firewall_rule_count = len(record.firewall_rule_ids) def action_view_networks(self): + """Open the networks view filtered for this site + + Returns a window action to display the list of networks + associated with this site. + """ self.ensure_one() return { 'name': _('Networks'), 'view_mode': 'tree,form', 'res_model': 'udm.network', - 'domain': [('config_id', '=', self.id)], + 'domain': [('site_id', '=', self.site_id.id)], 'type': 'ir.actions.act_window', } def action_view_devices(self): + """Open the devices view filtered for this site + + Returns a window action to display the list of devices + associated with this site. + """ self.ensure_one() return { 'name': _('Devices'), 'view_mode': 'tree,form', 'res_model': 'udm.device', - 'domain': [('config_id', '=', self.id)], + 'domain': [('site_id', '=', self.site_id.id)], 'type': 'ir.actions.act_window', } def action_view_users(self): + """Open the users view filtered for this site + + Returns a window action to display the list of users + associated with this site. + """ self.ensure_one() return { 'name': _('Users'), 'view_mode': 'tree,form', 'res_model': 'udm.user', - 'domain': [('config_id', '=', self.id)], + 'domain': [('site_id', '=', self.site_id.id)], 'type': 'ir.actions.act_window', } def action_view_firewall_rules(self): + """Open the firewall rules view filtered for this site + + Returns a window action to display the list of firewall rules + associated with this site. + """ self.ensure_one() return { 'name': _('Firewall Rules'), 'view_mode': 'tree,form', 'res_model': 'udm.firewall.rule', - 'domain': [('config_id', '=', self.id)], + 'domain': [('site_id', '=', self.site_id.id)], 'type': 'ir.actions.act_window', } + def action_import_configuration(self): + """Import configuration from UDM Pro device. + + This method is called when the user clicks the Import button in the + import configuration wizard. It performs the following steps: + + 1. Validates that all required connection information is provided + 2. Connects to the UDM Pro device using the provided credentials + 3. If MFA is required, opens the MFA wizard + 4. Once authenticated, retrieves the current configuration from the device + 5. Imports the configuration into Odoo using import_configuration() + 6. Returns an action to view the imported configuration + + Returns: + dict: An action to display the imported configuration or the MFA wizard + + Raises: + UserError: If any required connection information is missing + """ + self.ensure_one() + + # Validate that all required connection information is provided + if not all([self.host, self.port, self.username, self.password]): + raise UserError(_('Please provide host, username, password and port')) + + try: + # Try to connect + auth = self._login() + if not auth: + raise UserError(_('Unable to authenticate with UDM Pro')) + session = auth.get('session') + csrf_token = auth.get('csrf_token') + if not session or not csrf_token: + raise UserError(_('Missing session or CSRF token')) + except UserError as e: + if 'two-factor authentication' in str(e): + # Open MFA wizard + return { + 'name': _('Two-Factor Authentication Code'), + 'type': 'ir.actions.act_window', + 'res_model': 'udm.mfa.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_config_id': self.id + } + } + raise + + try: + config_data = {} + + # Get site context first + _logger.info('Getting site context...') + response = session.get( + self._get_api_url('/api/s/default/self'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + if response.status_code != 200: + _logger.error('Failed to get site context: %s', response.text) + raise UserError(_('Failed to access site. Please check your credentials.')) + + # Fetch system info + _logger.info('Retrieving system information...') + response = session.get( + self._get_api_url('/api/s/default/stat/sysinfo'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + + system_info = response.json() + _logger.info('System data received: %s', system_info) + + system_info_data = system_info.get('data') + if isinstance(system_info_data, list): + # Si les données sont dans une liste, prenons le premier élément + system_info_data = system_info_data[0] if system_info_data else {} + elif not isinstance(system_info_data, dict): + system_info_data = {} + + if not system_info_data: + _logger.error('No system data received in response') + raise UserError(_('Failed to retrieve system information from UDM Pro')) + config_data['system_info'] = system_info_data + + # Fetch networks + _logger.info('Retrieving networks...') + response = session.get( + self._get_api_url('/api/s/default/rest/setting/network'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + networks = response.json() + networks_data = networks.get('data') + if isinstance(networks_data, list): + # Handle list format + config_data['networks'] = {'networks': networks_data} + elif isinstance(networks_data, dict): + # Handle dictionary format + config_data['networks'] = {'networks': [networks_data]} + else: + _logger.error('No network data received in response') + raise UserError(_('Failed to retrieve networks from UDM Pro')) + + # Fetch VLANs + _logger.info('Retrieving VLANs...') + response = session.get( + self._get_api_url('/api/s/default/rest/setting/network'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + vlans = response.json() + vlans_data = vlans.get('data') + if isinstance(vlans_data, list): + # Handle list format + config_data['vlans'] = {'vlans': vlans_data} + elif isinstance(vlans_data, dict): + # Handle dictionary format + config_data['vlans'] = {'vlans': [vlans_data]} + else: + _logger.error('No VLAN data received in response') + raise UserError(_('Failed to retrieve VLANs from UDM Pro')) + + # Fetch devices + _logger.info('Retrieving devices...') + response = session.get( + self._get_api_url('/api/s/default/stat/device-basic'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + devices = response.json() + devices_data = devices.get('data') + if isinstance(devices_data, list): + # Handle list format + config_data['devices'] = {'devices': devices_data} + elif isinstance(devices_data, dict): + # Handle dictionary format + config_data['devices'] = {'devices': [devices_data]} + else: + _logger.error('No device data received in response') + raise UserError(_('Failed to retrieve devices from UDM Pro')) + + # Fetch users + _logger.info('Retrieving users...') + response = session.get( + self._get_api_url('/api/s/default/rest/user'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + users = response.json() + users_data = users.get('data') + if isinstance(users_data, list): + # Handle list format + config_data['users'] = {'users': users_data} + elif isinstance(users_data, dict): + # Handle dictionary format + config_data['users'] = {'users': [users_data]} + else: + _logger.error('No user data received in response') + raise UserError(_('Failed to retrieve users from UDM Pro')) + + # Fetch settings + _logger.info('Retrieving settings...') + response = session.get( + self._get_api_url('/api/s/default/rest/setting'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + settings = response.json() + settings_data = settings.get('data') + if isinstance(settings_data, list): + # Handle list format + config_data['settings'] = {'settings': settings_data} + elif isinstance(settings_data, dict): + # Handle dictionary format + config_data['settings'] = {'settings': [settings_data]} + else: + _logger.error('No settings data received in response') + raise UserError(_('Failed to retrieve settings from UDM Pro')) + + # Fetch firewall rules + _logger.info('Retrieving firewall rules...') + response = session.get( + self._get_api_url('/api/s/default/rest/firewallrule'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + firewall_rules = response.json() + firewall_rules_data = firewall_rules.get('data') + if isinstance(firewall_rules_data, list): + # Handle list format + config_data['firewall_rules'] = {'rules': firewall_rules_data} + elif isinstance(firewall_rules_data, dict): + # Handle dictionary format + config_data['firewall_rules'] = {'rules': [firewall_rules_data]} + else: + _logger.error('No firewall rule data received in response') + raise UserError(_('Failed to retrieve firewall rules from UDM Pro')) + + # Fetch port forwarding rules + _logger.info('Retrieving port forwarding rules...') + response = session.get( + self._get_api_url('/api/s/default/rest/portforward'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + port_forwards = response.json() + port_forwards_data = port_forwards.get('data') + if isinstance(port_forwards_data, list): + # Handle list format + config_data['port_forwards'] = {'port_forwards': port_forwards_data} + elif isinstance(port_forwards_data, dict): + # Handle dictionary format + config_data['port_forwards'] = {'port_forwards': [port_forwards_data]} + else: + _logger.error('No port forwarding data received in response') + raise UserError(_('Failed to retrieve port forwarding rules from UDM Pro')) + + # Fetch DNS configuration + _logger.info('Retrieving DNS configuration...') + response = session.get( + self._get_api_url('/api/s/default/rest/setting/dns'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + dns_config = response.json() + dns_config_data = dns_config.get('data') + if isinstance(dns_config_data, list): + # Handle list format + config_data['dns_config'] = {'dns_config': dns_config_data} + elif isinstance(dns_config_data, dict): + # Handle dictionary format + config_data['dns_config'] = {'dns_config': [dns_config_data]} + else: + _logger.error('No DNS configuration data received in response') + raise UserError(_('Failed to retrieve DNS configuration from UDM Pro')) + + # Fetch routing configuration + _logger.info('Retrieving routing configuration...') + response = session.get( + self._get_api_url('/api/s/default/stat/routing'), + headers=self._get_api_headers(csrf_token), + verify=False + ) + _logger.info('API response (status: %s): %s', response.status_code, response.text) + routing_config = response.json() + routing_config_data = routing_config.get('data') + if isinstance(routing_config_data, list): + # Handle list format + config_data['routing'] = {'routing': routing_config_data} + elif isinstance(routing_config_data, dict): + # Handle dictionary format + config_data['routing'] = {'routing': [routing_config_data]} + else: + _logger.error('No routing configuration data received in response') + raise UserError(_('Failed to retrieve routing configuration from UDM Pro')) + + except RequestException as e: + raise UserError(_('Failed to retrieve configuration from UDM Pro: %s') % str(e)) + + # Import the configuration using the model's import method + config = self.import_configuration(config_data) + + # Return an action to display the imported configuration + return { + 'type': 'ir.actions.act_window', + 'name': _('Configuration importée'), + 'res_model': 'udm.configuration', + 'res_id': config.id, + 'view_mode': 'form', + 'target': 'current' + } + @api.model def import_configuration(self, config_data): """ - Importe une configuration UDM Pro complète dans Odoo + Imports a complete UDM Pro configuration into Odoo Args: - config_data (dict): Données de configuration brutes de l'API + config_data (dict): Raw configuration data from the API Returns: - int: ID de la configuration créée + int: ID of the created configuration """ if not config_data: - raise UserError(_("No configuration data provided")) + raise UserError(_("Please provide configuration data")) - # Créer la configuration principale + # Create the main configuration vals = { 'timestamp': datetime.now(), 'raw_data': json.dumps(config_data, indent=2, ensure_ascii=False), @@ -377,29 +1002,36 @@ class UdmConfiguration(models.Model): config = self.create(vals) - # Créer les informations système - system_info_data = config_data.get('system_info', {}) + # Create system information + system_info_data = config_data.get('system_info', {}).get('system_info') + if isinstance(system_info_data, list): + # Si les données sont dans une liste, prenons le premier élément + system_info_data = system_info_data[0] if system_info_data else {} + elif not isinstance(system_info_data, dict): + system_info_data = {} + if system_info_data: system_info = self.env['udm.system.info'].create({ - 'config_id': config.id, + 'site_id': config.site_id.id, 'hostname': system_info_data.get('hostname', ''), 'version': system_info_data.get('version', ''), 'model': system_info_data.get('model', ''), 'uptime': system_info_data.get('uptime', 0), 'serial': system_info_data.get('serialNumber', ''), - 'mac_address': system_info_data.get('macAddress', ''), + 'mac_address': system_info_data.get('mac', '') or system_info_data.get('macAddress', ''), 'raw_data': json.dumps(system_info_data, indent=2, ensure_ascii=False), }) config.system_info_id = system_info.id - # Créer les réseaux + # Create networks networks_data = config_data.get('networks', {}).get('networks', []) for network_data in networks_data: if isinstance(network_data, dict): + # Create network with all available fields self.env['udm.network'].create({ - 'config_id': config.id, + 'site_id': config.site_id.id, 'name': network_data.get('name', ''), - 'purpose': network_data.get('purpose', ''), + 'purpose': network_data.get('purpose', 'corporate'), 'subnet': network_data.get('subnet', ''), 'vlan_id_number': network_data.get('vlanId'), 'dhcp_enabled': network_data.get('dhcpEnabled', False), @@ -409,63 +1041,163 @@ class UdmConfiguration(models.Model): 'raw_data': json.dumps(network_data, indent=2, ensure_ascii=False), }) - # Créer les VLANs + # Create VLANs vlans_data = config_data.get('networks', {}).get('vlans', []) for vlan_data in vlans_data: if isinstance(vlan_data, dict): + # Create VLAN with all available fields self.env['udm.vlan'].create({ - 'config_id': config.id, + 'site_id': config.site_id.id, 'vlan_id': vlan_data.get('id', 0), 'name': vlan_data.get('name', ''), - 'raw_data': json.dumps(vlan_data, indent=2, ensure_ascii=False), + 'enabled': vlan_data.get('enabled', True), + 'raw_data': json.dumps(vlan_data, indent=2, ensure_ascii=False) }) - # Créer les périphériques + # Create devices devices_data = config_data.get('devices', {}).get('devices', []) for device_data in devices_data: if isinstance(device_data, dict): + # Determine device type based on model or type + device_type = device_data.get('type', '') + if not device_type: + model = device_data.get('model', '').lower() + if 'uap' in model or 'ap' in model: + device_type = 'uap' + elif 'usw' in model or 'switch' in model: + device_type = 'usw' + elif 'ugw' in model or 'gateway' in model: + device_type = 'ugw' + elif 'udm' in model or 'dream' in model: + device_type = 'udm' + else: + device_type = 'client' + + # Create device with all available fields self.env['udm.device'].create({ - 'config_id': config.id, + 'site_id': config.site_id.id, 'name': device_data.get('name', ''), - 'mac': device_data.get('mac', ''), - 'ip': device_data.get('ip', ''), - 'device_type': device_data.get('type', ''), + 'mac_address': device_data.get('mac', ''), + 'ip_address': device_data.get('ip', ''), + 'device_type': device_type, 'model': device_data.get('model', ''), 'last_seen': datetime.fromtimestamp(device_data.get('lastSeen', 0)), 'raw_data': json.dumps(device_data, indent=2, ensure_ascii=False), }) - # Créer les utilisateurs + # Create users users_data = config_data.get('users', {}).get('users', []) for user_data in users_data: if isinstance(user_data, dict): self.env['udm.user'].create({ - 'config_id': config.id, + 'site_id': config.site_id.id, 'name': user_data.get('name', ''), 'email': user_data.get('email', ''), 'role': user_data.get('role', ''), 'enabled': user_data.get('enabled', True), + 'mac_address': user_data.get('mac', ''), + 'ip_address': user_data.get('ip', ''), + 'network_id': self.env['udm.network'].search([('site_id', '=', config.site_id.id), ('name', '=', user_data.get('network', ''))]).id, + 'last_seen': datetime.fromtimestamp(user_data.get('lastSeen', 0)), 'raw_data': json.dumps(user_data, indent=2, ensure_ascii=False), }) - # Créer les paramètres + # Create settings settings_data = config_data.get('settings', {}) if settings_data: + # Extract system settings + system_settings = settings_data.get('system', {}) + dns_settings = settings_data.get('dns', {}) + services = settings_data.get('services', {}) + + # Create settings record with all available fields settings = self.env['udm.settings'].create({ - 'config_id': config.id, - 'timezone': settings_data.get('timezone', ''), - 'ntp_servers': ','.join(settings_data.get('ntpServers', [])), - 'dns_servers': ','.join(settings_data.get('dnsServers', [])), + 'site_id': config.site_id.id, + 'timezone': system_settings.get('timezone', 'America/Montreal'), + + # Time settings + 'ntp_enabled': system_settings.get('ntp', {}).get('enabled', True), + 'ntp_servers': ','.join(system_settings.get('ntp', {}).get('servers', [])), + + # DNS settings + 'dns_enabled': dns_settings.get('enabled', True), + 'dns_servers': ','.join(dns_settings.get('servers', [])), + 'dns_forwarding': dns_settings.get('forwarding', {}).get('enabled', True), + + # Advanced settings + 'upnp_enabled': services.get('upnp', {}).get('enabled', False), + 'mdns_enabled': services.get('mdns', {}).get('enabled', True), + 'igmp_proxy': services.get('igmp_proxy', {}).get('enabled', False), + + # Raw data for debugging and future reference 'raw_data': json.dumps(settings_data, indent=2, ensure_ascii=False), }) config.settings_id = settings.id + + # Create port forwarding rules + port_forward_data = config_data.get('port_forwards', {}).get('data', []) + for rule_data in port_forward_data: + if isinstance(rule_data, dict): + self.env['udm.port.forward'].create({ + 'site_id': config.site_id.id, + 'name': rule_data.get('name', ''), + 'enabled': rule_data.get('enabled', True), + 'src_port': str(rule_data.get('fwd', '')), + 'dst_port': str(rule_data.get('port', '')), + 'protocol': rule_data.get('proto', '').lower(), + 'dst_address': rule_data.get('dst', ''), + 'raw_data': json.dumps(rule_data, indent=2, ensure_ascii=False), + }) - # Créer les règles de pare-feu - firewall_data = config_data.get('firewall', {}).get('rules', []) + # Create DNS configuration + dns_config_data = config_data.get('dns_config', {}).get('data', {}) + if dns_config_data: + dns_config = self.env['udm.dns.config'].create({ + 'site_id': config.site_id.id, # Lier directement au site + 'enabled': dns_config_data.get('system', {}).get('unifi', {}).get('enabled', True), + 'filters_enabled': dns_config_data.get('system', {}).get('unifi', {}).get('content_filtering_enabled', False), + 'custom_dns': ','.join([str(server) for server in dns_config_data.get('system', {}).get('nameservers', [])]), + 'raw_data': json.dumps(dns_config_data, indent=2, ensure_ascii=False), + }) + config.dns_config_id = dns_config.id + + # Create firewall rules + firewall_rules_data = config_data.get('firewall', {}).get('rules', []) + for rule_data in firewall_rules_data: + if isinstance(rule_data, dict): + # Create firewall rule with all available fields + self.env['udm.firewall.rule'].create({ + 'site_id': config.site_id.id, # Lier directement au site + 'name': rule_data.get('name', ''), + 'description': rule_data.get('description', ''), + 'enabled': rule_data.get('enabled', True), + 'sequence': rule_data.get('sequence', 10), + 'action': rule_data.get('action', 'drop').lower(), + 'protocol': rule_data.get('protocol', 'all').lower(), + 'source': rule_data.get('source', ''), + 'destination': rule_data.get('destination', ''), + 'src_port': rule_data.get('src_port', ''), + 'dst_port': rule_data.get('dst_port', ''), + 'raw_data': json.dumps(rule_data, indent=2, ensure_ascii=False) + }) + + # Create routing configuration + routing_config_data = config_data.get('routing', {}).get('data', {}) + if routing_config_data: + routing_config = self.env['udm.routing.config'].create({ + 'site_id': config.site_id.id, # Lier directement au site + 'ospf_enabled': routing_config_data.get('ospf', {}).get('enabled', False), + 'static_routes': ','.join([f"{route.get('network')}/{route.get('prefix')} via {route.get('nexthop')}" for route in routing_config_data.get('static_routes', [])]), + 'raw_data': json.dumps(routing_config_data, indent=2, ensure_ascii=False), + }) + config.routing_config_id = routing_config.id + + # Create firewall rules + firewall_data = config_data.get('firewall_rules', {}).get('rules', []) for rule_data in firewall_data: if isinstance(rule_data, dict): self.env['udm.firewall.rule'].create({ - 'config_id': config.id, + 'site_id': config.site_id.id, 'name': rule_data.get('name', ''), 'description': rule_data.get('description', ''), 'action': rule_data.get('action', 'drop'), @@ -476,13 +1208,13 @@ class UdmConfiguration(models.Model): 'raw_data': json.dumps(rule_data, indent=2, ensure_ascii=False), }) - # Journaliser l'importation réussie + # Log successful import _logger.info('Successfully imported UDM Pro configuration: %s', config.name) - return config.id + return config def action_compare_with(self): - """Ouvre un assistant pour comparer cette configuration avec une autre""" + """Opens a wizard to compare this configuration with another""" self.ensure_one() return { 'name': _('Compare Configurations'), @@ -496,13 +1228,13 @@ class UdmConfiguration(models.Model): } def action_duplicate(self): - """Duplique cette configuration""" + """Duplicates this configuration""" self.ensure_one() - # Créer une nouvelle configuration en copiant les données brutes + # Create a new configuration by copying the raw data new_config = self.copy({ 'timestamp': datetime.now(), - 'name': _('%s (Copy)') % self.name, + 'name': _('%s (Duplicate)') % self.name, }) return { @@ -514,15 +1246,21 @@ class UdmConfiguration(models.Model): } def action_generate_report(self): - """Génère un rapport PDF de cette configuration""" + """Generates a PDF report of this configuration""" self.ensure_one() - return self.env.ref('udm_pro_docs.action_report_udm_configuration').report_action(self) + return { + 'type': 'ir.actions.report', + 'report_name': 'unifi_integration.report_udm_configuration', + 'report_type': 'qweb-pdf', + 'res_model': self._name, + 'res_id': self.id, + } def action_view_dashboard(self): - """Affiche le tableau de bord pour le site de cette configuration""" + """Displays the dashboard for this configuration's site""" self.ensure_one() if not self.site_id: - raise UserError(_('This configuration is not associated with any site. Please set a site first.')) + raise UserError(_('Configuration not linked to a site. Please select a site first.')) return { 'name': _('Site Dashboard'), @@ -533,10 +1271,34 @@ class UdmConfiguration(models.Model): } def action_update_dashboard_metrics(self): - """Met à jour les métriques du tableau de bord pour le site de cette configuration""" + """Updates the dashboard metrics for this configuration's site""" self.ensure_one() if not self.site_id: - raise UserError(_('This configuration is not associated with any site. Please set a site first.')) + raise UserError(_('Configuration not linked to a site. Please select a site first.')) - # Appeler la méthode de rafraîchissement des métriques du site + # Call the site's metric refresh method return self.site_id.action_refresh_metrics() + + def unlink(self): + """Override unlink method to handle configuration deletion properly""" + for record in self: + # Suppression des enregistrements liés avec ondelete='cascade' + if record.system_info_id: + record.system_info_id.unlink() + if record.settings_id: + record.settings_id.unlink() + if record.dns_config_id: + record.dns_config_id.unlink() + if record.routing_config_id: + record.routing_config_id.unlink() + + # Suppression des enregistrements One2many + record.network_ids.unlink() + record.vlan_ids.unlink() + record.device_ids.unlink() + record.user_ids.unlink() + record.firewall_rule_ids.unlink() + record.port_forward_ids.unlink() + + # Appel de la méthode unlink standard + return super(UdmConfiguration, self).unlink() diff --git a/unifi_integration/models/udm_dashboard_data_point.py b/unifi_integration/models/udm_dashboard_data_point.py new file mode 100644 index 0000000..ca14dd4 --- /dev/null +++ b/unifi_integration/models/udm_dashboard_data_point.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + +class UdmDashboardDataPoint(models.Model): + """Data point for dashboard statistics""" + _name = 'udm.dashboard.data.point' + _description = 'Dashboard Data Point' + _order = 'sequence' + + # Reference to the statistic this data point belongs to + stat_id = fields.Many2one('udm.dashboard.stat', string='Statistic', + required=True, ondelete='cascade') + + # Data point attributes + sequence = fields.Integer(string='Sequence', default=10, + help='Order of this data point in the series') + value = fields.Float(string='Value', + help='Numeric value of this data point') + timestamp = fields.Datetime(string='Timestamp', + help='When this data point was recorded') diff --git a/unifi_integration/models/udm_device.py b/unifi_integration/models/udm_device.py index 44ee890..9024944 100644 --- a/unifi_integration/models/udm_device.py +++ b/unifi_integration/models/udm_device.py @@ -3,20 +3,44 @@ from odoo import models, fields, api, _ class UdmDevice(models.Model): - """Représentation d'un périphérique dans l'UDM Pro""" + """Represents a network device in the UniFi system + + This model stores information about devices connected to the network, + including both UniFi devices (APs, switches) and client devices. + + Devices are linked to a specific site and are automatically deleted when + the site is deleted (cascade). + """ _name = 'udm.device' - _description = 'UDM Pro Device' + _description = 'UniFi Device' + _order = 'last_seen desc' # Most recently seen devices first - config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade') - name = fields.Char(string='Name') - mac = fields.Char(string='MAC Address') - ip = fields.Char(string='IP Address') - device_type = fields.Char(string='Device Type') - model = fields.Char(string='Model') - last_seen = fields.Datetime(string='Last Seen') - raw_data = fields.Text(string='Raw Data') + site_id = fields.Many2one('udm.site', string='Site', required=True, + ondelete='cascade', + help='Site this device belongs to') + name = fields.Char(string='Name', + help='Device name or hostname') + mac_address = fields.Char(string='MAC Address', required=True, + help='Device MAC address') + ip_address = fields.Char(string='IP Address', + help='Current IP address') + device_type = fields.Selection([ + ('uap', 'Access Point'), + ('usw', 'Switch'), + ('ugw', 'Gateway'), + ('udm', 'Dream Machine'), + ('client', 'Client Device'), + ('other', 'Other') + ], string='Device Type', required=True, default='client', + help='Type of device') + model = fields.Char(string='Model', + help='Device model name') + last_seen = fields.Datetime(string='Last Seen', + help='Last time the device was seen online') + raw_data = fields.Text(string='Raw Data', + help='Raw device data in JSON format') - # Champs calculés + # Computed fields status = fields.Selection([ ('online', 'Online'), ('offline', 'Offline'), @@ -25,8 +49,13 @@ class UdmDevice(models.Model): @api.depends('last_seen') def _compute_status(self): - # Calcul du statut basé sur la dernière fois où le périphérique a été vu - # Ceci est un exemple simplifié + """Compute device online status based on last seen timestamp + + Status is determined by comparing the last_seen timestamp with current time: + - Online: seen within last 10 minutes + - Offline: not seen for more than 10 minutes + - Unknown: never seen (no last_seen timestamp) + """ for record in self: if not record.last_seen: record.status = 'unknown' diff --git a/unifi_integration/models/udm_dns.py b/unifi_integration/models/udm_dns.py new file mode 100644 index 0000000..f90bd29 --- /dev/null +++ b/unifi_integration/models/udm_dns.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + +class UdmDnsConfig(models.Model): + """Configuration DNS pour le système UniFi + + Ce modèle stocke la configuration DNS pour les contrôleurs UDM/UDR. + Il gère les paramètres DNS comme les serveurs personnalisés et le filtrage de contenu. + + La configuration DNS est liée à un site spécifique et est automatiquement supprimée + lorsque le site est supprimé (cascade). + """ + _name = 'udm.dns.config' + _description = 'UniFi DNS Configuration' + + site_id = fields.Many2one('udm.site', string='Site', required=True, + ondelete='cascade', + help='Site this DNS configuration belongs to') + enabled = fields.Boolean(string='Enabled', default=True, + help='Enable DNS server') + filters_enabled = fields.Boolean(string='Content Filtering Enabled', default=False, + help='Enable DNS content filtering') + custom_dns = fields.Char(string='Custom DNS Servers', + help='Comma-separated list of custom DNS servers') + raw_data = fields.Text(string='Raw Data', + help='Raw DNS configuration data in JSON format') diff --git a/unifi_integration/models/udm_dns_config.py b/unifi_integration/models/udm_dns_config.py new file mode 100644 index 0000000..4480be6 --- /dev/null +++ b/unifi_integration/models/udm_dns_config.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + +class UdmDnsConfig(models.Model): + """DNS configuration for the UDM Pro""" + _name = 'udm.dns.config' + _description = 'UDM Pro DNS Configuration' + + # Site this DNS configuration belongs to + site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade') + + # DNS settings + enabled = fields.Boolean(string='Enabled', default=True, + help='Whether DNS service is enabled') + filters_enabled = fields.Boolean(string='Content Filtering Enabled', default=False, + help='Enable content filtering on DNS queries') + custom_dns = fields.Char(string='Custom DNS Servers', + help='Comma-separated list of custom DNS servers') + raw_data = fields.Text(string='Raw Data', + help='Raw configuration data in JSON format') diff --git a/unifi_integration/models/udm_firewall.py b/unifi_integration/models/udm_firewall.py index 1062cbc..a166f09 100644 --- a/unifi_integration/models/udm_firewall.py +++ b/unifi_integration/models/udm_firewall.py @@ -3,35 +3,75 @@ from odoo import models, fields, api, _ class UdmFirewallRule(models.Model): - """Représentation d'une règle de pare-feu dans l'UDM Pro""" - _name = 'udm.firewall.rule' - _description = 'UDM Pro Firewall Rule' + """Represents a firewall rule in the UniFi system - config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade') - name = fields.Char(string='Name', required=True) - enabled = fields.Boolean(string='Enabled', default=True) + This model stores firewall rules that control network traffic. Each rule + specifies what traffic is allowed or blocked based on various criteria such + as source/destination addresses, ports, and protocols. + + Rules are linked to a specific site and are automatically deleted when + the site is deleted (cascade). + """ + _name = 'udm.firewall.rule' + _description = 'UniFi Firewall Rule' + _order = 'sequence, id' + + site_id = fields.Many2one('udm.site', string='Site', required=True, + ondelete='cascade', + help='Site this firewall rule belongs to') + name = fields.Char(string='Name', required=True, + help='Rule name') + description = fields.Text(string='Description', + help='Detailed description of the rule purpose') + enabled = fields.Boolean(string='Enabled', default=True, + help='Whether this rule is active') + sequence = fields.Integer(string='Sequence', default=10, + help='Order in which rules are evaluated') action = fields.Selection([ ('accept', 'Accept'), ('drop', 'Drop'), ('reject', 'Reject') - ], string='Action') + ], string='Action', required=True, default='drop', + help='Action to take when rule matches') protocol = fields.Selection([ ('tcp', 'TCP'), ('udp', 'UDP'), ('icmp', 'ICMP'), ('all', 'All') - ], string='Protocol') - src_address = fields.Char(string='Source Address') - dst_address = fields.Char(string='Destination Address') - src_port = fields.Char(string='Source Port') - dst_port = fields.Char(string='Destination Port') - raw_data = fields.Text(string='Raw Data') + ], string='Protocol', required=True, default='all', + help='Network protocol this rule applies to') + source = fields.Char(string='Source', + help='Source network or IP address in CIDR format') + destination = fields.Char(string='Destination', + help='Destination network or IP address in CIDR format') + src_address = fields.Char(string='Source Address', + help='Legacy field, use source instead') + dst_address = fields.Char(string='Destination Address', + help='Legacy field, use destination instead') + src_port = fields.Char(string='Source Port', + help='Source port number or range (e.g. 80 or 1024-2048)') + dst_port = fields.Char(string='Destination Port', + help='Destination port number or range (e.g. 80 or 1024-2048)') + raw_data = fields.Text(string='Raw Data', + help='Raw firewall rule data in JSON format') - # Champs calculés + # Computed fields rule_summary = fields.Char(string='Rule Summary', compute='_compute_rule_summary') @api.depends('action', 'protocol', 'src_address', 'dst_address', 'src_port', 'dst_port') def _compute_rule_summary(self): + """Compute a human-readable summary of the firewall rule + + This method generates a concise description of the rule's action, + protocol, and source/destination addresses and ports. The summary + is used in list views and reports to quickly understand what the + rule does without viewing all details. + + Example summaries: + - ACCEPT TCP from 192.168.1.0/24:80 to 10.0.0.0/8:443 + - DROP ICMP from 172.16.0.0/16 to any + - REJECT UDP from any to 192.168.2.10:53 + """ for record in self: parts = [] @@ -41,16 +81,18 @@ class UdmFirewallRule(models.Model): if record.protocol: parts.append(record.protocol.upper()) - if record.src_address: - src = f"from {record.src_address}" - if record.src_port: - src += f":{record.src_port}" - parts.append(src) + # Use new source/destination fields if available, fall back to legacy fields + src_addr = record.source or record.src_address or 'any' + dst_addr = record.destination or record.dst_address or 'any' - if record.dst_address: - dst = f"to {record.dst_address}" - if record.dst_port: - dst += f":{record.dst_port}" - parts.append(dst) + src = f"from {src_addr}" + if record.src_port: + src += f":{record.src_port}" + parts.append(src) - record.rule_summary = ' '.join(parts) if parts else 'No details' + dst = f"to {dst_addr}" + if record.dst_port: + dst += f":{record.dst_port}" + parts.append(dst) + + record.rule_summary = ' '.join(parts) diff --git a/unifi_integration/models/udm_network.py b/unifi_integration/models/udm_network.py index 1b5faa0..4411256 100644 --- a/unifi_integration/models/udm_network.py +++ b/unifi_integration/models/udm_network.py @@ -3,34 +3,64 @@ from odoo import models, fields, api, _ class UdmNetwork(models.Model): - """Représentation d'un réseau dans l'UDM Pro""" + """Represents a network in the UniFi system + + This model stores network configuration for both UDM/UDR and software controllers. + Each network can be associated with a VLAN and contains DHCP configuration. + + Networks are linked to a specific site and are automatically deleted when + the site is deleted (cascade). + """ _name = 'udm.network' - _description = 'UDM Pro Network' + _description = 'UniFi Network' - config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade') - name = fields.Char(string='Name', required=True) - purpose = fields.Char(string='Purpose') - subnet = fields.Char(string='Subnet') - vlan_id_number = fields.Integer(string='VLAN ID') - vlan_id = fields.Many2one('udm.vlan', string='VLAN', compute='_compute_vlan_id', store=True) - dhcp_enabled = fields.Boolean(string='DHCP Enabled', default=False) - dhcp_start = fields.Char(string='DHCP Start') - dhcp_stop = fields.Char(string='DHCP Stop') - domain_name = fields.Char(string='Domain Name') - raw_data = fields.Text(string='Raw Data') + site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade', + help='Site this network belongs to') + name = fields.Char(string='Name', required=True, + help='Network name') + purpose = fields.Selection([ + ('corporate', 'Corporate'), + ('guest', 'Guest'), + ('iot', 'IoT'), + ('other', 'Other') + ], string='Purpose', required=True, default='corporate', + help='Network purpose/type') + subnet = fields.Char(string='Subnet', required=True, + help='Network subnet in CIDR format (e.g. 192.168.1.0/24)') + vlan_id_number = fields.Integer(string='VLAN ID', + help='VLAN ID number (1-4094)') + vlan_id = fields.Many2one('udm.vlan', string='VLAN', + compute='_compute_vlan_id', store=True, + help='Associated VLAN configuration') + dhcp_enabled = fields.Boolean(string='DHCP Enabled', default=True, + help='Enable DHCP server for this network') + dhcp_start = fields.Char(string='DHCP Start', + help='Start of DHCP range') + dhcp_stop = fields.Char(string='DHCP Stop', + help='End of DHCP range') + domain_name = fields.Char(string='Domain Name', + help='Network domain name') + raw_data = fields.Text(string='Raw Data', + help='Raw network configuration data in JSON format') - # Statistiques + # Statistics device_count = fields.Integer(string='Device Count', compute='_compute_device_count') - @api.depends('vlan_id_number', 'config_id') + @api.depends('vlan_id_number', 'site_id') def _compute_vlan_id(self): + """Compute the associated VLAN based on the VLAN ID number + + This method searches for a VLAN with matching ID in the same site. + The VLAN ID is stored and updated automatically when either the + VLAN ID number or site changes. + """ for record in self: - if not record.vlan_id_number or not record.config_id: + if not record.vlan_id_number or not record.site_id: record.vlan_id = False continue vlan = self.env['udm.vlan'].search([ - ('config_id', '=', record.config_id.id), + ('site_id', '=', record.site_id.id), ('vlan_id', '=', record.vlan_id_number) ], limit=1) @@ -38,23 +68,36 @@ class UdmNetwork(models.Model): def _compute_device_count(self): for record in self: - # Cette fonction serait plus précise si les périphériques étaient liés aux réseaux - # Pour l'instant, c'est juste un exemple + # This function would be more accurate if devices were linked to networks + # For now, this is just an example record.device_count = 0 -class UdmVLAN(models.Model): - """Représentation d'un VLAN dans l'UDM Pro""" +class UdmVlan(models.Model): + """Represents a VLAN in the UniFi system + + This model stores VLAN configurations for both UDM/UDR and software controllers. + VLANs are used to segment network traffic and can be associated with multiple + networks. + + VLANs are linked to a specific site and are automatically deleted when + the site is deleted (cascade). + """ _name = 'udm.vlan' - _description = 'UDM Pro VLAN' + _description = 'UniFi VLAN' - config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade') - vlan_id = fields.Integer(string='VLAN ID', required=True) - name = fields.Char(string='Name', required=True) - enabled = fields.Boolean(string='Enabled', default=True) - raw_data = fields.Text(string='Raw Data') + site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade', + help='Site this VLAN belongs to') + vlan_id = fields.Integer(string='VLAN ID', required=True, + help='VLAN ID number (1-4094)') + name = fields.Char(string='Name', required=True, + help='VLAN name') + enabled = fields.Boolean(string='Enabled', default=True, + help='VLAN status') + raw_data = fields.Text(string='Raw Data', + help='Raw VLAN configuration data in JSON format') - # Relations inverses + # Reverse relations network_ids = fields.One2many('udm.network', 'vlan_id', string='Networks') network_count = fields.Integer(compute='_compute_network_count') diff --git a/unifi_integration/models/udm_port_forward.py b/unifi_integration/models/udm_port_forward.py new file mode 100644 index 0000000..dc793ce --- /dev/null +++ b/unifi_integration/models/udm_port_forward.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + +class UdmPortForward(models.Model): + """Represents a port forwarding rule in the UniFi system + + This model stores port forwarding rules that allow external access to + internal services. Each rule maps an external port to an internal + IP address and port. + + Port forwards are linked to a specific site and are automatically deleted + when the site is deleted (cascade). + """ + _name = 'udm.port.forward' + _description = 'UniFi Port Forward' + _order = 'sequence, id' + + site_id = fields.Many2one('udm.site', string='Site', required=True, + ondelete='cascade', + help='Site this port forward belongs to') + name = fields.Char(string='Name', required=True, + help='Rule name') + description = fields.Text(string='Description', + help='Detailed description of the port forward purpose') + enabled = fields.Boolean(string='Enabled', default=True, + help='Whether this rule is active') + sequence = fields.Integer(string='Sequence', default=10, + help='Order in which rules are evaluated') + protocol = fields.Selection([ + ('tcp', 'TCP'), + ('udp', 'UDP'), + ('tcp_udp', 'TCP & UDP') + ], string='Protocol', required=True, default='tcp', + help='Network protocol to forward') + source = fields.Char(string='Source', + help='Source network or IP address in CIDR format') + dst_port = fields.Char(string='Destination Port', required=True, + help='External port number or range (e.g. 80 or 1024-2048)') + fwd_ip = fields.Char(string='Forward IP', required=True, + help='Internal IP address to forward to') + fwd_port = fields.Char(string='Forward Port', required=True, + help='Internal port number or range') + raw_data = fields.Text(string='Raw Data', + help='Raw port forward data in JSON format') + + # Computed fields + rule_summary = fields.Char(string='Rule Summary', compute='_compute_rule_summary') + + @api.depends('protocol', 'source', 'dst_port', 'fwd_ip', 'fwd_port') + def _compute_rule_summary(self): + """Compute a human-readable summary of the port forward rule + + This method generates a concise description of the rule's protocol, + source, and port mappings. The summary is used in list views and + reports to quickly understand what the rule does without viewing + all details. + + Example summaries: + - TCP from any:80 to 192.168.1.100:8080 + - UDP from 203.0.113.0/24:53 to 192.168.2.10:53 + - TCP & UDP from any:25565 to 192.168.3.50:25565 + """ + for record in self: + parts = [] + + if record.protocol: + parts.append(record.protocol.upper()) + + src = f"from {record.source or 'any'}" + if record.dst_port: + src += f":{record.dst_port}" + parts.append(src) + + dst = f"to {record.fwd_ip}" + if record.fwd_port: + dst += f":{record.fwd_port}" + parts.append(dst) + + record.rule_summary = ' '.join(parts) diff --git a/unifi_integration/models/udm_routing.py b/unifi_integration/models/udm_routing.py new file mode 100644 index 0000000..bd3a8e8 --- /dev/null +++ b/unifi_integration/models/udm_routing.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + +class UdmRoutingConfig(models.Model): + """Routing configuration for the UniFi system + + This model stores routing configuration for both UDM/UDR controllers. + It manages routing settings such as OSPF and static routes. + + Routing configuration is linked to a specific site and is automatically deleted + when the site is deleted (cascade). + """ + _name = 'udm.routing.config' + _description = 'UniFi Routing Configuration' + + site_id = fields.Many2one('udm.site', string='Site', required=True, + ondelete='cascade', + help='Site this routing configuration belongs to') + ospf_enabled = fields.Boolean(string='OSPF Enabled', default=False, + help='Enable OSPF routing') + static_routes = fields.Text(string='Static Routes', + help='Comma-separated list of static routes in format: network/prefix via nexthop') + raw_data = fields.Text(string='Raw Data', + help='Raw routing configuration data in JSON format') diff --git a/unifi_integration/models/udm_routing_config.py b/unifi_integration/models/udm_routing_config.py new file mode 100644 index 0000000..5532947 --- /dev/null +++ b/unifi_integration/models/udm_routing_config.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ + +class UdmRoutingConfig(models.Model): + """Routing configuration for the UDM Pro""" + _name = 'udm.routing.config' + _description = 'UDM Pro Routing Configuration' + + # Site this routing configuration belongs to + site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade') + + # Routing settings + ospf_enabled = fields.Boolean(string='OSPF Enabled', default=False, + help='Enable OSPF routing protocol') + static_routes = fields.Text(string='Static Routes', + help='List of static routes in format: network/prefix via nexthop') + raw_data = fields.Text(string='Raw Data', + help='Raw configuration data in JSON format') diff --git a/unifi_integration/models/udm_settings.py b/unifi_integration/models/udm_settings.py index 1ab7d9f..c804eba 100644 --- a/unifi_integration/models/udm_settings.py +++ b/unifi_integration/models/udm_settings.py @@ -3,25 +3,90 @@ from odoo import models, fields, api, _ class UdmSettings(models.Model): - """Configuration générale de l'UDM Pro""" + """General configuration of the UDM Pro""" _name = 'udm.settings' _description = 'UDM Pro Settings' - config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade') - timezone = fields.Char(string='Timezone') - ntp_servers = fields.Char(string='NTP Servers') - dns_servers = fields.Char(string='DNS Servers') - raw_data = fields.Text(string='Raw Data') + # Basic fields + site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade', + help='Site these settings belong to') + name = fields.Char(string='Name', compute='_compute_name', store=True, + help='Settings name for display') - # Champs calculés - ntp_server_list = fields.Many2many('ir.model.data', string='NTP Server List', - compute='_compute_server_lists') + # Time settings + timezone = fields.Selection([ + ('UTC', 'UTC'), + ('America/Montreal', 'America/Montreal'), + ('America/New_York', 'America/New_York'), + ('America/Toronto', 'America/Toronto'), + ('Europe/Paris', 'Europe/Paris') + ], string='Timezone', default='America/Montreal', + help='Timezone for this site') + ntp_enabled = fields.Boolean(string='NTP Enabled', default=True, + help='Enable NTP synchronization') + ntp_servers = fields.Char(string='NTP Servers', + help='Comma-separated list of NTP servers') + + # DNS settings + dns_enabled = fields.Boolean(string='DNS Enabled', default=True, + help='Enable DNS service') + dns_servers = fields.Char(string='DNS Servers', + help='Comma-separated list of DNS servers') + dns_forwarding = fields.Boolean(string='DNS Forwarding', default=True, + help='Enable DNS forwarding') + + # Advanced settings + upnp_enabled = fields.Boolean(string='UPnP Enabled', default=False, + help='Enable Universal Plug and Play') + mdns_enabled = fields.Boolean(string='mDNS Enabled', default=True, + help='Enable multicast DNS') + igmp_proxy = fields.Boolean(string='IGMP Proxy', default=False, + help='Enable IGMP proxy') + + # Raw data + raw_data = fields.Text(string='Raw Data', + help='Raw settings data in JSON format') + + # Computed fields + ntp_server_list = fields.Many2many('ir.model.data', string='NTP Server List', + compute='_compute_server_lists', + help='List of NTP servers for display') dns_server_list = fields.Many2many('ir.model.data', string='DNS Server List', - compute='_compute_server_lists') + compute='_compute_server_lists', + help='List of DNS servers for display') + + @api.depends('site_id') + def _compute_name(self): + """Compute a display name for the settings + + The name is based on the site name and includes a timestamp to + differentiate between multiple settings records for the same site. + """ + for record in self: + if record.site_id: + record.name = f'{record.site_id.name} Settings' + else: + record.name = 'New Settings' @api.depends('ntp_servers', 'dns_servers') def _compute_server_lists(self): + """Convert comma-separated server lists to Many2many fields + + This method splits the NTP and DNS server strings into lists + for display in the user interface. The lists are stored in + technical fields that are not persisted to the database. + """ for record in self: - # Conversion des chaînes de caractères en listes pour l'affichage dans l'interface - record.ntp_server_list = False # Ceci est un champ technique pour l'UI - record.dns_server_list = False # Ceci est un champ technique pour l'UI + # Convert NTP servers string to list + if record.ntp_servers: + ntp_servers = [s.strip() for s in record.ntp_servers.split(',')] + record.ntp_server_list = [(6, 0, ntp_servers)] + else: + record.ntp_server_list = False + + # Convert DNS servers string to list + if record.dns_servers: + dns_servers = [s.strip() for s in record.dns_servers.split(',')] + record.dns_server_list = [(6, 0, dns_servers)] + else: + record.dns_server_list = False diff --git a/unifi_integration/models/udm_site.py b/unifi_integration/models/udm_site.py new file mode 100644 index 0000000..05451dc --- /dev/null +++ b/unifi_integration/models/udm_site.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import requests +import json +import logging +import random +from datetime import timedelta + +_logger = logging.getLogger(__name__) + +class UdmSite(models.Model): + """Represents a UniFi site managed by one or more UDM Pro + + This model is the central entity that groups all UniFi configurations and devices. + Each site can have multiple configurations, devices, networks, and users. + """ + _name = 'udm.site' + _description = 'UniFi Site' + _order = 'name' + _inherit = ['mail.thread'] + + # Basic site information + name = fields.Char(string='Name', required=True, tracking=True, + help='Site name') + site_id = fields.Char(string='Site ID', + help="Site identifier in UniFi (usually 'default')", + default='default', + readonly=True, + required=True) + description = fields.Text(string='Description', tracking=True, + help='Site description') + address = fields.Text(string='Physical Address', tracking=True, + help='Physical location of this site') + active = fields.Boolean(string='Active', default=True, tracking=True, + help='Indicates if this site is currently active') + + # Controller configuration + controller_type = fields.Selection([ + ('udm', 'UDM/UDR'), + ('software', 'Software Controller') + ], string='Controller Type', required=True, default='udm', tracking=True, + help='Type of UniFi controller managing this site') + + host = fields.Char(string='Host', required=True, tracking=True, + help='IP address or hostname of the controller') + port = fields.Integer(string='Port', default=443, required=True, + help='Port number (default: 443)') + username = fields.Char(string='Username', required=True, tracking=True) + password = fields.Char(string='Password', required=True) + mfa_token = fields.Char(string='MFA Token', + help='Two-factor authentication token if enabled') + + # Configuration data + timestamp = fields.Datetime(string='Last Update', tracking=True) + raw_data = fields.Text(string='Raw Data', + help='Raw configuration data in JSON format') + + # Related Records + configuration_ids = fields.One2many('udm.configuration', 'site_id', + string='Configurations', + help='Configurations for this site') + network_ids = fields.One2many('udm.network', 'site_id', + string='Networks', + help='Networks in this site') + vlan_ids = fields.One2many('udm.vlan', 'site_id', + string='VLANs', + help='VLANs in this site') + device_ids = fields.One2many('udm.device', 'site_id', + string='Devices', + help='Devices in this site') + user_ids = fields.One2many('udm.user', 'site_id', + string='Users', + help='Users in this site') + firewall_rule_ids = fields.One2many('udm.firewall.rule', 'site_id', + string='Firewall Rules', + help='Firewall rules for this site') + port_forward_ids = fields.One2many('udm.port.forward', 'site_id', + string='Port Forwards', + help='Port forwarding rules for this site') + dns_config_ids = fields.One2many('udm.dns.config', 'site_id', + string='DNS Configurations', + help='DNS configurations for this site') + routing_config_ids = fields.One2many('udm.routing.config', 'site_id', + string='Routing Configurations', + help='Routing configurations for this site') + + # Dashboard Metrics + dashboard_metric_ids = fields.One2many('udm.dashboard.metric', 'site_id', + string='Dashboard Metrics', + help='Real-time metrics for this site') + dashboard_stat_ids = fields.One2many('udm.dashboard.stat', 'site_id', + string='Statistics', + help='Historical statistics for this site') + + # Computed Fields + config_count = fields.Integer(compute='_compute_counts', + string='Configuration Count', + help='Number of configurations in this site') + device_count = fields.Integer(compute='_compute_device_count', + string='Total Devices', + help='Total number of devices in this site') + client_count = fields.Integer(compute='_compute_client_count', + string='Connected Clients', + help='Number of currently connected clients') + + @api.depends('configuration_ids') + def _compute_counts(self): + """Compute the number of configurations in this site""" + for site in self: + site.config_count = len(site.configuration_ids) + + @api.depends('device_ids') + def _compute_device_count(self): + """Compute the total number of devices in this site""" + for site in self: + site.device_count = len(site.device_ids) + + @api.depends('user_ids') + def _compute_client_count(self): + """Compute the number of connected clients""" + for site in self: + site.client_count = len(site.user_ids.filtered(lambda u: u.is_connected)) + + def action_view_configurations(self): + """Open the configurations view filtered for this site""" + self.ensure_one() + return { + 'name': _('Configurations'), + 'type': 'ir.actions.act_window', + 'res_model': 'udm.configuration', + 'view_mode': 'tree,form', + 'domain': [('site_id', '=', self.id)], + 'context': {'default_site_id': self.id}, + } + + def action_view_dashboard(self): + """Display the dashboard for this site""" + self.ensure_one() + return { + 'name': _('Site Dashboard'), + 'type': 'ir.actions.act_window', + 'res_model': 'udm.dashboard', + 'view_mode': 'kanban,form', + 'domain': [('site_id', '=', self.id)], + 'context': {'default_site_id': self.id}, + } + + def action_refresh_metrics(self): + """Refresh the dashboard metrics and statistics for this site""" + self.ensure_one() + for config in self.configuration_ids: + config.action_update_dashboard_metrics() + + _sql_constraints = [ + ('name_uniq', 'unique(name)', + 'Site name must be unique!'), + ] + + @api.model_create_multi + def create(self, vals_list): + """Override create to verify connection before saving""" + for vals in vals_list: + if not self._verify_connection(vals): + raise UserError(_('Could not connect to the UniFi controller. ' + 'Please verify your credentials and connection details.')) + return super(UdmSite, self).create(vals_list) + + def write(self, vals): + """Override write to verify connection if connection details change""" + if any(field in vals for field in ['host', 'port', 'username', + 'password', 'mfa_token']): + for record in self: + test_vals = {**record.copy_data()[0], **vals} + if not self._verify_connection(test_vals): + raise UserError(_('Could not connect to the UniFi controller. ' + 'Please verify your credentials and connection details.')) + return super(UdmSite, self).write(vals) + + def _verify_connection(self, vals): + """Verify connection to the UniFi controller + + Args: + vals (dict): Values to use for connection test + + Returns: + bool: True if connection successful, False otherwise + """ + try: + # Build base URL based on controller type + base_url = f"https://{vals['host']}:{vals.get('port', 443)}" + if vals.get('controller_type') == 'software': + base_url += '/api' + else: + base_url += '/proxy/network' + + # Disable SSL verification warning + import urllib3 + urllib3.disable_warnings() + + # Try to authenticate + session = requests.Session() + response = session.post( + f"{base_url}/api/auth/login", + json={ + 'username': vals['username'], + 'password': vals['password'], + 'remember': True, + 'token': vals.get('mfa_token', '') + }, + verify=False + ) + + if response.status_code == 200: + return True + + _logger.error( + "Authentication failed for UniFi controller at %s: %s", + vals['host'], + response.text + ) + return False + + except Exception as e: + _logger.error( + "Error connecting to UniFi controller at %s: %s", + vals['host'], + str(e) + ) + return False diff --git a/unifi_integration/models/udm_system_info.py b/unifi_integration/models/udm_system_info.py index 62bd6b0..9c6d5da 100644 --- a/unifi_integration/models/udm_system_info.py +++ b/unifi_integration/models/udm_system_info.py @@ -3,11 +3,12 @@ from odoo import models, fields, api, _ class UdmSystemInfo(models.Model): - """Informations système de l'UDM Pro""" + """System information of the UDM Pro""" _name = 'udm.system.info' _description = 'UDM Pro System Information' - config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade') + # Site this system information belongs to + site_id = fields.Many2one('udm.site', string='Site', required=True, ondelete='cascade') hostname = fields.Char(string='Hostname') version = fields.Char(string='Firmware Version') model = fields.Char(string='Model') diff --git a/unifi_integration/models/udm_user.py b/unifi_integration/models/udm_user.py index 9f419a8..8d83caa 100644 --- a/unifi_integration/models/udm_user.py +++ b/unifi_integration/models/udm_user.py @@ -1,23 +1,105 @@ # -*- coding: utf-8 -*- from odoo import models, fields, api, _ +from datetime import timedelta class UdmUser(models.Model): - """Représentation d'un utilisateur dans l'UDM Pro""" + """Represents a user in the UniFi system + + This model stores information about users who have access to the UniFi + network, including their roles and permissions. Users can be administrators, + operators, or read-only viewers. + + Users are linked to a specific site and are automatically deleted when + the site is deleted (cascade). + """ _name = 'udm.user' - _description = 'UDM Pro User' + _description = 'UniFi User' - config_id = fields.Many2one('udm.configuration', string='Configuration', ondelete='cascade') - name = fields.Char(string='Name', required=True) - email = fields.Char(string='Email') - role = fields.Char(string='Role') - enabled = fields.Boolean(string='Enabled', default=True) - raw_data = fields.Text(string='Raw Data') + # Basic fields + site_id = fields.Many2one('udm.site', string='Site', required=True, + ondelete='cascade', + help='Site this user belongs to') + name = fields.Char(string='Name', required=True, + help='User full name') + email = fields.Char(string='Email', + help='User email address') + mac_address = fields.Char(string='MAC Address', + help='Device MAC address') + ip_address = fields.Char(string='IP Address', + help='Current IP address') + network_id = fields.Many2one('udm.network', string='Network', + ondelete='set null', + help='Network this user is connected to') - # Champs calculés pour des statistiques ou filtrage - is_admin = fields.Boolean(string='Is Admin', compute='_compute_is_admin', store=True) + # Role and permissions + role = fields.Selection([ + ('admin', 'Administrator'), + ('operator', 'Operator'), + ('viewer', 'Viewer') + ], string='Role', required=True, default='viewer', + help='User role and permissions level') + enabled = fields.Boolean(string='Enabled', default=True, + help='Whether the user account is active') + + # Connection status + status = fields.Selection([ + ('connected', 'Connected'), + ('disconnected', 'Disconnected'), + ('idle', 'Idle') + ], string='Status', default='disconnected', + help='Current connection status of the user') + last_seen = fields.Datetime(string='Last Seen', + help='Last time the user was seen online') + + # Technical fields + raw_data = fields.Text(string='Raw Data', + help='Raw user data in JSON format') + + # Computed fields + is_admin = fields.Boolean(string='Is Admin', compute='_compute_is_admin', store=True, + help='Whether this user has administrator privileges') + is_connected = fields.Boolean(string='Is Connected', compute='_compute_is_connected', + store=True, help='Whether this user is currently connected') @api.depends('role') def _compute_is_admin(self): + """Compute whether the user has administrator privileges + + This is a convenience field that makes it easy to filter for or check + if a user has admin rights without parsing the role field. + """ for record in self: - record.is_admin = record.role and 'admin' in record.role.lower() or False + record.is_admin = record.role == 'admin' + + @api.depends('status') + def _compute_is_connected(self): + """Compute whether the user is currently connected + + This is a convenience field that makes it easy to filter for or check + if a user is currently connected without parsing the status field. + """ + for record in self: + record.is_connected = record.status == 'connected' + + def _update_status(self): + """Update the user's connection status based on last seen time + + This method is called periodically to update the user's status: + - If last seen within 5 minutes: Connected + - If last seen within 30 minutes: Idle + - Otherwise: Disconnected + """ + now = fields.Datetime.now() + for record in self: + if not record.last_seen: + record.status = 'disconnected' + continue + + delta = now - record.last_seen + if delta <= timedelta(minutes=5): + record.status = 'connected' + elif delta <= timedelta(minutes=30): + record.status = 'idle' + else: + record.status = 'disconnected' diff --git a/unifi_integration/security/ir.model.access.csv b/unifi_integration/security/ir.model.access.csv index 8490810..c9cee19 100644 --- a/unifi_integration/security/ir.model.access.csv +++ b/unifi_integration/security/ir.model.access.csv @@ -1,22 +1,33 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_udm_configuration_user,udm.configuration.user,model_udm_configuration,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_configuration_manager,udm.configuration.manager,model_udm_configuration,udm_pro_docs.group_udm_pro_manager,1,1,1,1 -access_udm_system_info_user,udm.system.info.user,model_udm_system_info,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_system_info_manager,udm.system.info.manager,model_udm_system_info,udm_pro_docs.group_udm_pro_manager,1,1,1,1 -access_udm_network_user,udm.network.user,model_udm_network,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_network_manager,udm.network.manager,model_udm_network,udm_pro_docs.group_udm_pro_manager,1,1,1,1 -access_udm_vlan_user,udm.vlan.user,model_udm_vlan,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_vlan_manager,udm.vlan.manager,model_udm_vlan,udm_pro_docs.group_udm_pro_manager,1,1,1,1 -access_udm_device_user,udm.device.user,model_udm_device,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_device_manager,udm.device.manager,model_udm_device,udm_pro_docs.group_udm_pro_manager,1,1,1,1 -access_udm_user_user,udm.user.user,model_udm_user,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_user_manager,udm.user.manager,model_udm_user,udm_pro_docs.group_udm_pro_manager,1,1,1,1 -access_udm_settings_user,udm.settings.user,model_udm_settings,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_settings_manager,udm.settings.manager,model_udm_settings,udm_pro_docs.group_udm_pro_manager,1,1,1,1 -access_udm_firewall_rule_user,udm.firewall.rule.user,model_udm_firewall_rule,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_firewall_rule_manager,udm.firewall.rule.manager,model_udm_firewall_rule,udm_pro_docs.group_udm_pro_manager,1,1,1,1 -# Nouveaux modèles pour le tableau de bord et la gestion multi-sites -access_udm_site_user,udm.site.user,model_udm_site,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_site_manager,udm.site.manager,model_udm_site,udm_pro_docs.group_udm_pro_manager,1,1,1,1 -access_udm_dashboard_metric_user,udm.dashboard.metric.user,model_udm_dashboard_metric,udm_pro_docs.group_udm_pro_user,1,0,0,0 -access_udm_dashboard_metric_manager,udm.dashboard.metric.manager,model_udm_dashboard_metric,udm_pro_docs.group_udm_pro_manager,1,1,1,1 +access_udm_site_user,udm.site.user,unifi_integration.model_udm_site,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_site_manager,udm.site.manager,unifi_integration.model_udm_site,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_configuration_user,udm.configuration.user,unifi_integration.model_udm_configuration,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_configuration_manager,udm.configuration.manager,unifi_integration.model_udm_configuration,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_settings_user,udm.settings.user,unifi_integration.model_udm_settings,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_settings_manager,udm.settings.manager,unifi_integration.model_udm_settings,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_system_info_user,udm.system.info.user,unifi_integration.model_udm_system_info,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_system_info_manager,udm.system.info.manager,unifi_integration.model_udm_system_info,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_network_user,udm.network.user,unifi_integration.model_udm_network,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_network_manager,udm.network.manager,unifi_integration.model_udm_network,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_vlan_user,udm.vlan.user,unifi_integration.model_udm_vlan,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_vlan_manager,udm.vlan.manager,unifi_integration.model_udm_vlan,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_device_user,udm.device.user,unifi_integration.model_udm_device,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_device_manager,udm.device.manager,unifi_integration.model_udm_device,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_user_user,udm.user.user,unifi_integration.model_udm_user,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_user_manager,udm.user.manager,unifi_integration.model_udm_user,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_firewall_rule_user,udm.firewall.rule.user,unifi_integration.model_udm_firewall_rule,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_firewall_rule_manager,udm.firewall.rule.manager,unifi_integration.model_udm_firewall_rule,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_dashboard_metric_user,udm.dashboard.metric.user,unifi_integration.model_udm_dashboard_metric,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_dashboard_metric_manager,udm.dashboard.metric.manager,unifi_integration.model_udm_dashboard_metric,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_dashboard_stat_user,udm.dashboard.stat.user,unifi_integration.model_udm_dashboard_stat,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_dashboard_stat_manager,udm.dashboard.stat.manager,unifi_integration.model_udm_dashboard_stat,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_site_import_wizard_user,udm.site.import.wizard.user,unifi_integration.model_udm_site_import_wizard,unifi_integration.group_udm_pro_user,1,1,1,0 +access_udm_site_import_wizard_manager,udm.site.import.wizard.manager,unifi_integration.model_udm_site_import_wizard,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_mfa_wizard_user,udm.mfa.wizard.user,unifi_integration.model_udm_mfa_wizard,unifi_integration.group_udm_pro_user,1,1,1,0 +access_udm_mfa_wizard_manager,udm.mfa.wizard.manager,unifi_integration.model_udm_mfa_wizard,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_port_forward_user,udm.port.forward.user,unifi_integration.model_udm_port_forward,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_port_forward_manager,udm.port.forward.manager,unifi_integration.model_udm_port_forward,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_dns_config_user,udm.dns.config.user,unifi_integration.model_udm_dns_config,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_dns_config_manager,udm.dns.config.manager,unifi_integration.model_udm_dns_config,unifi_integration.group_udm_pro_manager,1,1,1,1 +access_udm_routing_config_user,udm.routing.config.user,unifi_integration.model_udm_routing_config,unifi_integration.group_udm_pro_user,1,0,0,0 +access_udm_routing_config_manager,udm.routing.config.manager,unifi_integration.model_udm_routing_config,unifi_integration.group_udm_pro_manager,1,1,1,1 diff --git a/unifi_integration/security/udm_pro_security.xml b/unifi_integration/security/udm_pro_security.xml index 19b820b..67f7441 100644 --- a/unifi_integration/security/udm_pro_security.xml +++ b/unifi_integration/security/udm_pro_security.xml @@ -1,50 +1,56 @@ - + + - UDM Pro Documentation - Manage UDM Pro configurations and documentation + UDM Pro Integration + Manage UDM Pro configurations and network settings 50 - + + User - + - + + Manager - - + + - + + - + + UDM Pro Configuration User Access - + [(1, '=', 1)] - + - + + UDM Pro Configuration Manager Access - + [(1, '=', 1)] - + diff --git a/unifi_integration/static/description/icon.png b/unifi_integration/static/description/icon.png index 8b13789..61a96d8 100644 Binary files a/unifi_integration/static/description/icon.png and b/unifi_integration/static/description/icon.png differ diff --git a/unifi_integration/static/description/icon.svg b/unifi_integration/static/description/icon.svg new file mode 100644 index 0000000..c541e71 --- /dev/null +++ b/unifi_integration/static/description/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/unifi_integration/views/udm_config_views.xml b/unifi_integration/views/udm_config_views.xml index c401b07..686064c 100644 --- a/unifi_integration/views/udm_config_views.xml +++ b/unifi_integration/views/udm_config_views.xml @@ -122,7 +122,7 @@ - + @@ -172,7 +172,7 @@ - + @@ -208,7 +208,7 @@ - + @@ -245,7 +245,7 @@ - + @@ -280,7 +280,7 @@ - + @@ -313,7 +313,7 @@ - + @@ -340,7 +340,7 @@ - + @@ -379,7 +379,7 @@ - + diff --git a/unifi_integration/views/udm_configuration_views.xml b/unifi_integration/views/udm_configuration_views.xml index ab67f2e..37853be 100644 --- a/unifi_integration/views/udm_configuration_views.xml +++ b/unifi_integration/views/udm_configuration_views.xml @@ -1,11 +1,12 @@ - - - udm.configuration.tree + + + udm.configuration.list udm.configuration + list - + @@ -13,7 +14,7 @@ - + @@ -21,6 +22,7 @@ udm.configuration.form udm.configuration + form
@@ -38,13 +40,24 @@ +
+

- + + + + + + + + + @@ -53,27 +66,27 @@ - + - + - + - + - + @@ -81,23 +94,23 @@ - + - + - + - + @@ -105,7 +118,7 @@ - + @@ -121,6 +134,7 @@ udm.configuration.search udm.configuration + search @@ -135,4 +149,19 @@ + + + + UDM Pro Configurations + udm.configuration + list,form + +

+ No configurations found +

+

+ Configurations are automatically created when you refresh your UDM Pro settings. +

+
+
diff --git a/unifi_integration/views/udm_dashboard_metric_views.xml b/unifi_integration/views/udm_dashboard_metric_views.xml index 35510d8..66d1afe 100644 --- a/unifi_integration/views/udm_dashboard_metric_views.xml +++ b/unifi_integration/views/udm_dashboard_metric_views.xml @@ -1,18 +1,18 @@ - - - udm.dashboard.metric.tree + + + udm.dashboard.metric.list udm.dashboard.metric + list - - - - - - + - + + + + + @@ -20,21 +20,22 @@ udm.dashboard.metric.form udm.dashboard.metric + form - - - - + + + + - - + + - + @@ -46,16 +47,18 @@ udm.dashboard.metric.search udm.dashboard.metric + search - - + + - + + @@ -66,7 +69,7 @@ Dashboard Metrics udm.dashboard.metric - tree,form + list,form

No dashboard metrics found @@ -76,4 +79,88 @@

+ + + + udm.dashboard.stat.list + udm.dashboard.stat + list + + + + + + + + + + + + + + + + udm.dashboard.stat.form + udm.dashboard.stat + form + + + + + + + + + + + + + + + + + + + + + + + + + + udm.dashboard.stat.search + udm.dashboard.stat + search + + + + + + + + + + + + + + + + + + + + + Dashboard Statistics + udm.dashboard.stat + list,form + +

+ No dashboard statistics found +

+

+ Dashboard statistics are automatically created and updated when you refresh site metrics. +

+
+
diff --git a/unifi_integration/views/udm_device_views.xml b/unifi_integration/views/udm_device_views.xml index ff91e2d..16722a1 100644 --- a/unifi_integration/views/udm_device_views.xml +++ b/unifi_integration/views/udm_device_views.xml @@ -1,9 +1,28 @@ + + + udm.device.list + udm.device + list + + + + + + + + + + + + + udm.device.form udm.device + form
@@ -34,4 +53,26 @@
+ + + udm.device.search + udm.device + search + + + + + + + + + + + + + + + + +
diff --git a/unifi_integration/views/udm_firewall_views.xml b/unifi_integration/views/udm_firewall_views.xml index 697bf13..d6bd629 100644 --- a/unifi_integration/views/udm_firewall_views.xml +++ b/unifi_integration/views/udm_firewall_views.xml @@ -1,9 +1,28 @@ + + + udm.firewall.rule.list + udm.firewall.rule + list + + + + + + + + + + + + + udm.firewall.rule.form udm.firewall.rule + form
@@ -26,16 +45,39 @@ - + - +
+ + + udm.firewall.rule.search + udm.firewall.rule + search + + + + + + + + + + + + + + + + + +
diff --git a/unifi_integration/views/udm_menu_views.xml b/unifi_integration/views/udm_menu_views.xml index 0b33281..3dbecf9 100644 --- a/unifi_integration/views/udm_menu_views.xml +++ b/unifi_integration/views/udm_menu_views.xml @@ -1,170 +1,256 @@ - + + name="UniFi Integration" + web_icon="unifi_integration,static/description/icon.png" + sequence="90" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> + sequence="10" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> + + + udm.configuration.import.form + udm.configuration + +
+ + + + + + + + + + + + +
+
+
+
+
+ + + + Import Configuration + udm.configuration + form + + new + {'default_active': True} + + - - - - Configurations - udm.configuration - tree,form - -

- Create your first UDM Pro configuration -

-

- You can import configurations directly from your UDM Pro device - or manually create them for documentation purposes. -

-
-
+ groups="unifi_integration.group_udm_pro_manager"/> + action="unifi_integration.action_udm_configuration" + sequence="10" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> + sequence="20" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> Networks udm.network - tree,form - {'search_default_group_by_config_id': 1} + list,form + {'search_default_group_by_config_id': 1, 'search_default_active': 1} + sequence="10" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> VLANs udm.vlan - tree,form - {'search_default_group_by_config_id': 1} + list,form + {'search_default_group_by_config_id': 1, 'search_default_active': 1} + sequence="20" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> + sequence="30" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> Devices udm.device - tree,form - {'search_default_group_by_config_id': 1} + list,form + {'search_default_group_by_config_id': 1, 'search_default_active': 1} + sequence="10" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> + sequence="40" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> Users udm.user - tree,form - {'search_default_group_by_config_id': 1} + list,form + {'search_default_group_by_config_id': 1, 'search_default_active': 1} + sequence="10" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> Firewall Rules udm.firewall.rule - tree,form - {'search_default_group_by_config_id': 1} + list,form + {'search_default_group_by_config_id': 1, 'search_default_active': 1} + sequence="20" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> + + + + + + Dashboard Metrics + udm.dashboard.metric + list,form + {} + + + + + + + Dashboard Statistics + udm.dashboard.stat + list,form + {} + + + + + + + Sites + udm.site + list,form + {} + + + + + sequence="50" + groups="unifi_integration.group_udm_pro_manager"/> System Info udm.system.info - tree,form + list,form + sequence="10" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> - UDM Settings + UniFi Settings udm.settings - tree,form + list,form + sequence="20" + groups="unifi_integration.group_udm_pro_user,unifi_integration.group_udm_pro_manager"/> - Import UDM Pro Configuration + Import UniFi Configuration /udm_pro/import_config self diff --git a/unifi_integration/views/udm_network_config_views.xml b/unifi_integration/views/udm_network_config_views.xml new file mode 100644 index 0000000..6f2cd1b --- /dev/null +++ b/unifi_integration/views/udm_network_config_views.xml @@ -0,0 +1,158 @@ + + + + + udm.port.forward.list + udm.port.forward + + + + + + + + + + + + + + udm.port.forward.form + udm.port.forward + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + + udm.dns.config.list + udm.dns.config + + + + + + + + + + + udm.dns.config.form + udm.dns.config + +
+ + + + + + + + + + + + + + +
+
+
+ + + + udm.routing.config.list + udm.routing.config + + + + + + + + + + udm.routing.config.form + udm.routing.config + +
+ + + + + + + + + + + + + +
+
+
+ + + + Port Forwards + udm.port.forward + list,form + + + + + DNS Configuration + udm.dns.config + list,form + + + + + Routing Configuration + udm.routing.config + list,form + + + + + + + + + +
diff --git a/unifi_integration/views/udm_network_views.xml b/unifi_integration/views/udm_network_views.xml index ab3229d..e543dcd 100644 --- a/unifi_integration/views/udm_network_views.xml +++ b/unifi_integration/views/udm_network_views.xml @@ -1,9 +1,28 @@ + + + udm.network.list + udm.network + list + + + + + + + + + + + + + udm.network.form udm.network + form
@@ -28,11 +47,30 @@ - +
+ + + udm.network.search + udm.network + search + + + + + + + + + + + + + +
diff --git a/unifi_integration/views/udm_pro_docs_templates.xml b/unifi_integration/views/udm_pro_docs_templates.xml new file mode 100644 index 0000000..b4736fd --- /dev/null +++ b/unifi_integration/views/udm_pro_docs_templates.xml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unifi_integration/views/udm_settings_views.xml b/unifi_integration/views/udm_settings_views.xml index cf80f5e..594632a 100644 --- a/unifi_integration/views/udm_settings_views.xml +++ b/unifi_integration/views/udm_settings_views.xml @@ -1,9 +1,25 @@ + + + udm.settings.list + udm.settings + list + + + + + + + + + + udm.settings.form udm.settings + form
@@ -19,11 +35,28 @@ - +
+ + + udm.settings.search + udm.settings + search + + + + + + + + + + + +
diff --git a/unifi_integration/views/udm_site_views.xml b/unifi_integration/views/udm_site_views.xml index db916a2..b137d00 100644 --- a/unifi_integration/views/udm_site_views.xml +++ b/unifi_integration/views/udm_site_views.xml @@ -1,16 +1,58 @@ - - - udm.site.tree - udm.site + + + Import UDM Pro Site + udm.site.import.wizard + form + new + + + + + udm.site.import.wizard.form + udm.site.import.wizard - +
+ + + + + + + + + + + + + + + + +
+
+
+
+
+ + + + udm.site.list + udm.site + list + + +
+
- - + +
@@ -18,6 +60,7 @@ udm.site.form udm.site + form
@@ -27,7 +70,9 @@
@@ -42,7 +87,7 @@ - + @@ -58,14 +103,25 @@ - - - + + + + + + + + + + + + + + @@ -78,7 +134,7 @@ udm.site primary - +
@@ -103,16 +159,15 @@
- -

- - + +

+

+ t-att-style="'width: ' + (cpu_metric and cpu_metric.current_value or '0') + '%;'" + t-att-class="cpu_metric and cpu_metric.status == 'warning' and 'bg-warning' or cpu_metric.status == 'critical' and 'bg-danger' or 'bg-success'" + t-att-aria-valuenow="cpu_metric and cpu_metric.current_value or 0" aria-valuemin="0" aria-valuemax="100">

@@ -127,16 +182,15 @@
- -

- - + +

+

+ t-att-style="'width: ' + (memory_metric and memory_metric.current_value or '0') + '%;'" + t-att-class="memory_metric and memory_metric.status == 'warning' and 'bg-warning' or memory_metric.status == 'critical' and 'bg-danger' or 'bg-success'" + t-att-aria-valuenow="memory_metric and memory_metric.current_value or 0" aria-valuemin="0" aria-valuemax="100">

@@ -151,10 +205,9 @@
- -

- - + +

+

Current Network Load

@@ -235,6 +288,7 @@ udm.site.search + search udm.site diff --git a/unifi_integration/views/udm_system_info_views.xml b/unifi_integration/views/udm_system_info_views.xml index 7a1d8fa..894bce4 100644 --- a/unifi_integration/views/udm_system_info_views.xml +++ b/unifi_integration/views/udm_system_info_views.xml @@ -1,9 +1,27 @@ + + + udm.system.info.list + udm.system.info + list + + + + + + + + + + + + udm.system.info.form udm.system.info + form @@ -23,11 +41,31 @@ - + + + + udm.system.info.search + udm.system.info + search + + + + + + + + + + + + + + + diff --git a/unifi_integration/views/udm_user_views.xml b/unifi_integration/views/udm_user_views.xml index 4dcf053..7fa53ac 100644 --- a/unifi_integration/views/udm_user_views.xml +++ b/unifi_integration/views/udm_user_views.xml @@ -1,9 +1,27 @@ + + + udm.user.list + udm.user + list + + + + + + + + + + + + udm.user.form udm.user + form
@@ -25,11 +43,30 @@ - +
+ + + udm.user.search + udm.user + search + + + + + + + + + + + + + +
diff --git a/unifi_integration/views/udm_vlan_views.xml b/unifi_integration/views/udm_vlan_views.xml index 311bc3a..a409333 100644 --- a/unifi_integration/views/udm_vlan_views.xml +++ b/unifi_integration/views/udm_vlan_views.xml @@ -1,9 +1,26 @@ + + + udm.vlan.list + udm.vlan + list + + + + + + + + + + + udm.vlan.form udm.vlan + form
@@ -21,19 +38,35 @@ - + - + - +
+ + + udm.vlan.search + udm.vlan + search + + + + + + + + + + +
diff --git a/unifi_integration/wizards/__init__.py b/unifi_integration/wizards/__init__.py new file mode 100644 index 0000000..04f9ef2 --- /dev/null +++ b/unifi_integration/wizards/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import udm_site_import_wizard +from . import udm_mfa_wizard diff --git a/unifi_integration/wizards/udm_mfa_wizard.py b/unifi_integration/wizards/udm_mfa_wizard.py new file mode 100644 index 0000000..fbab844 --- /dev/null +++ b/unifi_integration/wizards/udm_mfa_wizard.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +class UdmMfaWizard(models.TransientModel): + """Assistant pour entrer le code MFA""" + _name = 'udm.mfa.wizard' + _description = 'Assistant Code MFA' + + mfa_code = fields.Char(string='Code MFA', required=True, help="Entrez le code d'authentification reçu par courriel") + config_id = fields.Many2one('udm.configuration', string='Configuration') + + def action_validate_mfa(self): + """Valide le code MFA et continue l'importation""" + self.ensure_one() + if not self.config_id: + raise UserError(_("Configuration non trouvée")) + + # Met à jour le code MFA dans la configuration + self.config_id.write({'mfa_token': self.mfa_code}) + + # Relance l'importation + return self.config_id.action_import_configuration() diff --git a/unifi_integration/wizards/udm_site_import_wizard.py b/unifi_integration/wizards/udm_site_import_wizard.py new file mode 100644 index 0000000..9762667 --- /dev/null +++ b/unifi_integration/wizards/udm_site_import_wizard.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +import requests +import urllib3 +from requests.exceptions import RequestException + +class UdmSiteImportWizard(models.TransientModel): + """Wizard to create a new site from a UDM Pro device""" + _name = 'udm.site.import.wizard' + _description = 'Import UDM Pro Site Wizard' + + name = fields.Char(string='Site Name', required=True) + host = fields.Char(string='UDM Pro IP/Hostname', required=True) + port = fields.Integer(string='Port', default=443) + username = fields.Char(string='Username', required=True) + password = fields.Char(string='Password', required=True) + site_id = fields.Char(string='Site ID', default='default', + help="Site identifier in UniFi (usually 'default' unless configured otherwise)") + + def action_import_site(self): + """Create a new site and configuration from UDM Pro""" + self.ensure_one() + + try: + # Disable SSL verification warnings - UDM Pro often uses self-signed certs + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + # Test connection to UDM Pro + login_url = f'https://{self.host}:{self.port}/api/auth/login' + login_data = { + 'username': self.username, + 'password': self.password + } + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + + response = requests.post( + login_url, + json=login_data, + headers=headers, + verify=False + ) + response.raise_for_status() + + # Create the site + site = self.env['udm.site'].create({ + 'name': self.name, + 'site_id': self.site_id, + 'active': True + }) + + # Create the configuration + config = self.env['udm.configuration'].create({ + 'site_id': site.id, + 'host': self.host, + 'port': self.port, + 'username': self.username, + 'password': self.password, + 'active': True + }) + + # Import the configuration + config.action_import_configuration() + + # Return action to view the new site + return { + 'name': _('Site'), + 'view_mode': 'form', + 'res_model': 'udm.site', + 'res_id': site.id, + 'type': 'ir.actions.act_window', + 'target': 'current', + } + + except RequestException as e: + raise UserError(_('Failed to connect to UDM Pro: %s') % str(e)) + except Exception as e: + raise UserError(_('Failed to create site: %s') % str(e)) diff --git a/unifi_integration/wizards/udm_site_import_wizard_views.xml b/unifi_integration/wizards/udm_site_import_wizard_views.xml new file mode 100644 index 0000000..6c498f1 --- /dev/null +++ b/unifi_integration/wizards/udm_site_import_wizard_views.xml @@ -0,0 +1,40 @@ + + + + + Import UDM Pro Site + udm.site.import.wizard + form + new + + + + + udm.site.import.wizard.form + udm.site.import.wizard + +
+ + + + + + + + + + + + + + + + +
+
+
+
+
+
diff --git a/unifi_integration/wizards/views/udm_mfa_wizard_views.xml b/unifi_integration/wizards/views/udm_mfa_wizard_views.xml new file mode 100644 index 0000000..56e418c --- /dev/null +++ b/unifi_integration/wizards/views/udm_mfa_wizard_views.xml @@ -0,0 +1,22 @@ + + + + + udm.mfa.wizard.form + udm.mfa.wizard + +
+

Un code d'authentification a été envoyé à votre adresse courriel.

+

Veuillez entrer ce code ci-dessous pour continuer l'importation.

+ + + + + +
+
+
+