# ── Imports ─────────────────────────────────────────────────────────────────── import os import re import csv import json import shutil import hashlib import base64 import secrets import logging from io import StringIO, BytesIO from datetime import datetime from functools import wraps from urllib.parse import urlencode, urlparse from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address import pyotp import qrcode import mysql.connector from dotenv import load_dotenv from flask import ( Flask, session, request, abort, jsonify, redirect, send_from_directory, send_file, current_app, ) from werkzeug.utils import safe_join os.chdir(os.path.dirname(os.path.abspath(__file__))) load_dotenv() logging.basicConfig(level=logging.INFO) # ── Config & app creation ───────────────────────────────────────────────────── app = Flask(__name__) app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'changeme') app.config['MYSQL_HOST'] = os.environ.get('MYSQL_HOST', 'localhost') app.config['MYSQL_USER'] = os.environ.get('MYSQL_USER', 'user') app.config['MYSQL_PASSWORD'] = os.environ.get('MYSQL_PASSWORD', 'password') app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam') app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['NAME'] = os.environ.get('NAME', 'JDB-NET') app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/projects/ipam.png') app.config['VERSION'] = os.environ.get('VERSION', 'unknown') app.config['LOGTO_OIDC_ENDPOINT'] = os.environ.get('LOGTO_OIDC_ENDPOINT', '') app.config['LOGTO_APP_ID'] = os.environ.get('LOGTO_APP_ID', '') app.config['LOGTO_APP_SECRET'] = os.environ.get('LOGTO_APP_SECRET', '') from db import ( init_db, hash_password, get_db_connection, verify_password, generate_api_key, get_app_settings, update_app_settings, ) from logto_client import ( logto_sso_available, init_logto_oauth, oauth, extract_logto_email, ) init_logto_oauth(app) # ── TOTP / 2FA helpers ─────────────────────────────────────────────────────── def generate_totp_secret(): """Generate a new TOTP secret""" return pyotp.random_base32() def get_totp_uri(secret, email, issuer_name="IPAM"): """Generate TOTP URI for QR code""" totp = pyotp.TOTP(secret) return totp.provisioning_uri( name=email, issuer_name=issuer_name ) def generate_qr_code(uri): """Generate QR code image from URI""" qr = qrcode.QRCode(version=1, box_size=10, border=5) qr.add_data(uri) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") buffer = BytesIO() img.save(buffer, format='PNG') buffer.seek(0) return base64.b64encode(buffer.getvalue()).decode('utf-8') def verify_totp(secret, code): """Verify a TOTP code""" if not secret or not code: return False try: totp = pyotp.TOTP(secret) return totp.verify(code, valid_window=1) # Allow 1 time step window for clock skew except Exception: return False def generate_backup_codes(count=10): """Generate backup codes for 2FA""" return [secrets.token_urlsafe(8).upper() for _ in range(count)] def verify_backup_code(backup_codes_json, code): """Verify a backup code and remove it if valid""" if not backup_codes_json or not code: return False, None try: codes = json.loads(backup_codes_json) code_upper = code.upper().strip() if code_upper in codes: codes.remove(code_upper) return True, json.dumps(codes) if codes else None return False, None except (json.JSONDecodeError, AttributeError): return False, None def format_backup_codes(codes): """Format backup codes for display (group in pairs)""" formatted = [] for i in range(0, len(codes), 2): if i + 1 < len(codes): formatted.append(f"{codes[i]} {codes[i+1]}") else: formatted.append(codes[i]) return formatted # ── Auth & permissions ──────────────────────────────────────────────────────── def _api_key_from_request(): if 'X-API-Key' in request.headers: return request.headers['X-API-Key'] if 'api_key' in request.args: return request.args.get('api_key') auth_header = request.headers.get('Authorization', '') if auth_header.startswith('Bearer '): return auth_header[7:] return None def load_permissions_for_user(user_id, conn): """Return the set of permission names granted to a user via their role.""" cursor = conn.cursor() cursor.execute('SELECT role_id FROM User WHERE id = %s', (user_id,)) role_result = cursor.fetchone() if not role_result or not role_result[0]: return set() cursor.execute(''' SELECT p.name FROM RolePermission rp JOIN Permission p ON rp.permission_id = p.id WHERE rp.role_id = %s ''', (role_result[0],)) return {row[0] for row in cursor.fetchall()} def _user_record_from_row(row, conn): user = { 'id': row[0], 'name': row[1], 'email': row[2], 'role_id': row[3], } user['permissions'] = load_permissions_for_user(user['id'], conn) return user def get_user_from_api_key(api_key): with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT id, name, email, role_id FROM User WHERE api_key = %s', (api_key,)) result = cursor.fetchone() if result: return _user_record_from_row(result, conn) return None def establish_user_session(user_id, conn=None, via_sso=False): """Populate session after successful authentication.""" close_conn = False if conn is None: conn = get_db_connection(current_app) close_conn = True try: cursor = conn.cursor() cursor.execute('SELECT name, email FROM User WHERE id = %s', (user_id,)) row = cursor.fetchone() session['user_name'] = row[0] if row else '' session['user_email'] = row[1] if row else '' session['permissions'] = list(load_permissions_for_user(user_id, conn)) session['logged_in'] = True session['user_id'] = user_id session['auth_via_sso'] = bool(via_sso) if via_sso: session.pop('pending_user_id', None) session.pop('pending_email', None) session.modified = True finally: if close_conn: conn.close() def resolve_auth(): """Return authenticated user dict or None (session or API key).""" if session.get('logged_in') and session.get('user_id'): return { 'id': session['user_id'], 'name': session.get('user_name', ''), 'email': session.get('user_email', ''), 'permissions': set(session.get('permissions', [])), 'auth_type': 'session', } api_key = _api_key_from_request() if api_key: user = get_user_from_api_key(api_key) if user: user['auth_type'] = 'api_key' return user return None def current_user(): return getattr(request, 'current_user', None) def has_permission(permission_name, user=None): user = user or current_user() if not user: return False perms = user.get('permissions') if isinstance(perms, set): return permission_name in perms if isinstance(perms, list): return permission_name in perms return False def require_permission(permission_name): """Decorator: session cookie or API key; enforces RBAC permission.""" def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): user = resolve_auth() if not user: return jsonify({'error': 'Unauthorized'}), 401 if permission_name and not has_permission(permission_name, user): return jsonify({'error': 'Permission denied', 'permission': permission_name}), 403 request.current_user = user response = f(*args, **kwargs) if user.get('auth_type') == 'api_key' and request.method in ('POST', 'PUT', 'DELETE', 'PATCH'): try: status_code = response.status_code if hasattr(response, 'status_code') else ( response[1] if isinstance(response, tuple) and len(response) > 1 else None ) details = f"API call: {request.method} {request.path}" if status_code: details += f" (Status: {status_code})" add_audit_log(user['id'], 'api_usage', details, subnet_id=None) except Exception as e: logging.error(f"Failed to log API usage: {e}") return response return decorated_function return decorator def require_auth(f): """Decorator: authenticated only (no specific permission).""" @wraps(f) def decorated_function(*args, **kwargs): user = resolve_auth() if not user: return jsonify({'error': 'Unauthorized'}), 401 request.current_user = user return f(*args, **kwargs) return decorated_function def require_sso_available(f): """Decorator: SSO subsystem must be enabled via environment variables.""" @wraps(f) def decorated_function(*args, **kwargs): if not logto_sso_available(): abort(404) return f(*args, **kwargs) return decorated_function def _sso_oidc_callback_url(): return f"{request.scheme}://{request.host}/api/v2/auth/sso/callback" def _me_payload(user=None): payload = { 'app_version': app.config['VERSION'], 'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']}, 'features': {'sso': logto_sso_available()}, } if user: payload.update({ 'logged_in': True, 'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')}, 'permissions': sorted(user.get('permissions') or []), }) else: payload['logged_in'] = False return payload def json_body(): return request.get_json(silent=True) or {} def items_response(items): return jsonify({'items': items}) def build_audit_filters(): """Build WHERE clause and params for audit log queries from request args.""" clauses = [] params = [] user = request.args.get('user', '').strip() if user: clauses.append('u.name LIKE %s') params.append(f'%{user}%') action = request.args.get('action', '').strip() if action: clauses.append('al.action = %s') params.append(action) from_date = request.args.get('from', '').strip() if from_date: clauses.append('al.timestamp >= %s') params.append(f'{from_date} 00:00:00') to_date = request.args.get('to', '').strip() if to_date: clauses.append('al.timestamp <= %s') params.append(f'{to_date} 23:59:59') where_sql = ('WHERE ' + ' AND '.join(clauses)) if clauses else '' return where_sql, params def get_current_user_id(): user = current_user() return user['id'] if user else session.get('user_id') def add_audit_log(user_id, action, details=None, subnet_id=None, conn=None): import datetime close_conn = False if conn is None: from flask import current_app conn = get_db_connection(current_app) close_conn = True cursor = conn.cursor() utc_now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) cursor.execute('''INSERT INTO AuditLog (user_id, action, details, subnet_id, timestamp) VALUES (%s, %s, %s, %s, %s)''', (user_id, action, details, subnet_id, utc_now)) if close_conn: conn.commit() conn.close() def get_ip_history_from_audit_logs(device_id=None, ip_address=None, conn=None): """ Extract IP assignment history from audit logs. Returns a list of history entries sorted by timestamp (newest first). Each entry contains: ip, action, device_name, subnet_name, subnet_cidr, user_name, timestamp """ import re close_conn = False if conn is None: from flask import current_app conn = get_db_connection(current_app) close_conn = True try: cursor = conn.cursor(dictionary=True) device_name = None if device_id: cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,)) device_result = cursor.fetchone() if device_result: device_name = device_result['name'] else: return [] query = ''' SELECT al.id, al.action, al.details, al.timestamp, COALESCE(u.name, 'Deleted User') as user_name, s.name as subnet_name, s.cidr as subnet_cidr FROM AuditLog al LEFT JOIN User u ON al.user_id = u.id LEFT JOIN Subnet s ON al.subnet_id = s.id WHERE (al.action = 'device_add_ip' OR al.action = 'device_delete_ip') ''' params = [] if ip_address: query += ' AND al.details LIKE %s' params.append(f'%IP {ip_address}%') if device_id and device_name: query += ' AND al.details LIKE %s' params.append(f'%device {device_name}%') query += ' ORDER BY al.timestamp DESC' cursor.execute(query, params) logs = cursor.fetchall() history = [] # Pattern to extract IP, subnet info, and device name from audit log details # Format: "Assigned IP 192.168.1.1 (SubnetName 192.168.1.0/24) to device DeviceName" # Format: "Removed IP 192.168.1.1 (SubnetName 192.168.1.0/24) from device DeviceName" ip_pattern = r'IP\s+([\d\.]+)' device_pattern = r'(?:to|from)\s+device\s+([^\s]+)' for log in logs: details = log['details'] or '' # Extract IP address ip_match = re.search(ip_pattern, details) if not ip_match: continue extracted_ip = ip_match.group(1) # If filtering by specific IP, skip if it doesn't match if ip_address and extracted_ip != ip_address: continue # Extract device name device_match = re.search(device_pattern, details) extracted_device_name = device_match.group(1) if device_match else 'Unknown' # If filtering by device_id, verify device name matches if device_id and device_name: if extracted_device_name != device_name: continue history.append({ 'ip': extracted_ip, 'action': 'assigned' if log['action'] == 'device_add_ip' else 'removed', 'device_name': extracted_device_name, 'subnet_name': log['subnet_name'] or 'Unknown', 'subnet_cidr': log['subnet_cidr'] or '', 'user_name': log['user_name'], 'timestamp': log['timestamp'] }) return history finally: if close_conn: conn.close() def validate_custom_field_value(field_def, value): """ Validate a custom field value against its field definition. Returns (is_valid, error_message) """ if value is None or value == '': if field_def.get('required', False): return False, f"{field_def.get('name', 'Field')} is required" return True, None field_type = field_def.get('field_type', 'text') validation_rules = field_def.get('validation_rules') # Parse validation rules if it's a JSON string if isinstance(validation_rules, str): try: validation_rules = json.loads(validation_rules) except json.JSONDecodeError: validation_rules = {} elif validation_rules is None: validation_rules = {} # Type-specific validation if field_type == 'ip_address': try: ip_address(value) except ValueError: return False, f"Invalid IP address format: {value}" elif field_type == 'email': email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' if not re.match(email_pattern, value): return False, f"Invalid email format: {value}" elif field_type == 'url': try: result = urlparse(value) if not all([result.scheme, result.netloc]): return False, f"Invalid URL format: {value}" except Exception: return False, f"Invalid URL format: {value}" elif field_type == 'date': try: datetime.strptime(value, '%Y-%m-%d') except ValueError: return False, f"Invalid date format. Expected YYYY-MM-DD: {value}" elif field_type == 'datetime': try: datetime.fromisoformat(value.replace('Z', '+00:00')) except ValueError: return False, f"Invalid datetime format. Expected ISO format: {value}" elif field_type == 'number': try: int(value) except ValueError: return False, f"Invalid integer: {value}" if 'min_value' in validation_rules: if int(value) < validation_rules['min_value']: return False, f"Value must be at least {validation_rules['min_value']}" if 'max_value' in validation_rules: if int(value) > validation_rules['max_value']: return False, f"Value must be at most {validation_rules['max_value']}" elif field_type == 'decimal': try: float(value) except ValueError: return False, f"Invalid decimal number: {value}" if 'min_value' in validation_rules: if float(value) < validation_rules['min_value']: return False, f"Value must be at least {validation_rules['min_value']}" if 'max_value' in validation_rules: if float(value) > validation_rules['max_value']: return False, f"Value must be at most {validation_rules['max_value']}" elif field_type == 'boolean': if value not in [True, False, 'true', 'false', '1', '0', 1, 0]: return False, f"Invalid boolean value: {value}" elif field_type == 'select': if 'select_options' in validation_rules: if value not in validation_rules['select_options']: return False, f"Value must be one of: {', '.join(validation_rules['select_options'])}" # Text length validation (applies to text, textarea, and string-based types) if field_type in ['text', 'textarea', 'ip_address', 'email', 'url']: if 'min_length' in validation_rules: if len(str(value)) < validation_rules['min_length']: return False, f"Value must be at least {validation_rules['min_length']} characters" if 'max_length' in validation_rules: if len(str(value)) > validation_rules['max_length']: return False, f"Value must be at most {validation_rules['max_length']} characters" # Regex pattern validation if 'regex_pattern' in validation_rules: try: if not re.match(validation_rules['regex_pattern'], str(value)): return False, f"Value does not match required pattern" except re.error: # Invalid regex pattern, skip validation pass return True, None def parse_custom_field_value(field_type, raw_value): """Parse and normalize a custom field value based on its type""" if raw_value is None or raw_value == '': return None if field_type == 'number': try: return int(raw_value) except ValueError: return None elif field_type == 'decimal': try: return float(raw_value) except ValueError: return None elif field_type == 'boolean': if isinstance(raw_value, bool): return raw_value if str(raw_value).lower() in ['true', '1', 'yes']: return True if str(raw_value).lower() in ['false', '0', 'no']: return False return None elif field_type in ['date', 'datetime']: # Return as string, validation ensures format return str(raw_value) else: # text, textarea, ip_address, email, url, select return str(raw_value) def validate_vlan_id(vlan_id_str): """ Validate VLAN ID. Must be integer between 1-4094 (standard VLAN range). Returns (is_valid, error_message, vlan_id_int) """ if vlan_id_str is None or vlan_id_str == '': return True, None, None try: vlan_id = int(vlan_id_str) if vlan_id < 1 or vlan_id > 4094: return False, "VLAN ID must be between 1 and 4094", None return True, None, vlan_id except ValueError: return False, "VLAN ID must be a valid integer", None def get_custom_fields_for_entity(entity_type, entity_id, conn=None): """ Retrieve custom field definitions with their values for an entity. Returns list of dicts with field definition and current value. """ close_conn = False if conn is None: from flask import current_app conn = get_db_connection(current_app) close_conn = True try: cursor = conn.cursor(dictionary=True) # Get field definitions for this entity type cursor.execute(''' SELECT id, entity_type, name, field_key, field_type, required, default_value, help_text, display_order, validation_rules FROM CustomFieldDefinition WHERE entity_type = %s ORDER BY display_order, name ''', (entity_type,)) field_defs = cursor.fetchall() # Get current values from entity table table_name = 'Device' if entity_type == 'device' else 'Subnet' cursor.execute(f'SELECT custom_fields FROM {table_name} WHERE id = %s', (entity_id,)) result = cursor.fetchone() current_values = {} if result and result.get('custom_fields'): try: current_values = json.loads(result['custom_fields']) except (json.JSONDecodeError, TypeError): current_values = {} # Merge definitions with values fields_with_values = [] for field_def in field_defs: field_key = field_def['field_key'] current_value = current_values.get(field_key) # Use default value if no current value if current_value is None and field_def.get('default_value'): current_value = field_def['default_value'] # Parse validation_rules if it's a JSON string validation_rules = field_def.get('validation_rules') if isinstance(validation_rules, str): try: validation_rules = json.loads(validation_rules) except (json.JSONDecodeError, TypeError): validation_rules = {} elif validation_rules is None: validation_rules = {} field_def['current_value'] = current_value field_def['validation_rules'] = validation_rules fields_with_values.append(field_def) return fields_with_values finally: if close_conn: conn.close() # ── Data helpers (queries & business logic) ─────────────────────────────────── def get_subnet_utilization(cursor, subnet_id, include_available=False): """Return utilization stats for a single subnet.""" cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) total_ips = cursor.fetchone()[0] cursor.execute(''' SELECT COUNT(*) FROM IPAddress ip INNER JOIN DeviceIPAddress dia ON ip.id = dia.ip_id WHERE ip.subnet_id = %s ''', (subnet_id,)) assigned_ips = cursor.fetchone()[0] cursor.execute(''' SELECT COUNT(*) FROM IPAddress ip LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND dia.ip_id IS NULL ''', (subnet_id,)) dhcp_ips = cursor.fetchone()[0] used_ips = assigned_ips + dhcp_ips utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0 stats = { 'total': total_ips, 'assigned': assigned_ips, 'dhcp': dhcp_ips, 'used': used_ips, 'percent': round(utilization_percent, 1), } if include_available: stats['available'] = total_ips - assigned_ips - dhcp_ips return stats def get_all_subnet_utilizations(cursor): """Return utilization stats keyed by subnet_id.""" cursor.execute(''' SELECT ip.subnet_id, COUNT(*) AS total, SUM(CASE WHEN dia.ip_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned, SUM(CASE WHEN dia.ip_id IS NULL AND ip.hostname = 'DHCP' THEN 1 ELSE 0 END) AS dhcp FROM IPAddress ip LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id GROUP BY ip.subnet_id ''') result = {} for row in cursor.fetchall(): if isinstance(row, dict): subnet_id = row['subnet_id'] total = int(row['total']) assigned = int(row['assigned']) dhcp = int(row['dhcp']) else: subnet_id, total, assigned, dhcp = row total = int(total) assigned = int(assigned) dhcp = int(dhcp) used = assigned + dhcp result[subnet_id] = { 'total': total, 'assigned': assigned, 'dhcp': dhcp, 'used': used, 'percent': round((used / total * 100) if total > 0 else 0, 1), } return result def get_dhcp_pool(cursor, subnet_id): cursor.execute( 'SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,), ) return cursor.fetchone() def is_ip_dhcp_reserved(cursor, subnet_id, ip): dhcp_row = get_dhcp_pool(cursor, subnet_id) if not dhcp_row: return False start_ip, end_ip, excluded_ips = dhcp_row excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x] if ip in excluded_list: return False in_range = False cursor.execute( 'SELECT ip FROM IPAddress WHERE subnet_id = %s ORDER BY INET_ATON(ip)', (subnet_id,), ) for (pool_ip,) in cursor.fetchall(): if pool_ip == start_ip: in_range = True if in_range and pool_ip not in excluded_list and pool_ip == ip: return True if pool_ip == end_ip: in_range = False return False def filter_ips_outside_dhcp(cursor, subnet_id, ips): """Filter list of {id, ip} dicts, removing IPs inside DHCP pool range.""" dhcp_row = get_dhcp_pool(cursor, subnet_id) if not dhcp_row: return ips start_ip, end_ip, excluded_ips = dhcp_row excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x] in_range = False filtered = [] for ip_obj in ips: ip = ip_obj['ip'] if ip == start_ip: in_range = True if ip in excluded_list or not (in_range and ip not in excluded_list): filtered.append(ip_obj) if ip == end_ip: in_range = False return filtered def get_subnet_name_cidr(cursor, subnet_id): cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,)) row = cursor.fetchone() return (row[0], row[1]) if row else (None, None) def get_device_name(cursor, device_id): cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,)) row = cursor.fetchone() return row[0] if row else None def normalize_site(site): return site if site else 'Unassigned' def assign_ip_to_device(conn, device_id, ip_id, user_id): """Assign an IP to a device. Raises ValueError on failure.""" cursor = conn.cursor() cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,)) ip_row = cursor.fetchone() if not ip_row: raise ValueError('IP not found') ip, subnet_id = ip_row[0], ip_row[1] cursor.execute('SELECT site FROM Subnet WHERE id = %s', (subnet_id,)) subnet_row = cursor.fetchone() if not subnet_row: raise ValueError('Subnet not found') new_site = normalize_site(subnet_row[0]) cursor.execute(''' SELECT DISTINCT s.site FROM DeviceIPAddress dia JOIN IPAddress ip ON dia.ip_id = ip.id JOIN Subnet s ON ip.subnet_id = s.id WHERE dia.device_id = %s ''', (device_id,)) existing_sites = {normalize_site(row[0]) for row in cursor.fetchall()} if existing_sites and new_site not in existing_sites: if len(existing_sites) == 1: home = next(iter(existing_sites)) raise ValueError( f'Device is homed at site "{home}"; cannot assign an IP from site "{new_site}"' ) raise ValueError( f'Device already has IPs at other sites; cannot assign an IP from site "{new_site}"' ) cursor.execute('SELECT id FROM DeviceIPAddress WHERE ip_id = %s', (ip_id,)) if cursor.fetchone(): raise ValueError('IP already assigned') if is_ip_dhcp_reserved(cursor, subnet_id, ip): raise ValueError('IP is reserved for DHCP') device_name = get_device_name(cursor, device_id) if not device_name: raise ValueError('Device not found') subnet_name, subnet_cidr = get_subnet_name_cidr(cursor, subnet_id) cursor.execute( 'INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (%s, %s)', (device_id, ip_id), ) cursor.execute('UPDATE IPAddress SET hostname = %s WHERE id = %s', (device_name, ip_id)) details = f"Assigned IP {ip} ({subnet_name} {subnet_cidr}) to device {device_name}" add_audit_log(user_id, 'device_add_ip', details, subnet_id, conn=conn) return ip def remove_ip_from_device(conn, device_ip_id, user_id): """Remove an IP assignment. Returns (ip, device_name) or raises ValueError.""" cursor = conn.cursor() cursor.execute(''' SELECT dia.device_id, ip.ip, ip.subnet_id, d.name FROM DeviceIPAddress dia JOIN IPAddress ip ON dia.ip_id = ip.id JOIN Device d ON dia.device_id = d.id WHERE dia.id = %s ''', (device_ip_id,)) row = cursor.fetchone() if not row: raise ValueError('Assignment not found') _device_id, ip, subnet_id, device_name = row subnet_name, subnet_cidr = get_subnet_name_cidr(cursor, subnet_id) cursor.execute('DELETE FROM DeviceIPAddress WHERE id = %s', (device_ip_id,)) cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE ip = %s', (ip,)) details = f"Removed IP {ip} ({subnet_name} {subnet_cidr}) from device {device_name}" add_audit_log(user_id, 'device_delete_ip', details, subnet_id, conn=conn) return ip, device_name def delete_device_record(conn, device_id, user_id): """Delete device and clear IP hostnames. Returns device name or None.""" cursor = conn.cursor() cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,)) device_row = cursor.fetchone() if not device_row: return None device_name = device_row[0] add_audit_log(user_id, 'delete_device', f"Deleted device {device_name}", conn=conn) cursor.execute('SELECT ip_id FROM DeviceIPAddress WHERE device_id = %s', (device_id,)) ip_ids = [row[0] for row in cursor.fetchall()] if ip_ids: cursor.executemany('UPDATE IPAddress SET hostname = NULL WHERE id = %s', [(i,) for i in ip_ids]) cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s', (device_id,)) cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,)) return device_name def create_subnet_from_cidr(conn, name, cidr, site, vlan_id, vlan_description, vlan_notes, user_id): """Create subnet and populate IPAddress rows. Returns subnet_id.""" network = ip_network(cidr, strict=False) if network.prefixlen < 24: raise ValueError('Subnet must be /24 or smaller') cursor = conn.cursor() cursor.execute( '''INSERT INTO Subnet (name, cidr, site, vlan_id, vlan_description, vlan_notes) VALUES (%s, %s, %s, %s, %s, %s)''', (name, cidr, site, vlan_id, vlan_description or None, vlan_notes or None), ) subnet_id = cursor.lastrowid hosts = list(network.hosts()) if hosts: cursor.executemany( 'INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)', [(str(h), subnet_id) for h in hosts], ) add_audit_log(user_id, 'add_subnet', f"Added subnet {name} ({cidr})", subnet_id, conn=conn) return subnet_id def build_audit_filter_clause(request_args): """Build SQL AND-clause fragment and params for audit list/export queries.""" conditions = [] params = [] user_ids = request_args.getlist('user_ids') if hasattr(request_args, 'getlist') else [] if user_ids: placeholders = ','.join(['%s'] * len(user_ids)) conditions.append(f'AuditLog.user_id IN ({placeholders})') params.extend(user_ids) subnet_id = request_args.get('subnet_id') if subnet_id: conditions.append('AuditLog.subnet_id = %s') params.append(subnet_id) action = request_args.get('action') if action: conditions.append('AuditLog.action = %s') params.append(action) device_name = request_args.get('device_name') if device_name: conditions.append('AuditLog.details LIKE %s') params.append(f'%{device_name}%') date_from = request_args.get('date_from') if date_from: conditions.append('AuditLog.timestamp >= %s') params.append(date_from) date_to = request_args.get('date_to') if date_to: conditions.append('AuditLog.timestamp <= %s') params.append(date_to + ' 23:59:59') search_query = request_args.get('search', '') if hasattr(request_args, 'get'): search_query = (search_query or '').strip() if search_query: conditions.append( "(AuditLog.details LIKE %s OR COALESCE(User.name, '') LIKE %s " "OR AuditLog.action LIKE %s OR COALESCE(Subnet.name, '') LIKE %s)" ) pattern = f'%{search_query}%' params.extend([pattern, pattern, pattern, pattern]) where = (' AND ' + ' AND '.join(conditions)) if conditions else '' return where, params def configure_dhcp_pool(cursor, subnet_id, start_ip, end_ip, excluded_ips, subnet_name, subnet_cidr, user_id, conn, is_update): """Create or update a DHCP pool and mark IPs as DHCP-reserved.""" excluded_ips = (excluded_ips or '').replace(' ', '') excluded_list = [ip for ip in excluded_ips.split(',') if ip] cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) all_ips = [row[0] for row in cursor.fetchall()] if start_ip not in all_ips or end_ip not in all_ips: raise ValueError('Start and End IP must be within the subnet.') cursor.execute('UPDATE IPAddress SET hostname=NULL WHERE subnet_id=%s AND hostname="DHCP"', (subnet_id,)) if is_update: cursor.execute( 'UPDATE DHCPPool SET start_ip = %s, end_ip = %s, excluded_ips = %s WHERE subnet_id = %s', (start_ip, end_ip, excluded_ips, subnet_id), ) action = 'dhcp_pool_update' details = f"Updated DHCP pool for subnet {subnet_name} ({subnet_cidr}): {start_ip} - {end_ip}, excluded: {excluded_ips}" else: cursor.execute( 'INSERT INTO DHCPPool (subnet_id, start_ip, end_ip, excluded_ips) VALUES (%s, %s, %s, %s)', (subnet_id, start_ip, end_ip, excluded_ips), ) action = 'dhcp_pool_create' details = f"Created DHCP pool for subnet {subnet_name} ({subnet_cidr}): {start_ip} - {end_ip}, excluded: {excluded_ips}" in_range = False for ip in all_ips: if ip == start_ip: in_range = True if in_range and ip not in excluded_list: cursor.execute('UPDATE IPAddress SET hostname="DHCP" WHERE subnet_id=%s AND ip=%s', (subnet_id, ip)) if ip == end_ip: break add_audit_log(user_id, action, details, subnet_id, conn=conn) return {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_ips} def remove_dhcp_pool(cursor, subnet_id, subnet_name, subnet_cidr, user_id, conn): cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,)) cursor.execute('UPDATE IPAddress SET hostname=NULL WHERE subnet_id=%s AND hostname="DHCP"', (subnet_id,)) add_audit_log(user_id, 'dhcp_pool_remove', f"Removed DHCP pool for subnet {subnet_name} ({subnet_cidr})", subnet_id, conn=conn) def assign_tag_to_device(conn, device_id, tag_id, user_id): """Assign tag to device. Returns (device_name, tag_name) or raises ValueError.""" cursor = conn.cursor() device_name = get_device_name(cursor, device_id) if not device_name: raise ValueError('Device not found') cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,)) tag_row = cursor.fetchone() if not tag_row: raise ValueError('Tag not found') tag_name = tag_row[0] cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id)) if cursor.fetchone(): raise ValueError('Tag already assigned') cursor.execute('INSERT INTO DeviceTag (device_id, tag_id) VALUES (%s, %s)', (device_id, tag_id)) add_audit_log(user_id, 'assign_device_tag', f"Assigned tag '{tag_name}' to device '{device_name}'", conn=conn) return device_name, tag_name def remove_tag_from_device(conn, device_id, tag_id, user_id): """Remove tag from device. Returns (device_name, tag_name) or raises ValueError.""" cursor = conn.cursor() device_name = get_device_name(cursor, device_id) if not device_name: raise ValueError('Device not found') cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,)) tag_row = cursor.fetchone() if not tag_row: raise ValueError('Tag not found') tag_name = tag_row[0] cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id)) if not cursor.fetchone(): raise ValueError('Tag not assigned') cursor.execute('DELETE FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id)) add_audit_log(user_id, 'remove_device_tag', f"Removed tag '{tag_name}' from device '{device_name}'", conn=conn) return device_name, tag_name def update_entity_custom_fields(conn, entity_type, entity_id, submitted_data, user_id, is_json=False): """Validate and save custom field values. Returns list of errors (empty if ok).""" table = 'Device' if entity_type == 'device' else 'Subnet' audit_action = f'update_{entity_type}_custom_fields' cursor = conn.cursor(dictionary=True) cursor.execute( 'SELECT id, field_key, field_type, required, validation_rules FROM CustomFieldDefinition WHERE entity_type = %s', (entity_type,), ) field_defs = {f['field_key']: f for f in cursor.fetchall()} new_values = {} errors = [] for field_key, field_def in field_defs.items(): if is_json: submitted_value = submitted_data.get(field_key, '') else: submitted_value = submitted_data.get(f'custom_field_{field_key}', '') validation_rules = field_def.get('validation_rules') if isinstance(validation_rules, str): try: validation_rules = json.loads(validation_rules) except json.JSONDecodeError: validation_rules = {} elif validation_rules is None: validation_rules = {} field_def['validation_rules'] = validation_rules if submitted_value == '' and not field_def.get('required'): continue is_valid, error_msg = validate_custom_field_value(field_def, submitted_value) if not is_valid: errors.append(error_msg) else: parsed_value = parse_custom_field_value(field_def['field_type'], submitted_value) if parsed_value is not None: new_values[field_key] = parsed_value if errors: return errors cursor.execute(f'UPDATE {table} SET custom_fields = %s WHERE id = %s', (json.dumps(new_values), entity_id)) add_audit_log(user_id, audit_action, f"Updated custom fields for {entity_type} {entity_id}", conn=conn) return [] def build_validation_rules_from_form(form, field_type=None): """Build validation_rules JSON from custom field form data.""" rules = {} if field_type is None or field_type in ('text', 'textarea'): if form.get('min_length'): rules['min_length'] = int(form['min_length']) if form.get('max_length'): rules['max_length'] = int(form['max_length']) if form.get('regex_pattern'): rules['regex_pattern'] = form['regex_pattern'] if field_type is None or field_type in ('number', 'decimal'): if form.get('min_value'): rules['min_value'] = float(form['min_value']) if form.get('max_value'): rules['max_value'] = float(form['max_value']) if field_type is None or field_type == 'select': if form.get('select_options'): rules['select_options'] = [o.strip() for o in form['select_options'].split(',') if o.strip()] return json.dumps(rules) if rules else None def load_rack_view(conn, rack_id, side='front', networked_only=True): """Load rack page context: rack dict, rack_devices, site_devices.""" cursor = conn.cursor(dictionary=True) cursor.execute('SELECT * FROM Rack WHERE id = %s', (rack_id,)) rack = cursor.fetchone() if not rack: return None cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,)) rack_devices = cursor.fetchall() device_ids = [rd['device_id'] for rd in rack_devices if rd['device_id']] device_names = {} if device_ids: fmt = ','.join(['%s'] * len(device_ids)) cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({fmt})', tuple(device_ids)) for row in cursor.fetchall(): device_names[row['id']] = row['name'] for rd in rack_devices: if rd['device_id']: rd['device_name'] = device_names.get(rd['device_id'], 'Unknown') else: rd['device_name'] = rd['nonnet_device_name'] if networked_only: cursor.execute(''' SELECT DISTINCT Device.id, Device.name, Device.description FROM Device JOIN DeviceIPAddress ON Device.id = DeviceIPAddress.device_id JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id JOIN Subnet ON IPAddress.subnet_id = Subnet.id WHERE Subnet.site = %s ''', (rack['site'],)) else: cursor.execute('SELECT id, name, description FROM Device') site_devices = cursor.fetchall() return {'rack': rack, 'rack_devices': rack_devices, 'site_devices': site_devices, 'current_side': side} def enrich_devices_batch(cursor, devices): """Attach ip_addresses, tags, custom_fields to device dicts in batch.""" if not devices: return devices device_ids = [d['id'] for d in devices] placeholders = ','.join(['%s'] * len(device_ids)) cursor.execute(f''' SELECT dia.device_id, ip.id, ip.ip, ip.hostname, ip.notes, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site FROM DeviceIPAddress dia JOIN IPAddress ip ON dia.ip_id = ip.id JOIN Subnet s ON ip.subnet_id = s.id WHERE dia.device_id IN ({placeholders}) ''', tuple(device_ids)) ips_by_device = {} for row in cursor.fetchall(): ips_by_device.setdefault(row['device_id'], []).append(row) cursor.execute(f''' SELECT dt.device_id, t.id, t.name, t.color FROM DeviceTag dt JOIN Tag t ON dt.tag_id = t.id WHERE dt.device_id IN ({placeholders}) ORDER BY t.name ''', tuple(device_ids)) tags_by_device = {} for row in cursor.fetchall(): tags_by_device.setdefault(row['device_id'], []).append( {'id': row['id'], 'name': row['name'], 'color': row['color']} ) cursor.execute(f'SELECT id, custom_fields FROM Device WHERE id IN ({placeholders})', tuple(device_ids)) cf_by_id = {} for row in cursor.fetchall(): cf = row['custom_fields'] if cf: try: cf_by_id[row['id']] = json.loads(cf) except (json.JSONDecodeError, TypeError): cf_by_id[row['id']] = {} else: cf_by_id[row['id']] = {} for device in devices: did = device['id'] device['ip_addresses'] = ips_by_device.get(did, []) device['tags'] = tags_by_device.get(did, []) device['custom_fields'] = cf_by_id.get(did, {}) return devices def parse_custom_fields_json(raw): if not raw: return {} try: return json.loads(raw) except (json.JSONDecodeError, TypeError): return {} def fetch_subnet_ip_rows(cursor, subnet_id): cursor.execute(''' SELECT ip.id, ip.ip, ip.hostname, d.id, d.description, ip.notes FROM IPAddress ip LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id LEFT JOIN Device d ON dia.device_id = d.id WHERE ip.subnet_id = %s ORDER BY INET_ATON(ip.ip) ''', (subnet_id,)) return cursor.fetchall() def subnet_ip_csv_rows(ip_rows): """Convert IP query rows to CSV data rows.""" rows = [] for ip in ip_rows: device_desc = ip[4] or '' ip_notes = ip[5] if len(ip) > 5 and ip[5] else '' combined_desc = device_desc if ip_notes: combined_desc = f"{combined_desc}\n{ip_notes}" if combined_desc else ip_notes rows.append([ip[1] or '', ip[2] or '', combined_desc]) return rows def csv_attachment(rows, headers, filename): output = StringIO() writer = csv.writer(output) if headers: writer.writerow(headers) writer.writerows(rows) output_bytes = BytesIO(output.getvalue().encode('utf-8')) output_bytes.seek(0) return send_file(output_bytes, mimetype='text/csv', as_attachment=True, download_name=filename) def check_rack_placement(cursor, rack_id, position_u, side): """Return (height_u, error_message). height_u is None if rack not found.""" cursor.execute('SELECT height_u FROM Rack WHERE id = %s', (rack_id,)) rack = cursor.fetchone() if not rack: return None, 'Rack not found' height_u = rack['height_u'] if position_u < 1 or position_u > height_u: return height_u, f"Invalid U position: {position_u}. Rack is {height_u}U tall." cursor.execute( 'SELECT COUNT(*) AS cnt FROM RackDevice WHERE rack_id = %s AND position_u = %s AND side = %s', (rack_id, position_u, side), ) if cursor.fetchone()['cnt'] > 0: return height_u, f"U{position_u} on the {side} is already occupied." return height_u, None def group_devices_by_site(devices): """Group device dicts by site key.""" sites = {} seen_ids = set() for device in devices: if device['id'] in seen_ids: continue seen_ids.add(device['id']) site = device.get('site') or 'Unassigned' sites.setdefault(site, []).append(device) return sites # ── Auth & account (v2) ─────────────────────────────────────────────────────── @app.route('/api/v2/auth/login/precheck', methods=['POST']) def api_auth_login_precheck(): data = json_body() email = (data.get('email') or '').strip().lower() if not email: return jsonify({'error': 'Email required'}), 400 if not logto_sso_available(): return jsonify({'method': 'local'}) with get_db_connection(current_app) as conn: settings = get_app_settings(conn) if not settings.get('is_sso_enabled'): return jsonify({'method': 'local'}) cursor = conn.cursor() cursor.execute('SELECT id FROM User WHERE LOWER(email) = %s', (email,)) if not cursor.fetchone(): return jsonify({'method': 'local'}) params = {'email': email} redirect_target = (data.get('redirect') or '').strip() if redirect_target: params['redirect'] = redirect_target return jsonify({ 'method': 'sso', 'redirect_url': f"/api/v2/auth/sso/login?{urlencode(params)}", }) @app.route('/api/v2/auth/sso/login', methods=['GET']) @require_sso_available def api_auth_sso_login(): email = (request.args.get('email') or '').strip().lower() if not email: return jsonify({'error': 'Email required'}), 400 redirect_target = (request.args.get('redirect') or '').strip() if redirect_target: session['sso_post_login_redirect'] = redirect_target session['sso_expected_email'] = email session.modified = True redirect_uri = _sso_oidc_callback_url() return oauth.logto.authorize_redirect(redirect_uri, login_hint=email) @app.route('/api/v2/auth/sso/callback', methods=['GET']) @require_sso_available def api_auth_sso_callback(): try: token = oauth.logto.authorize_access_token() except Exception as exc: logging.exception('Logto OIDC callback failed') return redirect('/login?error=sso_failed') email = extract_logto_email(oauth.logto, token) if not email: return redirect('/login?error=sso_no_email') expected = (session.pop('sso_expected_email', '') or '').strip().lower() if expected and email != expected: logging.warning('SSO email mismatch: expected %s got %s', expected, email) return redirect('/login?error=sso_email_mismatch') with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT id FROM User WHERE LOWER(email) = %s', (email,)) row = cursor.fetchone() if not row: return redirect('/login?error=sso_user_not_found') user_id = row[0] establish_user_session(user_id, conn=conn, via_sso=True) add_audit_log(user_id, 'sso_login', f'SSO login for {email}', conn=conn) logging.info('User %s logged in via SSO.', email) redirect_to = session.pop('sso_post_login_redirect', None) or '/' return redirect(redirect_to) @app.route('/api/v2/auth/login', methods=['POST']) def api_auth_login(): data = json_body() email = (data.get('email') or '').strip() password = data.get('password') or '' if not email or not password: return jsonify({'error': 'Email and password required'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT id, password FROM User WHERE email = %s', (email,)) user = cursor.fetchone() if not user or not verify_password(password, user[1]): logging.info(f"Failed login attempt for email: {email}") return jsonify({'error': 'Invalid email or password'}), 401 user_id = user[0] cursor.execute(''' SELECT u.totp_enabled, u.two_fa_setup_complete, r.require_2fa FROM User u LEFT JOIN Role r ON u.role_id = r.id WHERE u.id = %s ''', (user_id,)) result = cursor.fetchone() totp_enabled = result[0] if result else False setup_complete = result[1] if result else False role_requires_2fa = result[2] if result else False if role_requires_2fa and not setup_complete: session['pending_user_id'] = user_id session['pending_email'] = email return jsonify({'requires_setup': True}) if totp_enabled: session['pending_user_id'] = user_id session['pending_email'] = email return jsonify({'requires_2fa': True}) establish_user_session(user_id, conn=conn) logging.info(f"User {email} logged in successfully.") return jsonify({'ok': True}) @app.route('/api/v2/auth/logout', methods=['POST']) def api_auth_logout(): session.clear() return jsonify({'ok': True}) @app.route('/api/v2/auth/me', methods=['GET']) def api_auth_me(): user = resolve_auth() return jsonify(_me_payload(user)) @app.route('/api/v2/auth/verify-2fa', methods=['POST']) def api_auth_verify_2fa(): data = json_body() pending_user_id = session.get('pending_user_id') if not pending_user_id: return jsonify({'error': 'No pending login'}), 400 code = (data.get('code') or '').strip() use_backup = bool(data.get('use_backup')) if not code: return jsonify({'error': 'Verification code required'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT totp_secret, backup_codes FROM User WHERE id = %s', (pending_user_id,)) result = cursor.fetchone() if not result: return jsonify({'error': 'User not found'}), 404 totp_secret, backup_codes_json = result if not totp_secret: return jsonify({'error': '2FA is not configured for this account'}), 400 if use_backup: if not backup_codes_json: return jsonify({'error': 'No backup codes available'}), 400 valid, updated_codes = verify_backup_code(backup_codes_json, code) if not valid: return jsonify({'error': 'Invalid backup code'}), 401 cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s', (updated_codes, pending_user_id)) conn.commit() else: if len(code) != 6 or not code.isdigit(): return jsonify({'error': 'Invalid code format'}), 400 if not verify_totp(totp_secret, code): return jsonify({'error': 'Invalid verification code'}), 401 establish_user_session(pending_user_id, conn=conn) session.pop('pending_user_id', None) session.pop('pending_email', None) return jsonify({'ok': True}) @app.route('/api/v2/auth/setup-2fa', methods=['POST']) def api_auth_setup_2fa(): data = json_body() action = data.get('action', 'generate') user_id = session.get('pending_user_id') or session.get('user_id') if not user_id: return jsonify({'error': 'Not authenticated'}), 401 if action == 'generate': secret = generate_totp_secret() session['temp_totp_secret'] = secret with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT email FROM User WHERE id = %s', (user_id,)) row = cursor.fetchone() email = row[0] if row else '' uri = get_totp_uri(secret, email) return jsonify({'secret': secret, 'qr_code': generate_qr_code(uri), 'email': email}) if action == 'verify': code = (data.get('code') or '').strip() secret = session.get('temp_totp_secret') if not secret: return jsonify({'error': 'Session expired. Generate a new secret.'}), 400 if not verify_totp(secret, code): return jsonify({'error': 'Invalid code'}), 401 backup_codes = generate_backup_codes() backup_codes_json = json.dumps(backup_codes) with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute(''' UPDATE User SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s, two_fa_setup_complete = TRUE WHERE id = %s ''', (secret, backup_codes_json, user_id)) conn.commit() session.pop('temp_totp_secret', None) complete_login = session.get('pending_user_id') == user_id if complete_login: establish_user_session(user_id, conn=conn) session.pop('pending_user_id', None) session.pop('pending_email', None) return jsonify({'ok': True, 'backup_codes': backup_codes}) return jsonify({'error': 'Invalid action'}), 400 @app.route('/api/v2/account', methods=['GET']) @require_auth def api_account_get(): with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute(''' SELECT u.totp_enabled, u.two_fa_setup_complete, u.backup_codes, r.require_2fa FROM User u LEFT JOIN Role r ON u.role_id = r.id WHERE u.id = %s ''', (get_current_user_id(),)) row = cursor.fetchone() backup_codes = None if row and row.get('backup_codes'): try: codes = json.loads(row['backup_codes']) backup_codes = format_backup_codes(codes) except json.JSONDecodeError: pass return jsonify({ 'user': {'id': get_current_user_id(), 'name': current_user()['name'], 'email': current_user().get('email', '')}, 'totp_enabled': bool(row and row.get('totp_enabled')), 'two_fa_setup_complete': bool(row and row.get('two_fa_setup_complete')), 'role_requires_2fa': bool(row and row.get('require_2fa')), 'backup_codes': backup_codes, }) @app.route('/api/v2/account/change-password', methods=['POST']) @require_auth def api_account_change_password(): data = json_body() current_pw = data.get('current_password') or '' new_pw = data.get('new_password') or '' if not current_pw or not new_pw: return jsonify({'error': 'Current and new password required'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT password FROM User WHERE id = %s', (get_current_user_id(),)) row = cursor.fetchone() if not row or not verify_password(current_pw, row[0]): return jsonify({'error': 'Current password is incorrect'}), 401 cursor.execute('UPDATE User SET password = %s WHERE id = %s', (hash_password(new_pw), get_current_user_id())) conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/account/disable-2fa', methods=['POST']) @require_auth def api_account_disable_2fa(): data = json_body() password = data.get('password') or '' with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT password, totp_enabled FROM User WHERE id = %s', (get_current_user_id(),)) row = cursor.fetchone() if not row or not verify_password(password, row[0]): return jsonify({'error': 'Password is incorrect'}), 401 if not row[1]: return jsonify({'error': '2FA is not enabled'}), 400 cursor.execute(''' UPDATE User SET totp_secret = NULL, totp_enabled = FALSE, backup_codes = NULL, two_fa_setup_complete = FALSE WHERE id = %s ''', (get_current_user_id(),)) conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/account/regenerate-backup-codes', methods=['POST']) @require_auth def api_account_regenerate_backup_codes(): data = json_body() password = data.get('password') or '' with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT password, totp_enabled FROM User WHERE id = %s', (get_current_user_id(),)) row = cursor.fetchone() if not row or not verify_password(password, row[0]): return jsonify({'error': 'Password is incorrect'}), 401 if not row[1]: return jsonify({'error': '2FA is not enabled'}), 400 backup_codes = generate_backup_codes() cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s', (json.dumps(backup_codes), get_current_user_id())) conn.commit() return jsonify({'backup_codes': backup_codes}) def _sso_settings_response(settings): return { 'is_sso_enabled': bool(settings.get('is_sso_enabled')), } @app.route('/api/v2/settings/sso', methods=['GET']) @require_sso_available @require_permission('view_admin') def api_settings_sso_get(): with get_db_connection(current_app) as conn: settings = get_app_settings(conn) return jsonify(_sso_settings_response(settings)) @app.route('/api/v2/settings/sso', methods=['POST']) @require_sso_available @require_permission('view_admin') def api_settings_sso_post(): data = json_body() is_sso_enabled = bool(data.get('is_sso_enabled')) with get_db_connection(current_app) as conn: update_app_settings(conn, is_sso_enabled=is_sso_enabled) settings = get_app_settings(conn) add_audit_log( get_current_user_id(), 'sso_settings_update', f"External sign-in {'enabled' if is_sso_enabled else 'disabled'}", conn=conn, ) return jsonify(_sso_settings_response(settings)) # ── API v2 routes ───────────────────────────────────────────────────────────── @app.route('/api/v2/info', methods=['GET']) @require_auth def api_info(): """Get API information and authenticated user info""" return jsonify({ 'api_version': '2.0', 'user': { 'id': get_current_user_id(), 'name': current_user()['name'], 'email': current_user()['email'] } }) # Devices API @app.route('/api/v2/devices', methods=['GET']) @require_permission('view_devices') def api_devices(): """Get all devices""" tag_filter = request.args.get('tag') site_filter = request.args.get('site') with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) if tag_filter: cursor.execute(''' SELECT DISTINCT d.id, d.name, d.description FROM Device d JOIN DeviceTag dtag ON d.id = dtag.device_id JOIN Tag t ON dtag.tag_id = t.id WHERE t.name = %s ORDER BY d.name ''', (tag_filter,)) elif site_filter: site_val = None if site_filter == 'Unassigned' else site_filter cursor.execute(''' SELECT DISTINCT d.id, d.name, d.description FROM Device d JOIN DeviceIPAddress dia ON d.id = dia.device_id JOIN IPAddress ip ON dia.ip_id = ip.id JOIN Subnet s ON ip.subnet_id = s.id WHERE s.site <=> %s ORDER BY d.name ''', (site_val,)) else: cursor.execute('SELECT id, name, description FROM Device ORDER BY name') devices = cursor.fetchall() enrich_devices_batch(cursor, devices) return items_response(devices) @app.route('/api/v2/devices/', methods=['GET']) @require_permission('view_device') def api_device(device_id): """Get a specific device""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute(''' SELECT d.id, d.name, d.description FROM Device d WHERE d.id = %s ''', (device_id,)) device = cursor.fetchone() if not device: return jsonify({'error': 'Device not found'}), 404 cursor.execute(''' SELECT ip.id, ip.ip, ip.hostname, ip.notes, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site FROM DeviceIPAddress dia JOIN IPAddress ip ON dia.ip_id = ip.id JOIN Subnet s ON ip.subnet_id = s.id WHERE dia.device_id = %s ''', (device_id,)) device['ip_addresses'] = cursor.fetchall() cursor.execute(''' SELECT t.id, t.name, t.color FROM DeviceTag dt JOIN Tag t ON dt.tag_id = t.id WHERE dt.device_id = %s ORDER BY t.name ''', (device_id,)) device['tags'] = cursor.fetchall() # Get custom fields cursor.execute('SELECT custom_fields FROM Device WHERE id = %s', (device_id,)) cf_result = cursor.fetchone() if cf_result and cf_result.get('custom_fields'): try: device['custom_fields'] = json.loads(cf_result['custom_fields']) except (json.JSONDecodeError, TypeError): device['custom_fields'] = {} else: device['custom_fields'] = {} return jsonify(device) @app.route('/api/v2/devices', methods=['POST']) @require_permission('add_device') def api_add_device(): """Create a new device""" data = request.get_json() if not data or 'name' not in data: return jsonify({'error': 'Device name is required'}), 400 name = data['name'] description = data.get('description', '') from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('INSERT INTO Device (name, description) VALUES (%s, %s)', (name, description)) device_id = cursor.lastrowid add_audit_log(get_current_user_id(), 'add_device', f"Added device {name}", conn=conn) conn.commit() return jsonify({'id': device_id, 'name': name, 'description': description}), 201 @app.route('/api/v2/devices/', methods=['PUT']) @require_permission('edit_device') def api_update_device(device_id): """Update a device""" data = request.get_json() if not data: return jsonify({'error': 'Request body is required'}), 400 from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT name, description FROM Device WHERE id = %s', (device_id,)) current = cursor.fetchone() if not current: return jsonify({'error': 'Device not found'}), 404 current_name, current_description = current updates = [] values = [] rename = False new_name = current_name if 'name' in data: new_name = data['name'] if new_name != current_name: updates.append('name = %s') values.append(new_name) rename = True if 'description' in data and data['description'] != current_description: updates.append('description = %s') values.append(data['description']) if not updates: return jsonify({'error': 'No changes to apply'}), 400 values.append(device_id) cursor.execute(f'UPDATE Device SET {", ".join(updates)} WHERE id = %s', values) if rename: cursor.execute('UPDATE IPAddress SET hostname = %s WHERE hostname = %s', (new_name, current_name)) add_audit_log(get_current_user_id(), 'rename_device', f"Renamed device '{current_name}' to '{new_name}'", conn=conn) conn.commit() return jsonify({'message': 'Device updated successfully', 'device': {'id': device_id, 'name': new_name}}) @app.route('/api/v2/devices/', methods=['DELETE']) @require_permission('delete_device') def api_delete_device(device_id): """Delete a device""" from flask import current_app with get_db_connection(current_app) as conn: device_name = delete_device_record(conn, device_id, get_current_user_id()) if not device_name: return jsonify({'error': 'Device not found'}), 404 conn.commit() return jsonify({'message': 'Device deleted successfully', 'device': {'id': device_id, 'name': device_name}}) @app.route('/api/v2/devices//ips', methods=['POST']) @require_permission('add_device_ip') def api_add_device_ip(device_id): """Add an IP address to a device""" data = request.get_json() if not data or 'ip_id' not in data: return jsonify({'error': 'ip_id is required'}), 400 ip_id = data['ip_id'] from flask import current_app try: with get_db_connection(current_app) as conn: assign_ip_to_device(conn, device_id, ip_id, get_current_user_id()) conn.commit() except ValueError as e: msg = str(e) if 'not found' in msg.lower(): return jsonify({'error': msg}), 404 return jsonify({'error': msg}), 400 return jsonify({'message': 'IP address added to device successfully', 'ip_id': ip_id}), 201 @app.route('/api/v2/devices//ips/', methods=['DELETE']) @require_permission('remove_device_ip') def api_remove_device_ip(device_id, ip_id): """Remove an IP address from a device""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute(''' SELECT ip.ip, ip.subnet_id, d.name FROM DeviceIPAddress dia JOIN IPAddress ip ON dia.ip_id = ip.id JOIN Device d ON dia.device_id = d.id WHERE dia.device_id = %s AND dia.ip_id = %s ''', (device_id, ip_id)) row = cursor.fetchone() if not row: return jsonify({'error': 'IP address not found on device'}), 404 ip, subnet_id, device_name = row cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s AND ip_id = %s', (device_id, ip_id)) cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE id = %s', (ip_id,)) cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,)) subnet_row = cursor.fetchone() if subnet_row: subnet_name, subnet_cidr = subnet_row details = f"Removed IP {ip} ({subnet_name} {subnet_cidr}) from device {device_name}" else: details = f"Removed IP {ip} from device {device_name}" add_audit_log(get_current_user_id(), 'device_delete_ip', details, subnet_id, conn=conn) conn.commit() return jsonify({'message': 'IP address removed from device successfully', 'ip_id': ip_id}) # Subnets API @app.route('/api/v2/subnets', methods=['GET']) @require_permission('view_subnet') def api_subnets(): """Get all subnets""" include_util = request.args.get('include') == 'utilization' with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, name, cidr, site, vlan_id, vlan_description, vlan_notes, custom_fields FROM Subnet ORDER BY site, name') subnets = cursor.fetchall() utils = get_all_subnet_utilizations(cursor) if include_util else {} for subnet in subnets: subnet['custom_fields'] = parse_custom_fields_json(subnet.pop('custom_fields', None)) if include_util: u = utils.get(subnet['id'], {'total': 0, 'used': 0, 'percent': 0}) subnet['utilization'] = u['percent'] subnet['total_ips'] = u['total'] subnet['used_ips'] = u['used'] return items_response(subnets) @app.route('/api/v2/subnets/', methods=['GET']) @require_permission('view_subnet') def api_subnet(subnet_id): """Get a specific subnet with IP addresses""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,)) subnet = cursor.fetchone() if not subnet: return jsonify({'error': 'Subnet not found'}), 404 cursor.execute(''' SELECT ip.id, ip.ip, ip.hostname, ip.notes, d.id as device_id, d.name as device_name, d.description FROM IPAddress ip LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id LEFT JOIN Device d ON dia.device_id = d.id WHERE ip.subnet_id = %s ORDER BY INET_ATON(ip.ip) ''', (subnet_id,)) subnet['ip_addresses'] = cursor.fetchall() # Get custom fields cursor.execute('SELECT custom_fields FROM Subnet WHERE id = %s', (subnet_id,)) cf_result = cursor.fetchone() if cf_result and cf_result.get('custom_fields'): try: subnet['custom_fields'] = json.loads(cf_result['custom_fields']) except (json.JSONDecodeError, TypeError): subnet['custom_fields'] = {} else: subnet['custom_fields'] = {} return jsonify(subnet) @app.route('/api/v2/subnets//next_free_ip', methods=['GET']) @require_permission('view_subnet') def api_subnet_next_free_ip(subnet_id): """Get the next free IP address in a subnet""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) # First check if subnet exists cursor.execute('SELECT id FROM Subnet WHERE id = %s', (subnet_id,)) if not cursor.fetchone(): return jsonify({'error': 'Subnet not found'}), 404 # Find the first unassigned IP outside DHCP pools cursor.execute(''' SELECT ip.id, ip.ip FROM IPAddress ip LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id WHERE ip.subnet_id = %s AND dia.ip_id IS NULL ORDER BY INET_ATON(ip.ip) ''', (subnet_id,)) ips = [{'id': r['id'], 'ip': r['ip']} for r in cursor.fetchall()] ips = filter_ips_outside_dhcp(cursor, subnet_id, ips) if not ips: return jsonify({'error': 'No free IP addresses available in this subnet'}), 404 return jsonify({'id': ips[0]['id'], 'ip': ips[0]['ip']}) @app.route('/api/v2/subnets', methods=['POST']) @require_permission('add_subnet') def api_add_subnet(): """Create a new subnet""" data = request.get_json() if not data or 'name' not in data or 'cidr' not in data: return jsonify({'error': 'Name and CIDR are required'}), 400 name = data['name'] cidr = data['cidr'] site = data.get('site', '') vlan_id_str = str(data.get('vlan_id', '')).strip() if data.get('vlan_id') else '' vlan_description = data.get('vlan_description', '').strip() if data.get('vlan_description') else '' vlan_notes = data.get('vlan_notes', '').strip() if data.get('vlan_notes') else '' # Validate VLAN ID if provided if vlan_id_str: is_valid, error_msg, vlan_id = validate_vlan_id(vlan_id_str) if not is_valid: return jsonify({'error': error_msg}), 400 else: vlan_id = None try: with get_db_connection(current_app) as conn: subnet_id = create_subnet_from_cidr( conn, name, cidr, site, vlan_id, vlan_description, vlan_notes, get_current_user_id(), ) conn.commit() except ValueError as exc: return jsonify({'error': str(exc)}), 400 except Exception: return jsonify({'error': 'Invalid CIDR format'}), 400 return jsonify({ 'id': subnet_id, 'name': name, 'cidr': cidr, 'site': site, 'vlan_id': vlan_id, 'vlan_description': vlan_description if vlan_description else None, 'vlan_notes': vlan_notes if vlan_notes else None }), 201 @app.route('/api/v2/subnets/', methods=['PUT']) @require_permission('edit_subnet') def api_update_subnet(subnet_id): """Update a subnet""" data = request.get_json() if not data: return jsonify({'error': 'Request body is required'}), 400 from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,)) old_subnet = cursor.fetchone() if not old_subnet: return jsonify({'error': 'Subnet not found'}), 404 old_name, old_cidr, old_site, old_vlan_id, old_vlan_desc, old_vlan_notes = old_subnet new_name = data.get('name', old_name) new_cidr = data.get('cidr', old_cidr) new_site = data.get('site', old_site) # Handle VLAN fields vlan_id_str = str(data.get('vlan_id', '')).strip() if data.get('vlan_id') is not None else '' new_vlan_description = data.get('vlan_description', '').strip() if data.get('vlan_description') else '' new_vlan_notes = data.get('vlan_notes', '').strip() if data.get('vlan_notes') else '' # Validate VLAN ID if provided if vlan_id_str: is_valid, error_msg, new_vlan_id = validate_vlan_id(vlan_id_str) if not is_valid: return jsonify({'error': error_msg}), 400 elif 'vlan_id' in data and data['vlan_id'] is None: new_vlan_id = None else: new_vlan_id = old_vlan_id # Use old values if not provided in request if 'vlan_description' not in data: new_vlan_description = old_vlan_desc if old_vlan_desc else '' if 'vlan_notes' not in data: new_vlan_notes = old_vlan_notes if old_vlan_notes else '' updates = [] values = [] if new_name != old_name: updates.append('name = %s') values.append(new_name) if new_cidr != old_cidr: updates.append('cidr = %s') values.append(new_cidr) if new_site != old_site: updates.append('site = %s') values.append(new_site) if new_vlan_id != old_vlan_id: updates.append('vlan_id = %s') values.append(new_vlan_id) if new_vlan_description != (old_vlan_desc or ''): updates.append('vlan_description = %s') values.append(new_vlan_description if new_vlan_description else None) if new_vlan_notes != (old_vlan_notes or ''): updates.append('vlan_notes = %s') values.append(new_vlan_notes if new_vlan_notes else None) if not updates: return jsonify({'error': 'No changes to apply'}), 400 values.append(subnet_id) cursor.execute(f'UPDATE Subnet SET {", ".join(updates)} WHERE id = %s', values) vlan_info = f" (VLAN {new_vlan_id})" if new_vlan_id else "" add_audit_log( get_current_user_id(), 'edit_subnet', f"Edited subnet from {old_name} ({old_cidr}) to {new_name} ({new_cidr}) at site {new_site or 'Unassigned'}{vlan_info}", subnet_id, conn=conn ) conn.commit() return jsonify({ 'message': 'Subnet updated successfully', 'subnet': { 'id': subnet_id, 'name': new_name, 'cidr': new_cidr, 'site': new_site, 'vlan_id': new_vlan_id, 'vlan_description': new_vlan_description if new_vlan_description else None, 'vlan_notes': new_vlan_notes if new_vlan_notes else None } }) @app.route('/api/v2/subnets/', methods=['DELETE']) @require_permission('delete_subnet') def api_delete_subnet(subnet_id): """Delete a subnet""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,)) subnet = cursor.fetchone() if not subnet: return jsonify({'error': 'Subnet not found'}), 404 subnet_name, subnet_cidr = subnet cursor.execute('SELECT id FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) ip_ids = [row[0] for row in cursor.fetchall()] if ip_ids: cursor.executemany('DELETE FROM DeviceIPAddress WHERE ip_id = %s', [(ip_id,) for ip_id in ip_ids]) cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,)) cursor.execute('UPDATE AuditLog SET subnet_id = NULL WHERE subnet_id = %s', (subnet_id,)) cursor.execute('DELETE FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) cursor.execute('DELETE FROM Subnet WHERE id = %s', (subnet_id,)) add_audit_log(get_current_user_id(), 'delete_subnet', f"Deleted subnet {subnet_name} ({subnet_cidr})", subnet_id, conn=conn) conn.commit() return jsonify({'message': 'Subnet deleted successfully', 'subnet': {'id': subnet_id, 'name': subnet_name, 'cidr': subnet_cidr}}) # Racks API @app.route('/api/v2/racks', methods=['GET']) @require_permission('view_racks') def api_racks(): """Get all racks""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, name, site, height_u FROM Rack ORDER BY site, name') racks = cursor.fetchall() for rack in racks: cursor.execute('SELECT COUNT(*) as used FROM RackDevice WHERE rack_id = %s AND side = %s', (rack['id'], 'front')) usage_row = cursor.fetchone() rack['used_u'] = usage_row['used'] if usage_row and 'used' in usage_row else 0 rack['percent_full'] = int((rack['used_u'] / rack['height_u']) * 100) if rack['height_u'] else 0 cursor.execute(''' SELECT rd.id, rd.position_u, rd.side, rd.device_id, rd.nonnet_device_name, d.name as device_name FROM RackDevice rd LEFT JOIN Device d ON rd.device_id = d.id WHERE rd.rack_id = %s ORDER BY rd.position_u, rd.side ''', (rack['id'],)) rack['devices'] = cursor.fetchall() return items_response(racks) @app.route('/api/v2/racks/', methods=['GET']) @require_permission('view_rack') def api_rack(rack_id): """Get a specific rack""" from flask import current_app with get_db_connection(current_app) as conn: ctx = load_rack_view(conn, rack_id) if not ctx: return jsonify({'error': 'Rack not found'}), 404 rack = ctx['rack'] rack['devices'] = [ { 'id': rd['id'], 'position_u': rd['position_u'], 'side': rd['side'], 'device_id': rd['device_id'], 'device_name': rd.get('device_name'), 'nonnet_device_name': rd.get('nonnet_device_name'), } for rd in ctx['rack_devices'] ] rack['site_devices'] = ctx['site_devices'] return jsonify(rack) @app.route('/api/v2/racks/', methods=['PUT']) @require_permission('add_rack') def api_update_rack(rack_id): """Update a rack""" data = json_body() with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, name, site, height_u FROM Rack WHERE id = %s', (rack_id,)) rack = cursor.fetchone() if not rack: return jsonify({'error': 'Rack not found'}), 404 name = (data.get('name') or rack['name']).strip() site = (data.get('site') or rack['site']).strip() height_u = data.get('height_u', rack['height_u']) try: height_u = int(height_u) except (TypeError, ValueError): return jsonify({'error': 'height_u must be an integer'}), 400 if height_u <= 0: return jsonify({'error': 'height_u must be greater than zero'}), 400 cursor.execute('UPDATE Rack SET name = %s, site = %s, height_u = %s WHERE id = %s', (name, site, height_u, rack_id)) add_audit_log(get_current_user_id(), 'edit_rack', f"Updated rack '{rack['name']}' to '{name}' at site '{site}' ({height_u}U)", conn=conn) conn.commit() return jsonify({'id': rack_id, 'name': name, 'site': site, 'height_u': height_u}) @app.route('/api/v2/racks', methods=['POST']) @require_permission('add_rack') def api_add_rack(): """Create a new rack""" from flask import current_app data = request.get_json() if not data or 'name' not in data or 'site' not in data or 'height_u' not in data: return jsonify({'error': 'Name, site, and height_u are required'}), 400 name = data['name'] site = data['site'] height_u = data['height_u'] try: height_u = int(height_u) except (TypeError, ValueError): return jsonify({'error': 'height_u must be an integer'}), 400 if height_u <= 0: return jsonify({'error': 'height_u must be greater than zero'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u)) rack_id = cursor.lastrowid add_audit_log(get_current_user_id(), 'add_rack', f"Added rack '{name}' at site '{site}' ({height_u}U)", conn=conn) conn.commit() return jsonify({'id': rack_id, 'name': name, 'site': site, 'height_u': height_u}), 201 @app.route('/api/v2/racks/', methods=['DELETE']) @require_permission('delete_rack') def api_delete_rack(rack_id): """Delete a rack""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,)) rack = cursor.fetchone() if not rack: return jsonify({'error': 'Rack not found'}), 404 rack_name = rack[0] cursor.execute('DELETE FROM Rack WHERE id = %s', (rack_id,)) add_audit_log(get_current_user_id(), 'delete_rack', f"Deleted rack '{rack_name}'", conn=conn) conn.commit() return jsonify({'message': 'Rack deleted successfully', 'rack': {'id': rack_id, 'name': rack_name}}) @app.route('/api/v2/racks//devices', methods=['POST']) @require_permission('add_device_to_rack') def api_add_device_to_rack(rack_id): """Add a device to a rack""" from flask import current_app data = request.get_json() if not data or 'position_u' not in data or 'side' not in data: return jsonify({'error': 'position_u and side are required'}), 400 position_u = data['position_u'] side = data['side'] device_id = data.get('device_id') nonnet_device_name = data.get('nonnet_device_name') if device_id is None and not nonnet_device_name: return jsonify({'error': 'Either device_id or nonnet_device_name is required'}), 400 try: position_u = int(position_u) except (TypeError, ValueError): return jsonify({'error': 'position_u must be an integer'}), 400 side = str(side).lower() if side not in ('front', 'back'): return jsonify({'error': "side must be either 'front' or 'back'"}), 400 if device_id is not None: try: device_id = int(device_id) except (TypeError, ValueError): return jsonify({'error': 'device_id must be an integer'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT name, height_u FROM Rack WHERE id = %s', (rack_id,)) rack = cursor.fetchone() if not rack: return jsonify({'error': 'Rack not found'}), 404 if position_u < 1 or position_u > rack['height_u']: return jsonify({'error': f'Invalid U position: {position_u}. Rack is {rack["height_u"]}U tall.'}), 400 cursor.execute('SELECT COUNT(*) as occupied_count FROM RackDevice WHERE rack_id = %s AND position_u = %s AND side = %s', (rack_id, position_u, side)) occupied = cursor.fetchone() if occupied and occupied['occupied_count'] > 0: return jsonify({'error': f'U{position_u} on the {side} is already occupied.'}), 400 if device_id is not None: cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,)) device_row = cursor.fetchone() if not device_row: return jsonify({'error': 'Device not found'}), 404 device_name = device_row['name'] cursor.execute( 'INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, %s, %s, %s, NULL)', (rack_id, device_id, position_u, side) ) action = 'rack_add_device' details = f"Assigned device '{device_name}' to rack '{rack['name']}' U{position_u} ({side})" else: nonnet_device_name = (nonnet_device_name or '').strip() if not nonnet_device_name: return jsonify({'error': 'nonnet_device_name is required when device_id is not provided'}), 400 cursor.execute( 'INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, NULL, %s, %s, %s)', (rack_id, position_u, side, nonnet_device_name) ) device_name = nonnet_device_name action = 'rack_add_nonnet_device' details = f"Added non-networked device '{device_name}' to rack '{rack['name']}' U{position_u} ({side})" rack_device_id = cursor.lastrowid add_audit_log(get_current_user_id(), action, details, conn=conn) conn.commit() return jsonify({ 'message': 'Device added to rack successfully', 'rack_device': { 'id': rack_device_id, 'rack_id': rack_id, 'device_id': device_id, 'nonnet_device_name': device_name if device_id is None else None, 'device_name': device_name, 'position_u': position_u, 'side': side } }), 201 @app.route('/api/v2/racks//devices/', methods=['DELETE']) @require_permission('remove_device_from_rack') def api_remove_device_from_rack(rack_id, rack_device_id): """Remove a device from a rack""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute(''' SELECT rd.device_id, rd.nonnet_device_name, rd.position_u, rd.side, d.name AS device_name, r.name AS rack_name FROM RackDevice rd JOIN Rack r ON rd.rack_id = r.id LEFT JOIN Device d ON rd.device_id = d.id WHERE rd.id = %s AND rd.rack_id = %s ''', (rack_device_id, rack_id)) rd = cursor.fetchone() if not rd: return jsonify({'error': 'Device not found in rack'}), 404 if rd['device_id']: device_label = rd['device_name'] or str(rd['device_id']) else: device_label = rd['nonnet_device_name'] cursor.execute('DELETE FROM RackDevice WHERE id = %s AND rack_id = %s', (rack_device_id, rack_id)) add_audit_log( get_current_user_id(), 'rack_remove_device', f"Removed device '{device_label}' from rack '{rd['rack_name']}' U{rd['position_u']} ({rd['side']})", conn=conn ) conn.commit() return jsonify({'message': 'Device removed from rack successfully', 'rack_device_id': rack_device_id}) # Custom Fields API @app.route('/api/v2/custom_fields/', methods=['GET']) @require_permission('view_custom_fields') def api_custom_fields_by_type(entity_type): """Get custom field definitions for a specific entity type""" if entity_type not in ['device', 'subnet']: return jsonify({'error': 'Invalid entity type. Must be "device" or "subnet"'}), 400 from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute(''' SELECT id, entity_type, name, field_key, field_type, required, default_value, help_text, display_order, validation_rules FROM CustomFieldDefinition WHERE entity_type = %s ORDER BY display_order, name ''', (entity_type,)) fields = cursor.fetchall() # Parse validation_rules JSON strings for field in fields: if field.get('validation_rules'): try: field['validation_rules'] = json.loads(field['validation_rules']) except (json.JSONDecodeError, TypeError): field['validation_rules'] = {} return items_response(fields) @app.route('/api/v2/custom_fields', methods=['POST']) @require_permission('manage_custom_fields') def api_add_custom_field(): """Create a new custom field definition""" data = request.get_json() if not data: return jsonify({'error': 'Request body is required'}), 400 required_fields = ['entity_type', 'name', 'field_key', 'field_type'] for field in required_fields: if field not in data: return jsonify({'error': f'{field} is required'}), 400 entity_type = data['entity_type'] if entity_type not in ['device', 'subnet']: return jsonify({'error': 'Invalid entity_type. Must be "device" or "subnet"'}), 400 from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) try: validation_rules = data.get('validation_rules', {}) validation_rules_json = json.dumps(validation_rules) if validation_rules else None cursor.execute(''' INSERT INTO CustomFieldDefinition (entity_type, name, field_key, field_type, required, default_value, help_text, display_order, validation_rules) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ''', (entity_type, data['name'], data['field_key'], data['field_type'], data.get('required', False), data.get('default_value'), data.get('help_text'), data.get('display_order', 0), validation_rules_json)) field_id = cursor.lastrowid add_audit_log(get_current_user_id(), 'add_custom_field', f"Added custom field '{data['name']}' for {entity_type}", conn=conn) conn.commit() return jsonify({'id': field_id, 'message': 'Custom field created successfully'}), 201 except mysql.connector.IntegrityError: return jsonify({'error': f'Field key "{data["field_key"]}" already exists'}), 400 @app.route('/api/v2/custom_fields/', methods=['PUT']) @require_permission('manage_custom_fields') def api_update_custom_field(field_id): """Update a custom field definition""" data = request.get_json() if not data: return jsonify({'error': 'Request body is required'}), 400 from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id FROM CustomFieldDefinition WHERE id = %s', (field_id,)) if not cursor.fetchone(): return jsonify({'error': 'Custom field not found'}), 404 updates = [] values = [] if 'name' in data: updates.append('name = %s') values.append(data['name']) if 'field_type' in data: updates.append('field_type = %s') values.append(data['field_type']) if 'required' in data: updates.append('required = %s') values.append(data['required']) if 'default_value' in data: updates.append('default_value = %s') values.append(data['default_value']) if 'help_text' in data: updates.append('help_text = %s') values.append(data['help_text']) if 'display_order' in data: updates.append('display_order = %s') values.append(data['display_order']) if 'validation_rules' in data: validation_rules_json = json.dumps(data['validation_rules']) if data['validation_rules'] else None updates.append('validation_rules = %s') values.append(validation_rules_json) if not updates: return jsonify({'error': 'No changes to apply'}), 400 values.append(field_id) cursor.execute(f'UPDATE CustomFieldDefinition SET {", ".join(updates)} WHERE id = %s', values) add_audit_log(get_current_user_id(), 'edit_custom_field', f"Updated custom field {field_id}", conn=conn) conn.commit() return jsonify({'message': 'Custom field updated successfully'}) @app.route('/api/v2/custom_fields/', methods=['DELETE']) @require_permission('manage_custom_fields') def api_delete_custom_field(field_id): """Delete a custom field definition""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT name FROM CustomFieldDefinition WHERE id = %s', (field_id,)) field = cursor.fetchone() if not field: return jsonify({'error': 'Custom field not found'}), 404 cursor.execute('DELETE FROM CustomFieldDefinition WHERE id = %s', (field_id,)) add_audit_log(get_current_user_id(), 'delete_custom_field', f"Deleted custom field '{field['name']}'", conn=conn) conn.commit() return jsonify({'message': 'Custom field deleted successfully'}) # DHCP API @app.route('/api/v2/subnets//dhcp', methods=['GET']) @require_permission('view_dhcp') def api_get_dhcp(subnet_id): """Get DHCP pools for a subnet""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,)) pools = cursor.fetchall() return jsonify({'pools': pools}) @app.route('/api/v2/subnets//dhcp', methods=['POST']) @require_permission('configure_dhcp') def api_configure_dhcp(subnet_id): """Configure DHCP pools for a subnet""" data = request.get_json() if not data: return jsonify({'error': 'Request body is required'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,)) subnet = cursor.fetchone() if not subnet: return jsonify({'error': 'Subnet not found'}), 404 subnet_name, subnet_cidr = subnet if data.get('remove'): remove_dhcp_pool(cursor, subnet_id, subnet_name, subnet_cidr, get_current_user_id(), conn) conn.commit() return jsonify({'message': 'DHCP pool removed successfully'}) pools = data.get('pools') if not pools or not isinstance(pools, list): return jsonify({'error': 'pools array is required'}), 400 pool = pools[0] start_ip = pool.get('start_ip') end_ip = pool.get('end_ip') if not start_ip or not end_ip: return jsonify({'error': 'start_ip and end_ip are required'}), 400 excluded_ips = pool.get('excluded_ips', []) if not isinstance(excluded_ips, list): return jsonify({'error': 'excluded_ips must be a list of IP strings'}), 400 excluded_list = [ip.strip() for ip in excluded_ips if ip.strip()] excluded_str = ','.join(excluded_list) cursor.execute('SELECT id FROM DHCPPool WHERE subnet_id = %s', (subnet_id,)) is_update = cursor.fetchone() is not None try: result = configure_dhcp_pool( cursor, subnet_id, start_ip, end_ip, excluded_str, subnet_name, subnet_cidr, get_current_user_id(), conn, is_update, ) except ValueError as exc: return jsonify({'error': str(exc)}), 400 conn.commit() return jsonify({ 'message': 'DHCP pools configured successfully', 'pool': {'start_ip': result['start_ip'], 'end_ip': result['end_ip'], 'excluded_ips': excluded_list}, }) # Tags API @app.route('/api/v2/tags', methods=['GET']) @require_permission('view_tags') def api_tags(): """Get all tags""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, name, color, description, created_at FROM Tag ORDER BY name') tags = cursor.fetchall() for tag in tags: cursor.execute('SELECT COUNT(*) as device_count FROM DeviceTag WHERE tag_id = %s', (tag['id'],)) tag['device_count'] = cursor.fetchone()['device_count'] return items_response(tags) @app.route('/api/v2/tags', methods=['POST']) @require_permission('add_tag') def api_add_tag(): """Create a new tag""" data = request.get_json() if not data or 'name' not in data: return jsonify({'error': 'Tag name is required'}), 400 name = data['name'].strip() if not name: return jsonify({'error': 'Tag name cannot be empty'}), 400 color = data.get('color', '#6B7280') description = data.get('description', '') with get_db_connection(current_app) as conn: cursor = conn.cursor() try: cursor.execute('INSERT INTO Tag (name, color, description) VALUES (%s, %s, %s)', (name, color, description)) tag_id = cursor.lastrowid add_audit_log(get_current_user_id(), 'add_tag', f"Added tag '{name}'", conn=conn) conn.commit() return jsonify({'id': tag_id, 'name': name, 'color': color, 'description': description}), 201 except mysql.connector.IntegrityError: return jsonify({'error': 'Tag name already exists'}), 400 @app.route('/api/v2/tags/', methods=['GET']) @require_permission('view_tags') def api_tag(tag_id): """Get a specific tag""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, name, color, description, created_at FROM Tag WHERE id = %s', (tag_id,)) tag = cursor.fetchone() if not tag: return jsonify({'error': 'Tag not found'}), 404 cursor.execute(''' SELECT d.id, d.name, d.description FROM DeviceTag dtag JOIN Device d ON dtag.device_id = d.id WHERE dtag.tag_id = %s ORDER BY d.name ''', (tag_id,)) tag['devices'] = cursor.fetchall() return jsonify(tag) @app.route('/api/v2/tags/', methods=['PUT']) @require_permission('edit_tag') def api_update_tag(tag_id): """Update a tag""" data = request.get_json() if not data: return jsonify({'error': 'Request body is required'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT name, color, description FROM Tag WHERE id = %s', (tag_id,)) current = cursor.fetchone() if not current: return jsonify({'error': 'Tag not found'}), 404 current_name, current_color, current_description = current updates = [] values = [] if 'name' in data and data['name'].strip() != current_name: new_name = data['name'].strip() if not new_name: return jsonify({'error': 'Tag name cannot be empty'}), 400 updates.append('name = %s') values.append(new_name) if 'color' in data and data['color'] != current_color: updates.append('color = %s') values.append(data['color']) if 'description' in data and data['description'] != current_description: updates.append('description = %s') values.append(data['description']) if not updates: return jsonify({'error': 'No changes to apply'}), 400 values.append(tag_id) try: cursor.execute(f'UPDATE Tag SET {", ".join(updates)} WHERE id = %s', values) add_audit_log(get_current_user_id(), 'edit_tag', f"Updated tag '{current_name}'", conn=conn) conn.commit() return jsonify({'message': 'Tag updated successfully'}) except mysql.connector.IntegrityError: return jsonify({'error': 'Tag name already exists'}), 400 @app.route('/api/v2/tags/', methods=['DELETE']) @require_permission('delete_tag') def api_delete_tag(tag_id): """Delete a tag""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,)) tag = cursor.fetchone() if not tag: return jsonify({'error': 'Tag not found'}), 404 tag_name = tag[0] cursor.execute('DELETE FROM Tag WHERE id = %s', (tag_id,)) add_audit_log(get_current_user_id(), 'delete_tag', f"Deleted tag '{tag_name}'", conn=conn) conn.commit() return jsonify({'message': 'Tag deleted successfully'}) @app.route('/api/v2/devices//tags', methods=['GET']) @require_permission('view_device') def api_device_tags(device_id): """Get tags for a specific device""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, name FROM Device WHERE id = %s', (device_id,)) if not cursor.fetchone(): return jsonify({'error': 'Device not found'}), 404 cursor.execute(''' SELECT t.id, t.name, t.color, t.description, dt.created_at FROM DeviceTag dt JOIN Tag t ON dt.tag_id = t.id WHERE dt.device_id = %s ORDER BY t.name ''', (device_id,)) tags = cursor.fetchall() return items_response(tags) @app.route('/api/v2/devices//tags', methods=['POST']) @require_permission('assign_device_tag') def api_assign_device_tag(device_id): """Assign a tag to a device""" data = request.get_json() if not data or 'tag_id' not in data: return jsonify({'error': 'tag_id is required'}), 400 tag_id = data['tag_id'] with get_db_connection(current_app) as conn: try: assign_tag_to_device(conn, device_id, tag_id, get_current_user_id()) conn.commit() except ValueError as exc: msg = str(exc) if 'not found' in msg.lower(): return jsonify({'error': msg}), 404 if 'already assigned' in msg.lower(): return jsonify({'error': 'Tag already assigned to device'}), 400 return jsonify({'error': msg}), 400 return jsonify({'message': 'Tag assigned successfully'}) @app.route('/api/v2/devices//tags/', methods=['DELETE']) @require_permission('remove_device_tag') def api_remove_device_tag(device_id, tag_id): """Remove a tag from a device""" with get_db_connection(current_app) as conn: try: remove_tag_from_device(conn, device_id, tag_id, get_current_user_id()) conn.commit() except ValueError as exc: msg = str(exc) if 'not found' in msg.lower(): return jsonify({'error': msg}), 404 if 'not assigned' in msg.lower(): return jsonify({'error': 'Tag not assigned to device'}), 404 return jsonify({'error': msg}), 400 return jsonify({'message': 'Tag removed successfully'}) @app.route('/api/v2/devices/by-tag/', methods=['GET']) @require_permission('view_devices') def api_devices_by_tag(tag_identifier): """Get devices by tag name or ID. Use ?format=simple for simplified response.""" from flask import current_app simple_format = request.args.get('format') == 'simple' with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) # Check if tag_identifier is numeric (tag ID) or string (tag name) try: tag_id = int(tag_identifier) # Query by tag ID if simple_format: cursor.execute(''' SELECT d.id, d.name FROM DeviceTag dtag JOIN Device d ON dtag.device_id = d.id WHERE dtag.tag_id = %s ORDER BY d.name ''', (tag_id,)) else: cursor.execute(''' SELECT d.id, d.name, d.description FROM DeviceTag dtag JOIN Device d ON dtag.device_id = d.id WHERE dtag.tag_id = %s ORDER BY d.name ''', (tag_id,)) # Get tag name for response cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,)) tag_result = cursor.fetchone() if not tag_result: return jsonify({'error': 'Tag not found'}), 404 tag_name = tag_result['name'] except ValueError: # Query by tag name tag_name = tag_identifier if simple_format: cursor.execute(''' SELECT d.id, d.name FROM DeviceTag dtag JOIN Device d ON dtag.device_id = d.id JOIN Tag t ON dtag.tag_id = t.id WHERE t.name = %s ORDER BY d.name ''', (tag_name,)) else: cursor.execute(''' SELECT d.id, d.name, d.description FROM DeviceTag dtag JOIN Device d ON dtag.device_id = d.id JOIN Tag t ON dtag.tag_id = t.id WHERE t.name = %s ORDER BY d.name ''', (tag_name,)) devices = cursor.fetchall() if not devices: return jsonify({'items': [], 'meta': {'tag_name': tag_name, 'count': 0}}) if simple_format: # Simple format: just name and first IP as clean array simple_devices = [] for device in devices: cursor.execute(''' SELECT ip.ip FROM DeviceIPAddress dia JOIN IPAddress ip ON dia.ip_id = ip.id WHERE dia.device_id = %s ORDER BY INET_ATON(ip.ip) LIMIT 1 ''', (device['id'],)) ip_result = cursor.fetchone() first_ip = ip_result['ip'] if ip_result else None # Only include devices that have an IP address if first_ip: simple_devices.append({ 'device': device['name'], 'ip': first_ip }) return jsonify(simple_devices) else: # Full format: complete device information for device in devices: cursor.execute(''' SELECT ip.id, ip.ip, ip.hostname, ip.notes, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site FROM DeviceIPAddress dia JOIN IPAddress ip ON dia.ip_id = ip.id JOIN Subnet s ON ip.subnet_id = s.id WHERE dia.device_id = %s ''', (device['id'],)) device['ip_addresses'] = cursor.fetchall() cursor.execute(''' SELECT t.id, t.name, t.color FROM DeviceTag dtag JOIN Tag t ON dtag.tag_id = t.id WHERE dtag.device_id = %s ORDER BY t.name ''', (device['id'],)) device['tags'] = cursor.fetchall() return jsonify({'items': devices, 'meta': {'tag_name': tag_name, 'count': len(devices)}}) # Audit Log API @app.route('/api/v2/audit/actions', methods=['GET']) @require_permission('view_audit') def api_audit_actions(): with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT DISTINCT action FROM AuditLog ORDER BY action') actions = [row[0] for row in cursor.fetchall()] return items_response(actions) @app.route('/api/v2/audit', methods=['GET']) @require_permission('view_audit') def api_audit(): """Get audit log entries""" from flask import current_app where_sql, filter_params = build_audit_filters() limit = request.args.get('limit', 100, type=int) offset = request.args.get('offset', 0, type=int) with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute(f''' SELECT COUNT(*) as total FROM AuditLog al LEFT JOIN User u ON al.user_id = u.id {where_sql} ''', tuple(filter_params)) total = cursor.fetchone()['total'] cursor.execute(f''' SELECT al.id, al.user_id, u.name as user_name, al.action, al.details, al.subnet_id, al.timestamp FROM AuditLog al LEFT JOIN User u ON al.user_id = u.id {where_sql} ORDER BY al.timestamp DESC LIMIT %s OFFSET %s ''', tuple(filter_params) + (limit, offset)) logs = cursor.fetchall() return jsonify({'items': logs, 'total': total}) # Users API (admin only) @app.route('/api/v2/users', methods=['GET']) @require_permission('view_users') def api_users(): """Get all users (admin only)""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute(''' SELECT u.id, u.name, u.email, r.id as role_id, r.name as role_name FROM User u LEFT JOIN Role r ON u.role_id = r.id ORDER BY u.name ''') users = cursor.fetchall() # Don't return API keys in list for user in users: user.pop('api_key', None) return items_response(users) # Roles API (admin only) @app.route('/api/v2/roles', methods=['GET']) @require_permission('view_users') def api_roles(): """Get all roles (admin only)""" from flask import current_app with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, name, description, require_2fa FROM Role ORDER BY name') roles = cursor.fetchall() for role in roles: cursor.execute(''' SELECT p.id, p.name, p.description, p.category FROM RolePermission rp JOIN Permission p ON rp.permission_id = p.id WHERE rp.role_id = %s ''', (role['id'],)) role['permissions'] = cursor.fetchall() return items_response(roles) # ── Extended v2 endpoints ───────────────────────────────────────────────────── @app.route('/api/v2/dashboard', methods=['GET']) @require_permission('view_index') def api_dashboard(): with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) utils = get_all_subnet_utilizations(cursor) cursor.execute('SELECT COUNT(*) AS n FROM Device') device_count = cursor.fetchone()['n'] cursor.execute('SELECT COUNT(*) AS n FROM Subnet') subnet_count = cursor.fetchone()['n'] total_ips = sum(u['total'] for u in utils.values()) used_ips = sum(u['used'] for u in utils.values()) available_ips = max(total_ips - used_ips, 0) utilization_percent = round((used_ips / total_ips * 100) if total_ips > 0 else 0, 1) alerting_subnets = sum(1 for u in utils.values() if u['percent'] >= 90) cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet ORDER BY name') subnet_overview = [] for s in cursor.fetchall(): u = utils.get(s['id'], {'total': 0, 'used': 0, 'percent': 0}) pct = u['percent'] subnet_overview.append({ 'id': s['id'], 'name': s['name'], 'cidr': s['cidr'], 'site': s['site'] or 'Unassigned', 'vlan_id': s['vlan_id'], 'utilization': pct, 'available': u['total'] - u['used'], 'status': 'alerting' if pct >= 90 else 'active', }) cursor.execute(''' SELECT HOUR(timestamp) AS hour, COUNT(*) AS count FROM AuditLog WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 24 HOUR) GROUP BY HOUR(timestamp) ORDER BY hour ''') activity_by_hour = {row['hour']: row['count'] for row in cursor.fetchall()} activity = [{'hour': h, 'count': activity_by_hour.get(h, 0)} for h in range(24)] return jsonify({ 'stats': { 'total_ips': total_ips, 'used_ips': used_ips, 'available_ips': available_ips, 'utilization_percent': utilization_percent, 'subnet_count': subnet_count, 'alerting_subnets': alerting_subnets, 'device_count': device_count, }, 'subnet_overview': subnet_overview, 'activity': activity, }) @app.route('/api/v2/search', methods=['GET']) @require_auth def api_search(): query = (request.args.get('q') or '').strip() results = {'subnets': [], 'ips': [], 'devices': [], 'tags': [], 'racks': [], 'sites': []} if not query: return jsonify(results) pattern = f'%{query}%' with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute(''' SELECT id, name, cidr, site FROM Subnet WHERE name LIKE %s OR cidr LIKE %s OR site LIKE %s ORDER BY site, name ''', (pattern, pattern, pattern)) results['subnets'] = [{**r, 'site': r['site'] or 'Unassigned'} for r in cursor.fetchall()] cursor.execute(''' SELECT ip.id, ip.ip, ip.hostname, ip.subnet_id, s.name as subnet_name, s.cidr, s.site FROM IPAddress ip JOIN Subnet s ON ip.subnet_id = s.id WHERE ip.ip LIKE %s OR ip.hostname LIKE %s OR ip.notes LIKE %s ORDER BY INET_ATON(ip.ip) ''', (pattern, pattern, pattern)) results['ips'] = [{**r, 'site': r['site'] or 'Unassigned'} for r in cursor.fetchall()] cursor.execute('SELECT id, name, description FROM Device WHERE name LIKE %s OR description LIKE %s ORDER BY name', (pattern, pattern)) results['devices'] = cursor.fetchall() cursor.execute('SELECT id, name, description FROM Tag WHERE name LIKE %s OR description LIKE %s ORDER BY name', (pattern, pattern)) results['tags'] = cursor.fetchall() cursor.execute('SELECT id, name, site, height_u FROM Rack WHERE name LIKE %s OR site LIKE %s ORDER BY site, name', (pattern, pattern)) results['racks'] = cursor.fetchall() all_sites = {x['site'] for x in results['subnets']} | {x['site'] for x in results['racks']} | {x['site'] for x in results['ips']} results['sites'] = sorted(s for s in all_sites if query.lower() in s.lower()) return jsonify(results) @app.route('/api/v2/devices//ip-history', methods=['GET']) @require_permission('view_device') def api_device_ip_history(device_id): with get_db_connection(current_app) as conn: history = get_ip_history_from_audit_logs(device_id=device_id, conn=conn) return jsonify({'items': history}) @app.route('/api/v2/ips//history', methods=['GET']) @require_permission('view_subnet') def api_ip_history(ip_address): with get_db_connection(current_app) as conn: history = get_ip_history_from_audit_logs(ip_address=ip_address, conn=conn) return jsonify({'items': history, 'ip': ip_address}) @app.route('/api/v2/subnets//available-ips', methods=['GET']) @require_permission('view_subnet') def api_subnet_available_ips(subnet_id): with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id FROM Subnet WHERE id = %s', (subnet_id,)) if not cursor.fetchone(): return jsonify({'error': 'Subnet not found'}), 404 cursor.execute(''' SELECT ip.id, ip.ip FROM IPAddress ip LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id WHERE ip.subnet_id = %s AND dia.ip_id IS NULL ORDER BY INET_ATON(ip.ip) ''', (subnet_id,)) ips = [{'id': r['id'], 'ip': r['ip']} for r in cursor.fetchall()] ips = filter_ips_outside_dhcp(cursor, subnet_id, ips) return items_response(ips) @app.route('/api/v2/subnets//export', methods=['GET']) @require_permission('export_subnet_csv') def api_subnet_export(subnet_id): with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,)) subnet = cursor.fetchone() if not subnet: return jsonify({'error': 'Subnet not found'}), 404 rows = subnet_ip_csv_rows(fetch_subnet_ip_rows(cursor, subnet_id)) return csv_attachment(rows, ['IP Address', 'Hostname', 'Description'], f"{subnet[0]}_{subnet[1]}.csv".replace('/', '_')) @app.route('/api/v2/ip-addresses/', methods=['PATCH']) @require_permission('edit_subnet') def api_patch_ip_address(ip_id): data = json_body() notes = data.get('notes') if notes is None: return jsonify({'error': 'notes field required'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,)) row = cursor.fetchone() if not row: return jsonify({'error': 'IP not found'}), 404 cursor.execute('UPDATE IPAddress SET notes = %s WHERE id = %s', (notes.strip() or None, ip_id)) add_audit_log(get_current_user_id(), 'update_ip_notes', f"Updated notes for IP {row['ip']}", row['subnet_id'], conn=conn) conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/devices//custom-fields', methods=['PATCH']) @require_permission('edit_device') def api_patch_device_custom_fields(device_id): data = json_body() with get_db_connection(current_app) as conn: errors = update_entity_custom_fields(conn, 'device', device_id, data.get('custom_fields') or data, get_current_user_id(), is_json=True) if errors: return jsonify({'error': '; '.join(errors)}), 400 conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/subnets//custom-fields', methods=['PATCH']) @require_permission('edit_subnet') def api_patch_subnet_custom_fields(subnet_id): data = json_body() with get_db_connection(current_app) as conn: errors = update_entity_custom_fields(conn, 'subnet', subnet_id, data.get('custom_fields') or data, get_current_user_id(), is_json=True) if errors: return jsonify({'error': '; '.join(errors)}), 400 conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/custom-fields/reorder', methods=['POST']) @require_permission('manage_custom_fields') def api_reorder_custom_fields(): data = json_body() entity_type = data.get('entity_type') field_orders = data.get('field_orders') or {} if entity_type not in ('device', 'subnet'): return jsonify({'error': 'Invalid entity_type'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() for field_id, order in field_orders.items(): cursor.execute('UPDATE CustomFieldDefinition SET display_order = %s WHERE id = %s AND entity_type = %s', (int(order), int(field_id), entity_type)) conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/audit/export', methods=['GET']) @require_permission('view_audit') def api_audit_export(): where_sql, filter_params = build_audit_filters() with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute(f''' SELECT al.timestamp, u.name, al.action, al.details, s.name FROM AuditLog al LEFT JOIN User u ON al.user_id = u.id LEFT JOIN Subnet s ON al.subnet_id = s.id {where_sql} ORDER BY al.timestamp DESC ''', tuple(filter_params)) rows = cursor.fetchall() return csv_attachment(rows, ['Timestamp', 'User', 'Action', 'Details', 'Subnet'], 'audit_log.csv') @app.route('/api/v2/users', methods=['POST']) @require_permission('manage_users') def api_add_user(): data = json_body() name = (data.get('name') or '').strip() email = (data.get('email') or '').strip() password = data.get('password') or '' role_id = data.get('role_id') if not all([name, email, password]): return jsonify({'error': 'Name, email, and password required'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() try: api_key = generate_api_key() cursor.execute('INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)', (name, email, hash_password(password), role_id, api_key)) conn.commit() return jsonify({'id': cursor.lastrowid}), 201 except mysql.connector.IntegrityError: return jsonify({'error': 'Email already exists'}), 400 @app.route('/api/v2/users/', methods=['PUT']) @require_permission('manage_users') def api_update_user(user_id): data = json_body() with get_db_connection(current_app) as conn: cursor = conn.cursor() if 'name' in data: cursor.execute('UPDATE User SET name = %s WHERE id = %s', (data['name'].strip(), user_id)) if 'email' in data: cursor.execute('UPDATE User SET email = %s WHERE id = %s', (data['email'].strip(), user_id)) if 'password' in data and data['password']: cursor.execute('UPDATE User SET password = %s WHERE id = %s', (hash_password(data['password']), user_id)) if 'role_id' in data: cursor.execute('UPDATE User SET role_id = %s WHERE id = %s', (data['role_id'], user_id)) conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/users/', methods=['DELETE']) @require_permission('manage_users') def api_delete_user(user_id): if user_id == get_current_user_id(): return jsonify({'error': 'Cannot delete your own account'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('DELETE FROM User WHERE id = %s', (user_id,)) conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/users//regenerate-api-key', methods=['POST']) @require_permission('manage_users') def api_regenerate_user_api_key(user_id): with get_db_connection(current_app) as conn: cursor = conn.cursor() new_key = generate_api_key() cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (new_key, user_id)) conn.commit() return jsonify({'api_key': new_key}) @app.route('/api/v2/roles', methods=['POST']) @require_permission('manage_roles') def api_add_role(): data = json_body() name = (data.get('name') or '').strip() description = (data.get('description') or '').strip() permission_ids = data.get('permission_ids') or [] if not name: return jsonify({'error': 'Name required'}), 400 with get_db_connection(current_app) as conn: cursor = conn.cursor() try: cursor.execute('INSERT INTO Role (name, description) VALUES (%s, %s)', (name, description)) role_id = cursor.lastrowid for pid in permission_ids: cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)', (role_id, pid)) conn.commit() return jsonify({'id': role_id}), 201 except mysql.connector.IntegrityError: return jsonify({'error': 'Role already exists'}), 400 @app.route('/api/v2/roles/', methods=['PUT']) @require_permission('manage_roles') def api_update_role(role_id): data = json_body() with get_db_connection(current_app) as conn: cursor = conn.cursor() if 'name' in data: cursor.execute('UPDATE Role SET name = %s WHERE id = %s', (data['name'].strip(), role_id)) if 'description' in data: cursor.execute('UPDATE Role SET description = %s WHERE id = %s', (data['description'], role_id)) if 'require_2fa' in data: cursor.execute('UPDATE Role SET require_2fa = %s WHERE id = %s', (bool(data['require_2fa']), role_id)) if 'permission_ids' in data: cursor.execute('DELETE FROM RolePermission WHERE role_id = %s', (role_id,)) for pid in data['permission_ids']: cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)', (role_id, pid)) conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/roles/', methods=['DELETE']) @require_permission('manage_roles') def api_delete_role(role_id): with get_db_connection(current_app) as conn: cursor = conn.cursor() cursor.execute('SELECT COUNT(*) FROM User WHERE role_id = %s', (role_id,)) if cursor.fetchone()[0] > 0: return jsonify({'error': 'Role is assigned to users'}), 400 cursor.execute('DELETE FROM RolePermission WHERE role_id = %s', (role_id,)) cursor.execute('DELETE FROM Role WHERE id = %s', (role_id,)) conn.commit() return jsonify({'ok': True}) @app.route('/api/v2/permissions', methods=['GET']) @require_permission('manage_roles') def api_permissions(): with get_db_connection(current_app) as conn: cursor = conn.cursor(dictionary=True) cursor.execute('SELECT id, name, description, category FROM Permission ORDER BY category, name') return items_response(cursor.fetchall()) @app.route('/api/v2/bulk/assign-ips', methods=['POST']) @require_permission('add_device_ip') def api_bulk_assign_ips(): data = json_body() device_id = data.get('device_id') ip_ids = data.get('ip_ids') or [] results = {'success': [], 'failed': []} with get_db_connection(current_app) as conn: for ip_id in ip_ids: try: ip = assign_ip_to_device(conn, device_id, ip_id, get_current_user_id()) results['success'].append({'ip_id': ip_id, 'ip': ip}) except Exception as e: results['failed'].append({'ip_id': ip_id, 'reason': str(e)}) conn.commit() return jsonify(results) @app.route('/api/v2/bulk/create-devices', methods=['POST']) @require_permission('add_device') def api_bulk_create_devices(): data = json_body() names = data.get('names') or [] results = {'success': [], 'failed': []} user_id = get_current_user_id() with get_db_connection(current_app) as conn: cursor = conn.cursor() for name in names: name = name.strip() if not name: continue try: cursor.execute('INSERT INTO Device (name) VALUES (%s)', (name,)) device_id = cursor.lastrowid add_audit_log(user_id, 'add_device', f"Added device {name}", conn=conn) results['success'].append({'id': device_id, 'name': name}) except Exception as e: results['failed'].append({'name': name, 'reason': str(e)}) conn.commit() return jsonify(results) @app.route('/api/v2/bulk/assign-tags', methods=['POST']) @require_permission('assign_device_tag') def api_bulk_assign_tags(): data = json_body() device_ids = data.get('device_ids') or [] tag_id = data.get('tag_id') results = {'success': [], 'failed': []} with get_db_connection(current_app) as conn: for device_id in device_ids: try: assign_tag_to_device(conn, device_id, tag_id, get_current_user_id()) results['success'].append({'device_id': device_id}) except Exception as e: results['failed'].append({'device_id': device_id, 'reason': str(e)}) conn.commit() return jsonify(results) @app.route('/api/v2/bulk/export-subnets', methods=['POST']) @require_permission('export_subnet_csv') def api_bulk_export_subnets(): data = json_body() subnet_ids = data.get('subnet_ids') or [] output = StringIO() writer = csv.writer(output) with get_db_connection(current_app) as conn: cursor = conn.cursor() for subnet_id in subnet_ids: cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,)) subnet = cursor.fetchone() if not subnet: continue writer.writerow([f"Subnet: {subnet[0]} ({subnet[1]})"]) writer.writerow(['IP Address', 'Hostname', 'Description']) writer.writerows(subnet_ip_csv_rows(fetch_subnet_ip_rows(cursor, subnet_id))) writer.writerow([]) output.seek(0) return send_file(BytesIO(output.getvalue().encode('utf-8')), mimetype='text/csv', as_attachment=True, download_name='bulk_subnet_export.csv') @app.route('/api/v2/racks//export', methods=['GET']) @require_permission('export_rack_csv') def api_rack_export(rack_id): with get_db_connection(current_app) as conn: ctx = load_rack_view(conn, rack_id, side='front', networked_only=False) if not ctx: return jsonify({'error': 'Rack not found'}), 404 rack = ctx['rack'] output = StringIO() writer = csv.writer(output) writer.writerow([f"Rack: {rack['name']} ({rack['site']})"]) writer.writerow(['U', 'Side', 'Device']) for rd in sorted(ctx['rack_devices'], key=lambda x: (-x['position_u'], x['side'])): writer.writerow([rd['position_u'], rd['side'], rd.get('device_name') or rd.get('nonnet_device_name') or '']) output.seek(0) return send_file(BytesIO(output.getvalue().encode('utf-8')), mimetype='text/csv', as_attachment=True, download_name=f"{rack['name']}_rack.csv".replace(' ', '_')) # ── SPA static files ────────────────────────────────────────────────────────── STATIC_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') DIST = os.path.join(STATIC_ROOT, 'dist') @app.route('/favicon.ico') def favicon(): logo = app.config['LOGO_PNG'] if logo.startswith(('http://', 'https://')): return redirect(logo) path = logo if os.path.isabs(logo) else os.path.join(os.path.dirname(os.path.abspath(__file__)), logo) if os.path.isfile(path): return send_file(path, mimetype='image/png') abort(404) @app.route('/assets/') def spa_assets(sub): return send_from_directory(os.path.join(DIST, 'assets'), sub) @app.route('/', defaults={'path': ''}) @app.route('/') def spa(path): if path.startswith('api/'): abort(404) index = os.path.join(DIST, 'index.html') if not os.path.isfile(index): return ('Frontend not built. Run: cd frontend && npm ci && npm run build', 503) if path: try: file_path = safe_join(DIST, path) except ValueError: file_path = None if file_path and os.path.isfile(file_path): return send_file(file_path) return send_from_directory(DIST, 'index.html') # ── App startup ─────────────────────────────────────────────────────────────── init_db(app) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True)