8 Commits

Author SHA1 Message Date
Jamie 4b21fdc5cf Merge pull request #6 from JDB-NET/release-please--branches--main
chore(main): release 1.4.1
2025-11-06 14:48:05 +00:00
github-actions[bot] b381195200 chore(main): release 1.4.1 2025-11-06 14:40:20 +00:00
jamie 80b6de395f fix: 🐛 pagination no longer gets out of control 2025-11-06 14:39:58 +00:00
jamie d56e0647f7 fix: 🐛 styling of admin and users pages 2025-11-06 14:39:58 +00:00
Jamie 8909834a19 Merge pull request #5 from JDB-NET/release-please--branches--main
chore(main): release 1.4.0
2025-11-06 13:33:30 +00:00
jamie 59662ec4d8 docs: 📝 add api documentation to readme 2025-11-06 13:32:34 +00:00
github-actions[bot] e0b6c22c1f chore(main): release 1.4.0 2025-11-06 13:26:52 +00:00
jamie c53472c5d7 feat: full api integration 2025-11-06 13:26:33 +00:00
11 changed files with 920 additions and 43 deletions
+49
View File
@@ -1,2 +1,51 @@
# Documentation
README.md
CHANGELOG.md
*.md
# Deployment files
deployment.yml deployment.yml
run.sh run.sh
Dockerfile
.dockerignore
# Git
.git
.gitignore
.gitattributes
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Virtual environments
venv/
env/
ENV/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment files
.env
.env.local
.env.*.local
# Logs
*.log
logs/
# Build tools
tailwindcss
# OS files
.DS_Store
Thumbs.db
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "1.3.0" ".": "1.4.1"
} }
+15
View File
@@ -1,5 +1,20 @@
# Changelog # Changelog
## [1.4.1](https://github.com/JDB-NET/ipam/compare/v1.4.0...v1.4.1) (2025-11-06)
### Bug Fixes
* :bug: pagination no longer gets out of control ([80b6de3](https://github.com/JDB-NET/ipam/commit/80b6de395fc4ddb4e7cd3ece89b423af2667d298))
* :bug: styling of admin and users pages ([d56e064](https://github.com/JDB-NET/ipam/commit/d56e0647f74fba1db1f504e02364406691ede9f3))
## [1.4.0](https://github.com/JDB-NET/ipam/compare/v1.3.0...v1.4.0) (2025-11-06)
### Features
* :sparkles: full api integration ([c53472c](https://github.com/JDB-NET/ipam/commit/c53472c5d760e28e53a737cb0546e85c9a422d15))
## [1.3.0](https://github.com/JDB-NET/ipam/compare/v1.2.0...v1.3.0) (2025-11-06) ## [1.3.0](https://github.com/JDB-NET/ipam/compare/v1.2.0...v1.3.0) (2025-11-06)
+60
View File
@@ -17,6 +17,8 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
- **Site Organization**: Organize subnets and devices by site/location - **Site Organization**: Organize subnets and devices by site/location
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps - **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
- **User Management**: Multi-user support with secure password authentication - **User Management**: Multi-user support with secure password authentication
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
- **REST API**: Full-featured REST API with API key authentication for programmatic access
- **CSV Export**: Export subnet and rack data to CSV files - **CSV Export**: Export subnet and rack data to CSV files
- **Device Statistics**: View device counts by type - **Device Statistics**: View device counts by type
- **Web Interface**: Modern, responsive web GUI built with Tailwind CSS and dark mode support - **Web Interface**: Modern, responsive web GUI built with Tailwind CSS and dark mode support
@@ -142,6 +144,61 @@ View all changes and actions in the "Audit Log" section, with filtering by user,
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames - **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames
- **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information - **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
### Role-Based Access Control
The system uses a granular role-based access control (RBAC) system to manage user permissions:
1. **Default Roles**:
- **Admin**: Full access to all features including user and role management
- **User**: Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles
- **View Only**: Read-only access to view pages but cannot make any changes
2. **Custom Roles**: Administrators can create custom roles with specific permission sets from the Users page
3. **Permission Granularity**: Permissions are organized into categories:
- View permissions (access to pages)
- Device Management (add, edit, delete devices)
- Network Management (subnet operations)
- Rack Management (rack operations)
- DHCP Configuration
- Administration (user and role management)
4. **User Management**: Navigate to the Users page to:
- Create and manage users
- Assign roles to users
- Create custom roles with specific permissions
- View and regenerate API keys
### REST API
The application includes a comprehensive REST API for programmatic access:
1. **Authentication**: All API requests require an API key, which can be provided via:
- `X-API-Key` header
- `Authorization: Bearer <api_key>` header
- `?api_key=<api_key>` query parameter
2. **Base URL**: All API endpoints are prefixed with `/api/v1`
3. **Available Endpoints**:
- **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices`
- **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
- **Device Types**: `GET /api/v1/device-types`
- **DHCP**: `GET`, `POST /api/v1/subnets/{id}/dhcp`
- **Audit Log**: `GET /api/v1/audit`
- **Users & Roles**: `GET /api/v1/users`, `GET /api/v1/roles` (admin only)
4. **API Keys**: Each user has a unique API key that can be viewed and regenerated from the Users page. API keys respect the same role-based permissions as the web interface.
5. **Documentation**: Full API documentation is available in the Help page of the web interface.
**Example API Request**:
```bash
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices
```
## Kubernetes Deployment ## Kubernetes Deployment
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details. The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
@@ -190,6 +247,9 @@ spec:
- Ensure database connections are secured (consider SSL/TLS for MySQL connections) - Ensure database connections are secured (consider SSL/TLS for MySQL connections)
- Review audit logs regularly for unauthorized changes - Review audit logs regularly for unauthorized changes
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization) - Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization)
- **API Keys**: Keep API keys secure and never share them publicly. Regenerate keys if they may have been compromised
- **Role-Based Access**: Use the RBAC system to grant users only the permissions they need (principle of least privilege)
- **HTTPS**: Use HTTPS in production to protect API keys and session data in transit
## Troubleshooting ## Troubleshooting
+1 -1
View File
@@ -1 +1 @@
1.3.0 1.4.1
+20 -2
View File
@@ -1,6 +1,7 @@
import os import os
import hashlib import hashlib
import base64 import base64
import secrets
import mysql.connector import mysql.connector
from flask import current_app from flask import current_app
@@ -18,6 +19,10 @@ def verify_password(password, hashed):
return False return False
return hash_password(password, salt) == hashed return hash_password(password, salt) == hashed
def generate_api_key():
"""Generate a secure API key"""
return secrets.token_urlsafe(32)
def get_db_connection(app=None): def get_db_connection(app=None):
if app is None: if app is None:
app = current_app app = current_app
@@ -189,6 +194,11 @@ def init_db(app=None):
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e): if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e):
raise raise
# Add api_key column to User table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM User LIKE 'api_key'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE')
# Define all permissions with categories # Define all permissions with categories
permissions = [ permissions = [
# View permissions # View permissions
@@ -333,9 +343,17 @@ def init_db(app=None):
# This ensures existing users maintain admin access # This ensures existing users maintain admin access
cursor.execute('UPDATE User SET role_id = %s WHERE role_id IS NULL', (admin_role_id,)) cursor.execute('UPDATE User SET role_id = %s WHERE role_id IS NULL', (admin_role_id,))
# Generate API keys for users that don't have one
cursor.execute('SELECT id FROM User WHERE api_key IS NULL')
users_without_api_key = cursor.fetchall()
for (user_id,) in users_without_api_key:
api_key = generate_api_key()
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (api_key, user_id))
cursor.execute('SELECT COUNT(*) FROM User') cursor.execute('SELECT COUNT(*) FROM User')
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
cursor.execute('''INSERT INTO User (name, email, password, role_id) VALUES (%s, %s, %s, %s)''', api_key = generate_api_key()
('admin', 'admin@example.com', hash_password('password'), admin_role_id)) cursor.execute('''INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)''',
('admin', 'admin@example.com', hash_password('password'), admin_role_id, api_key))
conn.commit() conn.commit()
conn.close() conn.close()
+620 -6
View File
@@ -1,5 +1,5 @@
from flask import render_template, request, redirect, url_for, send_from_directory, send_file, session, abort from flask import render_template, request, redirect, url_for, send_from_directory, send_file, session, abort, jsonify
from db import init_db, hash_password, get_db_connection, verify_password from db import init_db, hash_password, get_db_connection, verify_password, generate_api_key
from ipaddress import ip_network from ipaddress import ip_network
from functools import wraps from functools import wraps
import os import os
@@ -65,6 +65,63 @@ def permission_required(permission_name):
return decorated_function return decorated_function
return decorator return decorator
def get_user_from_api_key(api_key):
"""Get user from API key"""
from flask import current_app
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 {
'id': result[0],
'name': result[1],
'email': result[2],
'role_id': result[3]
}
return None
def api_auth_required(f):
"""Decorator for API authentication using API key"""
@wraps(f)
def decorated_function(*args, **kwargs):
api_key = None
# Check for API key in header
if 'X-API-Key' in request.headers:
api_key = request.headers['X-API-Key']
# Check for API key in query parameter
elif 'api_key' in request.args:
api_key = request.args.get('api_key')
# Check for API key in Authorization header (Bearer token)
elif 'Authorization' in request.headers:
auth_header = request.headers['Authorization']
if auth_header.startswith('Bearer '):
api_key = auth_header[7:]
if not api_key:
return jsonify({'error': 'API key required'}), 401
user = get_user_from_api_key(api_key)
if not user:
return jsonify({'error': 'Invalid API key'}), 401
# Store user info in request context
request.api_user = user
return f(*args, **kwargs)
return decorated_function
def api_permission_required(permission_name):
"""Decorator to require a specific permission for API endpoints"""
def decorator(f):
@wraps(f)
@api_auth_required
def decorated_function(*args, **kwargs):
if not has_permission(permission_name, user_id=request.api_user['id']):
return jsonify({'error': 'Permission denied'}), 403
return f(*args, **kwargs)
return decorated_function
return decorator
def add_audit_log(user_id, action, details=None, subnet_id=None, conn=None): def add_audit_log(user_id, action, details=None, subnet_id=None, conn=None):
import datetime import datetime
close_conn = False close_conn = False
@@ -456,9 +513,11 @@ def register_routes(app):
password = hash_password(request.form['password']) password = hash_password(request.form['password'])
role_id = request.form.get('role_id') role_id = request.form.get('role_id')
if role_id: if role_id:
cursor.execute('INSERT INTO User (name, email, password, role_id) VALUES (%s, %s, %s, %s)', (name, email, password, role_id)) 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, password, role_id, api_key))
else: else:
cursor.execute('INSERT INTO User (name, email, password) VALUES (%s, %s, %s)', (name, email, password)) api_key = generate_api_key()
cursor.execute('INSERT INTO User (name, email, password, api_key) VALUES (%s, %s, %s, %s)', (name, email, password, api_key))
logging.info(f"User {user_name} added user '{name}' ({email}).") logging.info(f"User {user_name} added user '{name}' ({email}).")
conn.commit() conn.commit()
elif action == 'edit_user': elif action == 'edit_user':
@@ -560,10 +619,19 @@ def register_routes(app):
cursor.execute('DELETE FROM Role WHERE id = %s', (role_id,)) cursor.execute('DELETE FROM Role WHERE id = %s', (role_id,))
conn.commit() conn.commit()
logging.info(f"User {user_name} deleted role '{role_name}'.") logging.info(f"User {user_name} deleted role '{role_name}'.")
elif action == 'regenerate_api_key':
if not has_permission('manage_users', conn=conn):
error = 'You do not have permission to regenerate API keys.'
else:
user_id = request.form['user_id']
new_api_key = generate_api_key()
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (new_api_key, user_id))
conn.commit()
logging.info(f"User {user_name} regenerated API key for user {user_id}.")
# Get users with their roles # Get users with their roles and API keys
cursor.execute(''' cursor.execute('''
SELECT u.id, u.name, u.email, r.id as role_id, r.name as role_name SELECT u.id, u.name, u.email, r.id as role_id, r.name as role_name, u.api_key
FROM User u FROM User u
LEFT JOIN Role r ON u.role_id = r.id LEFT JOIN Role r ON u.role_id = r.id
ORDER BY u.name ORDER BY u.name
@@ -1188,6 +1256,539 @@ def register_routes(app):
def help(): def help():
return render_with_user('help.html') return render_with_user('help.html')
# ========== API ROUTES ==========
@app.route('/api/v1/info', methods=['GET'])
@api_auth_required
def api_info():
"""Get API information and authenticated user info"""
return jsonify({
'api_version': '1.0',
'user': {
'id': request.api_user['id'],
'name': request.api_user['name'],
'email': request.api_user['email']
}
})
# Devices API
@app.route('/api/v1/devices', methods=['GET'])
@api_permission_required('view_devices')
def api_devices():
"""Get all devices"""
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, dt.name as device_type, dt.icon_class
FROM Device d
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
ORDER BY d.name
''')
devices = cursor.fetchall()
for device in devices:
cursor.execute('''
SELECT ip.id, ip.ip, ip.hostname, 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()
return jsonify({'devices': devices})
@app.route('/api/v1/devices/<int:device_id>', methods=['GET'])
@api_permission_required('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, dt.name as device_type, dt.icon_class
FROM Device d
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
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, 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()
return jsonify(device)
@app.route('/api/v1/devices', methods=['POST'])
@api_permission_required('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', '')
device_type_id = data.get('device_type_id', 1)
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO Device (name, description, device_type_id) VALUES (%s, %s, %s)',
(name, description, device_type_id))
device_id = cursor.lastrowid
add_audit_log(request.api_user['id'], 'add_device', f"Added device {name}", conn=conn)
conn.commit()
return jsonify({'id': device_id, 'name': name, 'description': description, 'device_type_id': device_type_id}), 201
@app.route('/api/v1/devices/<int:device_id>', methods=['PUT'])
@api_permission_required('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 FROM Device WHERE id = %s', (device_id,))
if not cursor.fetchone():
return jsonify({'error': 'Device not found'}), 404
updates = []
values = []
if 'name' in data:
updates.append('name = %s')
values.append(data['name'])
if 'description' in data:
updates.append('description = %s')
values.append(data['description'])
if 'device_type_id' in data:
updates.append('device_type_id = %s')
values.append(data['device_type_id'])
if not updates:
return jsonify({'error': 'No fields to update'}), 400
values.append(device_id)
cursor.execute(f'UPDATE Device SET {", ".join(updates)} WHERE id = %s', values)
add_audit_log(request.api_user['id'], 'edit_device', f"Updated device {device_id}", conn=conn)
conn.commit()
return jsonify({'message': 'Device updated successfully'})
@app.route('/api/v1/devices/<int:device_id>', methods=['DELETE'])
@api_permission_required('delete_device')
def api_delete_device(device_id):
"""Delete a device"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
if not device:
return jsonify({'error': 'Device not found'}), 404
cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,))
add_audit_log(request.api_user['id'], 'delete_device', f"Deleted device {device[0]}", conn=conn)
conn.commit()
return jsonify({'message': 'Device deleted successfully'})
@app.route('/api/v1/devices/<int:device_id>/ips', methods=['POST'])
@api_permission_required('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
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM Device WHERE id = %s', (device_id,))
if not cursor.fetchone():
return jsonify({'error': 'Device not found'}), 404
cursor.execute('SELECT id FROM IPAddress WHERE id = %s', (ip_id,))
if not cursor.fetchone():
return jsonify({'error': 'IP address not found'}), 404
cursor.execute('SELECT id FROM DeviceIPAddress WHERE device_id = %s AND ip_id = %s', (device_id, ip_id))
if cursor.fetchone():
return jsonify({'error': 'IP address already assigned to this device'}), 400
cursor.execute('INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (%s, %s)', (device_id, ip_id))
add_audit_log(request.api_user['id'], 'add_device_ip', f"Added IP to device {device_id}", conn=conn)
conn.commit()
return jsonify({'message': 'IP address added to device successfully'}), 201
@app.route('/api/v1/devices/<int:device_id>/ips/<int:ip_id>', methods=['DELETE'])
@api_permission_required('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('DELETE FROM DeviceIPAddress WHERE device_id = %s AND ip_id = %s', (device_id, ip_id))
if cursor.rowcount == 0:
return jsonify({'error': 'IP address not found on device'}), 404
add_audit_log(request.api_user['id'], 'remove_device_ip', f"Removed IP from device {device_id}", conn=conn)
conn.commit()
return jsonify({'message': 'IP address removed from device successfully'})
# Subnets API
@app.route('/api/v1/subnets', methods=['GET'])
@api_permission_required('view_subnet')
def api_subnets():
"""Get all subnets"""
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 FROM Subnet ORDER BY site, name')
subnets = cursor.fetchall()
for subnet in subnets:
cursor.execute('SELECT COUNT(*) as total, COUNT(CASE WHEN hostname IS NOT NULL THEN 1 END) as used FROM IPAddress WHERE subnet_id = %s', (subnet['id'],))
stats = cursor.fetchone()
subnet['total_ips'] = stats['total']
subnet['used_ips'] = stats['used']
subnet['available_ips'] = stats['total'] - stats['used']
return jsonify({'subnets': subnets})
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['GET'])
@api_permission_required('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 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, d.id as device_id, d.name as device_name
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 ip.ip
''', (subnet_id,))
subnet['ip_addresses'] = cursor.fetchall()
return jsonify(subnet)
@app.route('/api/v1/subnets', methods=['POST'])
@api_permission_required('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', '')
try:
network = ip_network(cidr, strict=False)
if network.prefixlen < 24:
return jsonify({'error': 'Subnet must be /24 or smaller'}), 400
except Exception:
return jsonify({'error': 'Invalid CIDR format'}), 400
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO Subnet (name, cidr, site) VALUES (%s, %s, %s)', (name, cidr, site))
subnet_id = cursor.lastrowid
ip_rows = [(str(ip), subnet_id) for ip in network.hosts()]
cursor.executemany('INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)', ip_rows)
add_audit_log(request.api_user['id'], 'add_subnet', f"Added subnet {name} ({cidr})", subnet_id, conn=conn)
conn.commit()
return jsonify({'id': subnet_id, 'name': name, 'cidr': cidr, 'site': site}), 201
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['PUT'])
@api_permission_required('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 FROM Subnet WHERE id = %s', (subnet_id,))
old_subnet = cursor.fetchone()
if not old_subnet:
return jsonify({'error': 'Subnet not found'}), 404
updates = []
values = []
if 'name' in data:
updates.append('name = %s')
values.append(data['name'])
if 'cidr' in data:
updates.append('cidr = %s')
values.append(data['cidr'])
if 'site' in data:
updates.append('site = %s')
values.append(data['site'])
if not updates:
return jsonify({'error': 'No fields to update'}), 400
values.append(subnet_id)
cursor.execute(f'UPDATE Subnet SET {", ".join(updates)} WHERE id = %s', values)
add_audit_log(request.api_user['id'], 'edit_subnet', f"Updated subnet {subnet_id}", subnet_id, conn=conn)
conn.commit()
return jsonify({'message': 'Subnet updated successfully'})
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['DELETE'])
@api_permission_required('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 FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone()
if not subnet:
return jsonify({'error': 'Subnet not found'}), 404
cursor.execute('DELETE FROM Subnet WHERE id = %s', (subnet_id,))
add_audit_log(request.api_user['id'], 'delete_subnet', f"Deleted subnet {subnet[0]}", subnet_id, conn=conn)
conn.commit()
return jsonify({'message': 'Subnet deleted successfully'})
# Racks API
@app.route('/api/v1/racks', methods=['GET'])
@api_permission_required('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 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 jsonify({'racks': racks})
@app.route('/api/v1/racks/<int:rack_id>', methods=['GET'])
@api_permission_required('view_rack')
def api_rack(rack_id):
"""Get a specific rack"""
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 WHERE id = %s', (rack_id,))
rack = cursor.fetchone()
if not rack:
return jsonify({'error': 'Rack not found'}), 404
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 jsonify(rack)
@app.route('/api/v1/racks', methods=['POST'])
@api_permission_required('add_rack')
def api_add_rack():
"""Create a new rack"""
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']
from flask import current_app
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(request.api_user['id'], 'add_rack', f"Added rack {name}", conn=conn)
conn.commit()
return jsonify({'id': rack_id, 'name': name, 'site': site, 'height_u': height_u}), 201
@app.route('/api/v1/racks/<int:rack_id>', methods=['DELETE'])
@api_permission_required('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
cursor.execute('DELETE FROM Rack WHERE id = %s', (rack_id,))
add_audit_log(request.api_user['id'], 'delete_rack', f"Deleted rack {rack[0]}", conn=conn)
conn.commit()
return jsonify({'message': 'Rack deleted successfully'})
@app.route('/api/v1/racks/<int:rack_id>/devices', methods=['POST'])
@api_permission_required('add_device_to_rack')
def api_add_device_to_rack(rack_id):
"""Add a device to a rack"""
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 not device_id and not nonnet_device_name:
return jsonify({'error': 'Either device_id or nonnet_device_name is required'}), 400
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id FROM Rack WHERE id = %s', (rack_id,))
if not cursor.fetchone():
return jsonify({'error': 'Rack not found'}), 404
cursor.execute('INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, %s, %s, %s, %s)',
(rack_id, device_id, position_u, side, nonnet_device_name))
add_audit_log(request.api_user['id'], 'add_device_to_rack', f"Added device to rack {rack_id}", conn=conn)
conn.commit()
return jsonify({'message': 'Device added to rack successfully'}), 201
@app.route('/api/v1/racks/<int:rack_id>/devices/<int:rack_device_id>', methods=['DELETE'])
@api_permission_required('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()
cursor.execute('DELETE FROM RackDevice WHERE id = %s AND rack_id = %s', (rack_device_id, rack_id))
if cursor.rowcount == 0:
return jsonify({'error': 'Device not found in rack'}), 404
add_audit_log(request.api_user['id'], 'remove_device_from_rack', f"Removed device from rack {rack_id}", conn=conn)
conn.commit()
return jsonify({'message': 'Device removed from rack successfully'})
# Device Types API
@app.route('/api/v1/device-types', methods=['GET'])
@api_permission_required('view_device_types')
def api_device_types():
"""Get all device types"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, icon_class FROM DeviceType ORDER BY name')
device_types = cursor.fetchall()
return jsonify({'device_types': device_types})
# DHCP API
@app.route('/api/v1/subnets/<int:subnet_id>/dhcp', methods=['GET'])
@api_permission_required('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/v1/subnets/<int:subnet_id>/dhcp', methods=['POST'])
@api_permission_required('configure_dhcp')
def api_configure_dhcp(subnet_id):
"""Configure DHCP pools for a subnet"""
data = request.get_json()
if not data or 'pools' not in data:
return jsonify({'error': 'pools array is required'}), 400
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
for pool in data['pools']:
if 'start_ip' not in pool or 'end_ip' not in pool:
continue
excluded_ips = ','.join(pool.get('excluded_ips', []))
cursor.execute('INSERT INTO DHCPPool (subnet_id, start_ip, end_ip, excluded_ips) VALUES (%s, %s, %s, %s)',
(subnet_id, pool['start_ip'], pool['end_ip'], excluded_ips))
add_audit_log(request.api_user['id'], 'configure_dhcp', f"Configured DHCP for subnet {subnet_id}", subnet_id, conn=conn)
conn.commit()
return jsonify({'message': 'DHCP pools configured successfully'})
# Audit Log API
@app.route('/api/v1/audit', methods=['GET'])
@api_permission_required('view_audit')
def api_audit():
"""Get audit log entries"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
cursor.execute('''
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
ORDER BY al.timestamp DESC
LIMIT %s OFFSET %s
''', (limit, offset))
logs = cursor.fetchall()
return jsonify({'logs': logs})
# Users API (admin only)
@app.route('/api/v1/users', methods=['GET'])
@api_permission_required('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 jsonify({'users': users})
# Roles API (admin only)
@app.route('/api/v1/roles', methods=['GET'])
@api_permission_required('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 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 jsonify({'roles': roles})
def get_current_user_name(): def get_current_user_name():
user_id = session.get('user_id') user_id = session.get('user_id')
if not user_id: if not user_id:
@@ -1237,3 +1838,16 @@ def register_routes(app):
app.add_url_rule('/rack/<int:rack_id>/delete', 'delete_rack', delete_rack, methods=['POST']) app.add_url_rule('/rack/<int:rack_id>/delete', 'delete_rack', delete_rack, methods=['POST'])
app.add_url_rule('/rack/<int:rack_id>/export_csv', 'export_rack_csv', export_rack_csv) app.add_url_rule('/rack/<int:rack_id>/export_csv', 'export_rack_csv', export_rack_csv)
app.add_url_rule('/help', 'help', help) app.add_url_rule('/help', 'help', help)
# API key regeneration route
@app.route('/regenerate_api_key', methods=['POST'])
@permission_required('manage_users')
def regenerate_api_key():
user_id = request.form['user_id']
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
new_api_key = generate_api_key()
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (new_api_key, user_id))
conn.commit()
return redirect(url_for('users'))
+18 -18
View File
@@ -24,7 +24,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors"> <a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<i class="fas fa-clipboard-list text-3xl text-blue-500"></i> <i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i>
<div> <div>
<h3 class="text-lg font-bold">Audit Log</h3> <h3 class="text-lg font-bold">Audit Log</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View system activity</p> <p class="text-sm text-gray-600 dark:text-gray-400">View system activity</p>
@@ -34,7 +34,7 @@
</a> </a>
<a href="/users" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors"> <a href="/users" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<i class="fas fa-users text-3xl text-green-500"></i> <i class="fas fa-users text-3xl text-gray-600 dark:text-gray-400"></i>
<div> <div>
<h3 class="text-lg font-bold">User Management</h3> <h3 class="text-lg font-bold">User Management</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Manage users & roles</p> <p class="text-sm text-gray-600 dark:text-gray-400">Manage users & roles</p>
@@ -49,7 +49,7 @@
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">Subnet Management</h2> <h2 class="text-2xl font-bold">Subnet Management</h2>
{% if can_add_subnet %} {% if can_add_subnet %}
<button onclick="showAddSubnetModal()" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium"> <button onclick="showAddSubnetModal()" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>Add Subnet <i class="fas fa-plus mr-2"></i>Add Subnet
</button> </button>
{% endif %} {% endif %}
@@ -60,34 +60,34 @@
<table class="w-full"> <table class="w-full">
<thead> <thead>
<tr class="border-b border-gray-600"> <tr class="border-b border-gray-600">
<th class="text-left p-3">Name</th> <th class="text-center p-3">Name</th>
<th class="text-left p-3">CIDR</th> <th class="text-center p-3">CIDR</th>
<th class="text-left p-3">Site</th> <th class="text-center p-3">Site</th>
<th class="text-left p-3">Actions</th> <th class="text-center p-3">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for subnet in subnets %} {% for subnet in subnets %}
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700"> <tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
<td class="p-3 font-medium">{{ subnet.name }}</td> <td class="p-3 font-medium text-center">{{ subnet.name }}</td>
<td class="p-3 font-mono text-sm">{{ subnet.cidr }}</td> <td class="p-3 font-mono text-sm text-center">{{ subnet.cidr }}</td>
<td class="p-3"> <td class="p-3 text-center">
<span class="px-2 py-1 bg-blue-200 dark:bg-blue-800 rounded text-sm">{{ subnet.site }}</span> <span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
</td> </td>
<td class="p-3"> <td class="p-3 text-center">
<div class="flex items-center space-x-2"> <div class="flex items-center justify-center space-x-2">
<a href="/subnet/{{ subnet.id }}" class="text-blue-500 hover:text-blue-700" title="View Subnet"> <a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
{% if can_edit_subnet %} {% if can_edit_subnet %}
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-green-500 hover:text-green-700" title="Edit Subnet"> <button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
{% endif %} {% endif %}
{% if can_delete_subnet %} {% if can_delete_subnet %}
<form action="/delete_subnet" method="POST" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');" class="inline"> <form action="/delete_subnet" method="POST" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');" class="inline">
<input type="hidden" name="subnet_id" value="{{ subnet.id }}"> <input type="hidden" name="subnet_id" value="{{ subnet.id }}">
<button type="submit" class="text-red-500 hover:text-red-700" title="Delete Subnet"> <button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Subnet">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
</form> </form>
@@ -126,8 +126,8 @@
<span id="cidr-error" class="text-red-500 text-sm hidden"></span> <span id="cidr-error" class="text-red-500 text-sm hidden"></span>
</div> </div>
<div class="flex justify-end space-x-2 mt-6"> <div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button> <button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Subnet</button> <button type="submit" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Subnet</button>
</div> </div>
</form> </form>
</div> </div>
+29 -1
View File
@@ -75,11 +75,39 @@
<span class="hidden sm:inline">Prev</span> <span class="hidden sm:inline">Prev</span>
</a> </a>
{% endif %} {% endif %}
{% for p in range(1, total_pages+1) %}
{# Smart pagination logic #}
{% set delta = 2 %}
{% set start_page = [1, page - delta]|max %}
{% set end_page = [total_pages, page + delta]|min %}
{# Show first page if we're not near the start #}
{% if start_page > 1 %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': 1}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
{% if start_page > 2 %}
<span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span>
{% endif %}
{% endif %}
{# Show pages around current page #}
{% for p in range(start_page, end_page + 1) %}
{% set page_args = query_args.copy() %} {% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': p}) %} {% set _ = page_args.update({'page': p}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a> <a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
{% endfor %} {% endfor %}
{# Show last page if we're not near the end #}
{% if end_page < total_pages %}
{% if end_page < total_pages - 1 %}
<span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span>
{% endif %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': total_pages}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
{% endif %}
{% if page < total_pages %} {% if page < total_pages %}
{% set next_args = query_args.copy() %} {% set next_args = query_args.copy() %}
{% set _ = next_args.update({'page': page+1}) %} {% set _ = next_args.update({'page': page+1}) %}
+80
View File
@@ -80,6 +80,86 @@
<h3 class="text-xl font-semibold mb-1">Audit & History</h3> <h3 class="text-xl font-semibold mb-1">Audit & History</h3>
<p>All changes are logged and can be reviewed on the <a href="/audit" class="text-blue-600 dark:text-blue-400 hover:underline">Audit</a> page for accountability and troubleshooting. The audit log shows who made changes, what was changed, and when.</p> <p>All changes are logged and can be reviewed on the <a href="/audit" class="text-blue-600 dark:text-blue-400 hover:underline">Audit</a> page for accountability and troubleshooting. The audit log shows who made changes, what was changed, and when.</p>
</div> </div>
<div>
<h3 class="text-xl font-semibold mb-1">API Keys</h3>
<p>Each user has a unique API key that can be used to authenticate API requests. API keys can be viewed and regenerated from the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page. Keep your API key secure and never share it publicly.</p>
</div>
</div>
</div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">API Documentation</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Authentication</h3>
<p>All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
</ul>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Base URL</h3>
<p>All API endpoints are prefixed with <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Available Endpoints</h3>
<div class="space-y-3 mt-2">
<div>
<h4 class="font-semibold">Devices</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices</code> - List all devices</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices/{id}</code> - Get device details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices</code> - Create device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/devices/{id}</code> - Update device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}</code> - Delete device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP from device</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Subnets</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets</code> - List all subnets</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets</code> - Create subnet</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Racks</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks</code> - List all racks</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks/{id}</code> - Get rack details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks</code> - Create rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}/devices/{rack_device_id}</code> - Remove device from rack</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Other</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/info</code> - Get API info and user details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/device-types</code> - List device types</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}/dhcp</code> - Get DHCP pools</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets/{id}/dhcp</code> - Configure DHCP pools</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/audit</code> - Get audit log</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/users</code> - List users (admin only)</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/roles</code> - List roles (admin only)</li>
</ul>
</div>
</div>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Permissions</h3>
<p>API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">403 Forbidden</code> error.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Response Format</h3>
<p>All API responses are in JSON format. Successful requests return <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">200 OK</code> or <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">201 Created</code> with the requested data. Errors return appropriate HTTP status codes with an error message in the response body.</p>
</div>
</div> </div>
</div> </div>
</div> </div>
+23 -10
View File
@@ -61,9 +61,9 @@
<tr class="border-b border-gray-600"> <tr class="border-b border-gray-600">
<th class="text-left p-2">Name</th> <th class="text-left p-2">Name</th>
<th class="text-left p-2">Email</th> <th class="text-left p-2">Email</th>
<th class="text-left p-2">Role</th> <th class="text-center p-2">Role</th>
{% if can_manage_users %} {% if can_manage_users %}
<th class="text-left p-2">Actions</th> <th class="text-center p-2">Actions</th>
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
@@ -72,18 +72,25 @@
<tr class="border-b border-gray-600"> <tr class="border-b border-gray-600">
<td class="p-2">{{ user[1] }}</td> <td class="p-2">{{ user[1] }}</td>
<td class="p-2">{{ user[2] }}</td> <td class="p-2">{{ user[2] }}</td>
<td class="p-2"> <td class="p-2 text-center">
<span class="px-2 py-1 bg-blue-200 dark:bg-blue-800 rounded">{{ user[4] or 'No Role' }}</span> <span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded">{{ user[4] or 'No Role' }}</span>
</td> </td>
{% if can_manage_users %} {% if can_manage_users %}
<td class="p-2"> <td class="p-2 text-center">
<button onclick="editUser({{ user[0] }}, '{{ user[1]|replace("'", "\\'") }}', '{{ user[2]|replace("'", "\\'") }}', {{ user[3] if user[3] else 'null' }})" class="text-blue-500 hover:text-blue-700 mr-4 hover:cursor-pointer" title="Edit User"> <button onclick="editUser({{ user[0] }}, '{{ user[1]|replace("'", "\\'") }}', '{{ user[2]|replace("'", "\\'") }}', {{ user[3] if user[3] else 'null' }}, '{{ user[5]|replace("'", "\\'") if user[5] else '' }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 mr-2 hover:cursor-pointer" title="Edit User">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to regenerate the API key for this user?');" class="inline mr-2">
<input type="hidden" name="action" value="regenerate_api_key">
<input type="hidden" name="user_id" value="{{ user[0] }}">
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Regenerate API Key">
<i class="fas fa-key"></i>
</button>
</form>
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');" class="inline"> <form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');" class="inline">
<input type="hidden" name="action" value="delete_user"> <input type="hidden" name="action" value="delete_user">
<input type="hidden" name="user_id" value="{{ user[0] }}"> <input type="hidden" name="user_id" value="{{ user[0] }}">
<button type="submit" class="text-red-500 hover:text-red-700 hover:cursor-pointer" title="Delete User"> <button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete User">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
</form> </form>
@@ -120,10 +127,10 @@
</div> </div>
{% if can_manage_roles %} {% if can_manage_roles %}
<div class="flex space-x-2"> <div class="flex space-x-2">
<button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}'); return false;" class="text-blue-500 hover:text-blue-700 hover:cursor-pointer" title="Edit Role"> <button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Role">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<button type="button" onclick="deleteRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}'); return false;" class="text-red-500 hover:text-red-700 hover:cursor-pointer" title="Delete Role"> <button type="button" onclick="deleteRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Role">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
</div> </div>
@@ -176,6 +183,11 @@
<option value="{{ role[0] }}">{{ role[1] }}</option> <option value="{{ role[0] }}">{{ role[1] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
<label class="text-sm font-semibold mb-2 block">API Key</label>
<code id="edit-user-api-key" class="text-xs font-mono break-all block bg-gray-200 dark:bg-zinc-800 px-2 py-1 rounded"></code>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">Use this API key to authenticate API requests. Keep it secure!</p>
</div>
<div class="flex justify-end space-x-2"> <div class="flex justify-end space-x-2">
<button type="button" onclick="closeEditUserModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button> <button type="button" onclick="closeEditUserModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save</button> <button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save</button>
@@ -347,12 +359,13 @@
} }
} }
function editUser(userId, name, email, roleId) { function editUser(userId, name, email, roleId, apiKey) {
document.getElementById('edit-user-id').value = userId; document.getElementById('edit-user-id').value = userId;
document.getElementById('edit-user-name').value = name; document.getElementById('edit-user-name').value = name;
document.getElementById('edit-user-email').value = email; document.getElementById('edit-user-email').value = email;
document.getElementById('edit-user-password').value = ''; document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId; document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
document.getElementById('edit-user-modal').classList.remove('hidden'); document.getElementById('edit-user-modal').classList.remove('hidden');
} }