This commit is contained in:
pika 2025-03-30 19:57:41 +02:00
parent 6dd38036e7
commit 097b3dbf09
34 changed files with 1719 additions and 520 deletions

View file

@ -22,15 +22,16 @@ def create_app(config_name='development'):
os.makedirs(os.path.join(app.instance_path), exist_ok=True)
# Initialize extensions
from app.core.extensions import db, bcrypt, limiter, login_manager, csrf
from app.core.extensions import db, migrate, login_manager, bcrypt, limiter, csrf
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
bcrypt.init_app(app)
limiter.init_app(app)
csrf.init_app(app)
limiter.init_app(app)
# Initialize login manager
from app.core.auth import User
login_manager.init_app(app)
@login_manager.user_loader
def load_user(user_id):

View file

@ -1,31 +1,34 @@
from flask_login import LoginManager, UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from .extensions import db
from .extensions import db, bcrypt
from datetime import datetime
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
username = db.Column(db.String(64), unique=True, index=True)
email = db.Column(db.String(120), unique=True, index=True)
password_hash = db.Column(db.String(128))
is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<User {self.username}>'
def set_password(self, password):
self.password_hash = generate_password_hash(password)
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
return check_password_hash(self.password_hash, password)
return bcrypt.check_password_hash(self.password_hash, password)
def get_id(self):
return str(self.id)
def __repr__(self):
return f'<User {self.email}>'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

View file

@ -1,28 +1,22 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_bcrypt import Bcrypt
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
# Initialize extensions
db = SQLAlchemy()
bcrypt = Bcrypt()
migrate = Migrate()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
csrf = CSRFProtect()
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'
# Initialize rate limiter with fallback storage
try:
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"],
storage_uri="memory://" # Use memory storage for development
)
except Exception as e:
print(f"Error initializing rate limiter: {e}")
# Fallback limiter with very basic functionality
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)
bcrypt = Bcrypt()
csrf = CSRFProtect()
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)

View file

@ -1,77 +1,93 @@
from .extensions import db
from app.core.extensions import db
import json
from datetime import datetime
import ipaddress
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
class Subnet(db.Model):
# User model has been moved to app.core.auth
# Import it from there instead if needed: from app.core.auth import User
class Port(db.Model):
__tablename__ = 'ports'
id = db.Column(db.Integer, primary_key=True)
cidr = db.Column(db.String(18), unique=True) # Format: "192.168.1.0/24"
location = db.Column(db.String(80))
auto_scan = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
app_id = db.Column(db.Integer, db.ForeignKey('apps.id', ondelete='CASCADE'), nullable=False)
port_number = db.Column(db.Integer, nullable=False)
protocol = db.Column(db.String(10), default='TCP') # TCP, UDP, etc.
description = db.Column(db.String(200))
@property
def network(self):
return ipaddress.ip_network(self.cidr)
@property
def num_addresses(self):
return self.network.num_addresses
@property
def used_ips(self):
# Count servers in this subnet
return Server.query.filter_by(subnet_id=self.id).count()
# Relationship
app = db.relationship('App', back_populates='ports')
def __repr__(self):
return f'<Subnet {self.cidr}>'
return f'<Port {self.port_number}/{self.protocol}>'
class Server(db.Model):
__tablename__ = 'servers'
id = db.Column(db.Integer, primary_key=True)
hostname = db.Column(db.String(80), unique=True)
ip_address = db.Column(db.String(15), unique=True)
subnet_id = db.Column(db.Integer, db.ForeignKey('subnet.id'))
subnet = db.relationship('Subnet', backref=db.backref('servers', lazy=True))
hostname = db.Column(db.String(64), nullable=False)
ip_address = db.Column(db.String(39), nullable=False) # IPv4 or IPv6
subnet_id = db.Column(db.Integer, db.ForeignKey('subnets.id'), nullable=False)
documentation = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Store ports as JSON in the database
_ports = db.Column(db.Text, default='[]')
@property
def ports(self):
return json.loads(self._ports) if self._ports else []
@ports.setter
def ports(self, value):
self._ports = json.dumps(value) if value else '[]'
def get_open_ports(self):
return self.ports
# Relationships
subnet = db.relationship('Subnet', back_populates='servers')
apps = db.relationship('App', back_populates='server', cascade='all, delete-orphan')
def __repr__(self):
return f'<Server {self.hostname}>'
class App(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80))
server_id = db.Column(db.Integer, db.ForeignKey('server.id'))
server = db.relationship('Server', backref=db.backref('apps', lazy=True))
documentation = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class Subnet(db.Model):
__tablename__ = 'subnets'
# Store ports as JSON in the database
_ports = db.Column(db.Text, default='[]')
id = db.Column(db.Integer, primary_key=True)
cidr = db.Column(db.String(18), unique=True, nullable=False) # e.g., 192.168.1.0/24
location = db.Column(db.String(64))
active_hosts = db.Column(db.Text) # Store as JSON string
last_scanned = db.Column(db.DateTime)
auto_scan = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
servers = db.relationship('Server', back_populates='subnet')
def __repr__(self):
return f'<Subnet {self.cidr}>'
@property
def ports(self):
return json.loads(self._ports) if self._ports else []
def used_ips(self):
"""Number of IPs used in this subnet (servers)"""
return len(self.servers)
@ports.setter
def ports(self, value):
self._ports = json.dumps(value) if value else '[]'
# Getter and setter for active_hosts as JSON
@property
def active_hosts_list(self):
if not self.active_hosts:
return []
return json.loads(self.active_hosts)
@active_hosts_list.setter
def active_hosts_list(self, hosts):
self.active_hosts = json.dumps(hosts)
class App(db.Model):
__tablename__ = 'apps'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), nullable=False)
server_id = db.Column(db.Integer, db.ForeignKey('servers.id'), nullable=False)
documentation = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
server = db.relationship('Server', back_populates='apps')
ports = db.relationship('Port', back_populates='app', cascade='all, delete-orphan')
def __repr__(self):
return f'<App {self.name}>'

View file

@ -0,0 +1,36 @@
import ipaddress
from flask import Blueprint
bp = Blueprint('filters', __name__)
@bp.app_template_filter('ip_network')
def ip_network_filter(cidr):
"""Convert a CIDR string to an IP network object"""
try:
return ipaddress.ip_network(cidr, strict=False)
except ValueError:
return None
@bp.app_template_filter('ip_address')
def ip_address_filter(ip):
"""Convert an IP string to an IP address object"""
try:
return ipaddress.ip_address(ip)
except ValueError:
return None
@bp.app_template_filter('markdown')
def markdown_filter(text):
"""Convert markdown text to HTML"""
import markdown
if text:
return markdown.markdown(text, extensions=['tables', 'fenced_code'])
return ""
@bp.app_template_global('get_ip_network')
def get_ip_network(cidr):
"""Global function to get an IP network object from CIDR"""
try:
return ipaddress.ip_network(cidr, strict=False)
except ValueError:
return None

View file

@ -1,8 +1,10 @@
from flask import Blueprint, jsonify, request, abort
from flask_login import login_required
from app.core.models import Subnet, Server, App
from app.core.models import Subnet, Server, App, Port
from app.core.extensions import db
from app.scripts.ip_scanner import scan
import random
import ipaddress
bp = Blueprint('api', __name__, url_prefix='/api')
@ -93,14 +95,24 @@ def get_servers():
@bp.route('/servers/<int:server_id>', methods=['GET'])
@login_required
def get_server(server_id):
"""Get details for a specific server"""
"""Get a specific server"""
server = Server.query.get_or_404(server_id)
apps = []
for app in App.query.filter_by(server_id=server_id).all():
for app in server.apps:
ports = []
for port in app.ports:
ports.append({
'id': port.id,
'port_number': port.port_number,
'protocol': port.protocol,
'description': port.description
})
apps.append({
'id': app.id,
'name': app.name,
'ports': ports,
'created_at': app.created_at.strftime('%Y-%m-%d %H:%M:%S')
})
@ -110,9 +122,8 @@ def get_server(server_id):
'ip_address': server.ip_address,
'subnet_id': server.subnet_id,
'documentation': server.documentation,
'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S'),
'ports': server.ports,
'apps': apps
'apps': apps,
'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S')
}
return jsonify(result)
@ -196,4 +207,103 @@ def suggest_ports():
return jsonify([
{'port': 80, 'type': 'tcp', 'desc': 'HTTP'},
{'port': 22, 'type': 'tcp', 'desc': 'SSH'}
])
])
@bp.route('/servers/<int:server_id>/suggest_port', methods=['GET'])
@login_required
def suggest_port(server_id):
"""Suggest a random unused port for a server"""
server = Server.query.get_or_404(server_id)
# Get all used ports for this server
used_ports = []
for app in server.apps:
for port in app.ports:
used_ports.append(port.port_number)
# Find an unused port in the dynamic/private port range
available_port = None
attempts = 0
while attempts < 50: # Try 50 times to find a random port
# Random port between 10000 and 65535
port = random.randint(10000, 65535)
if port not in used_ports:
available_port = port
break
attempts += 1
if available_port is None:
# If no random port found, find first available in sequence
for port in range(10000, 65536):
if port not in used_ports:
available_port = port
break
return jsonify({'port': available_port})
@bp.route('/apps/<int:app_id>/ports', methods=['GET'])
@login_required
def get_app_ports(app_id):
"""Get all ports for an app"""
app = App.query.get_or_404(app_id)
ports = []
for port in app.ports:
ports.append({
'id': port.id,
'port_number': port.port_number,
'protocol': port.protocol,
'description': port.description
})
return jsonify({'ports': ports})
@bp.route('/apps/<int:app_id>/ports', methods=['POST'])
@login_required
def add_app_port(app_id):
"""Add a new port to an app"""
app = App.query.get_or_404(app_id)
data = request.json
if not data or 'port_number' not in data:
return jsonify({'error': 'Missing port number'}), 400
port_number = data.get('port_number')
protocol = data.get('protocol', 'TCP')
description = data.get('description', '')
# Check if port already exists for this app
existing_port = Port.query.filter_by(app_id=app_id, port_number=port_number).first()
if existing_port:
return jsonify({'error': 'Port already exists for this app'}), 400
new_port = Port(
app_id=app_id,
port_number=port_number,
protocol=protocol,
description=description
)
db.session.add(new_port)
db.session.commit()
return jsonify({
'id': new_port.id,
'port_number': new_port.port_number,
'protocol': new_port.protocol,
'description': new_port.description
})
@bp.route('/ports/<int:port_id>', methods=['DELETE'])
@login_required
def delete_port(port_id):
"""Delete a port"""
port = Port.query.get_or_404(port_id)
db.session.delete(port)
db.session.commit()
return jsonify({'success': True})

View file

@ -8,7 +8,7 @@ bp = Blueprint('auth', __name__, url_prefix='/auth')
@bp.route('/login', methods=['GET', 'POST'])
def login():
# If already logged in, redirect to dashboard
"""User login"""
if current_user.is_authenticated:
return redirect(url_for('dashboard.dashboard_home'))
@ -19,58 +19,64 @@ def login():
user = User.query.filter_by(email=email).first()
if user and user.check_password(password):
login_user(user, remember=remember)
next_page = request.args.get('next')
if next_page:
return redirect(next_page)
return redirect(url_for('dashboard.dashboard_home'))
if not user or not user.check_password(password):
flash('Invalid email or password', 'danger')
return render_template('auth/login.html', title='Login')
flash('Invalid email or password', 'danger')
login_user(user, remember=remember)
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('dashboard.dashboard_home')
return redirect(next_page)
return render_template('auth/login.html', title='Login')
@bp.route('/register', methods=['GET', 'POST'])
def register():
# If already logged in, redirect to dashboard
"""User registration"""
if current_user.is_authenticated:
return redirect(url_for('dashboard.dashboard_home'))
if request.method == 'POST':
email = request.form.get('email')
username = request.form.get('username')
password = request.form.get('password')
password_confirm = request.form.get('password_confirm')
# Check if email already exists
existing_user = User.query.filter_by(email=email).first()
if existing_user:
flash('Email already registered', 'danger')
# Validation
if not email or not username or not password:
flash('All fields are required', 'danger')
return render_template('auth/register.html', title='Register')
# Check if passwords match
if password != password_confirm:
flash('Passwords do not match', 'danger')
if User.query.filter_by(email=email).first():
flash('Email already registered', 'danger')
return render_template('auth/register.html', title='Register')
if User.query.filter_by(username=username).first():
flash('Username already taken', 'danger')
return render_template('auth/register.html', title='Register')
# Create new user
user = User(email=email)
user = User(email=email, username=username)
user.set_password(password)
# Make first user an admin
if User.query.count() == 0:
user.is_admin = True
db.session.add(user)
db.session.commit()
flash('Registration successful! Please log in.', 'success')
return redirect(url_for('auth.login'))
flash('Registration successful! You are now logged in.', 'success')
# Auto-login after registration
login_user(user)
return redirect(url_for('dashboard.dashboard_home'))
return render_template('auth/register.html', title='Register')
@bp.route('/logout')
@login_required
def logout():
"""User logout"""
logout_user()
flash('You have been logged out', 'info')
return redirect(url_for('auth.login'))

View file

@ -1,7 +1,7 @@
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify
from flask_login import login_required, current_user
import markdown
from app.core.models import Server, App, Subnet
from app.core.models import Server, App, Subnet, Port
from app.core.extensions import db, limiter
from datetime import datetime
@ -196,22 +196,50 @@ def app_new():
server_id = request.form.get('server_id')
documentation = request.form.get('documentation', '')
# Get port data from form
port_numbers = request.form.getlist('port_numbers[]')
protocols = request.form.getlist('protocols[]')
port_descriptions = request.form.getlist('port_descriptions[]')
# Basic validation
if not name or not server_id:
flash('Please fill in all required fields', 'danger')
return render_template(
'dashboard/app_form.html',
title='New Application',
servers=servers,
now=datetime.now()
servers=servers
)
# Create new app
app = App(
name=name,
server_id=server_id,
documentation=documentation
)
db.session.add(app)
db.session.flush() # Get the app ID without committing
# Add ports if provided
for i in range(len(port_numbers)):
if port_numbers[i] and port_numbers[i].strip():
try:
port_num = int(port_numbers[i])
# Get protocol and description, handling index errors
protocol = protocols[i] if i < len(protocols) else 'TCP'
description = port_descriptions[i] if i < len(port_descriptions) else ''
new_port = Port(
app_id=app.id,
port_number=port_num,
protocol=protocol,
description=description
)
db.session.add(new_port)
except (ValueError, IndexError):
continue
db.session.commit()
flash('Application created successfully', 'success')
@ -220,8 +248,7 @@ def app_new():
return render_template(
'dashboard/app_form.html',
title='New Application',
servers=servers,
now=datetime.now()
servers=servers
)
@bp.route('/app/<int:app_id>', methods=['GET'])
@ -245,49 +272,69 @@ def app_view(app_id):
def app_edit(app_id):
"""Edit an existing application"""
app = App.query.get_or_404(app_id)
servers = Server.query.all()
if request.method == 'POST':
name = request.form.get('name')
server_id = request.form.get('server_id')
documentation = request.form.get('documentation', '')
# Process ports
ports = []
port_numbers = request.form.getlist('port[]')
port_types = request.form.getlist('port_type[]')
port_descs = request.form.getlist('port_desc[]')
# Get port data from form
port_numbers = request.form.getlist('port_numbers[]')
protocols = request.form.getlist('protocols[]')
port_descriptions = request.form.getlist('port_descriptions[]')
for i in range(len(port_numbers)):
if port_numbers[i]:
port = {
'port': int(port_numbers[i]),
'type': port_types[i] if i < len(port_types) else 'tcp',
'desc': port_descs[i] if i < len(port_descs) else '',
'status': 'open'
}
ports.append(port)
# Validate inputs
if not all([name, server_id]):
flash('All fields are required', 'danger')
return render_template('dashboard/app_form.html',
title='Edit Application',
app=app,
servers=servers,
edit_mode=True)
# Update app
app.name = name
app.server_id = server_id
app.documentation = documentation
app.ports = ports
db.session.commit()
# Delete existing ports and recreate them
# This simplifies handling additions, deletions, and updates
Port.query.filter_by(app_id=app.id).delete()
flash('Application updated successfully', 'success')
return redirect(url_for('dashboard.app_view', app_id=app.id))
# Add new ports
for i in range(len(port_numbers)):
if port_numbers[i] and port_numbers[i].strip():
try:
port_num = int(port_numbers[i])
# Get protocol and description, handling index errors
protocol = protocols[i] if i < len(protocols) else 'TCP'
description = port_descriptions[i] if i < len(port_descriptions) else ''
new_port = Port(
app_id=app.id,
port_number=port_num,
protocol=protocol,
description=description
)
db.session.add(new_port)
except (ValueError, IndexError):
continue
try:
db.session.commit()
flash(f'Application {name} has been updated', 'success')
return redirect(url_for('dashboard.server_view', server_id=app.server_id))
except Exception as e:
db.session.rollback()
flash(f'Error updating application: {str(e)}', 'danger')
# GET request - show form with current values
servers = Server.query.all()
return render_template(
'dashboard/app_edit.html',
title=f'Edit Application - {app.name}',
app=app,
servers=servers,
use_editor=True
)
return render_template('dashboard/app_form.html',
title='Edit Application',
app=app,
servers=servers,
edit_mode=True)
@bp.route('/app/<int:app_id>/delete', methods=['POST'])
@login_required

View file

@ -5,6 +5,7 @@ from app.core.extensions import db
from app.scripts.ip_scanner import scan
import ipaddress
from datetime import datetime
import json
bp = Blueprint('ipam', __name__, url_prefix='/ipam')
@ -16,7 +17,10 @@ def ipam_home():
# Calculate usage for each subnet
for subnet in subnets:
subnet.usage_percent = subnet.used_ips / 254 * 100 if subnet.cidr.endswith('/24') else 0
network = ipaddress.ip_network(subnet.cidr, strict=False)
max_hosts = network.num_addresses - 2 if network.prefixlen < 31 else network.num_addresses
used_count = Server.query.filter_by(subnet_id=subnet.id).count()
subnet.usage_percent = (used_count / max_hosts) * 100 if max_hosts > 0 else 0
return render_template(
'ipam/index.html',
@ -32,42 +36,43 @@ def subnet_new():
if request.method == 'POST':
cidr = request.form.get('cidr')
location = request.form.get('location')
auto_scan = 'auto_scan' in request.form
auto_scan = request.form.get('auto_scan') == 'on'
# Basic validation
if not cidr or not location:
flash('Please fill in all required fields', 'danger')
return render_template(
'ipam/subnet_form.html',
title='New Subnet',
now=datetime.now()
title='New Subnet'
)
# Check if valid CIDR
# Validate CIDR format
try:
ipaddress.ip_network(cidr)
ipaddress.ip_network(cidr, strict=False)
except ValueError:
flash('Invalid CIDR notation', 'danger')
flash('Invalid CIDR format', 'danger')
return render_template(
'ipam/subnet_form.html',
title='New Subnet',
now=datetime.now()
title='New Subnet'
)
# Check if subnet already exists
# Check if CIDR already exists
if Subnet.query.filter_by(cidr=cidr).first():
flash('Subnet already exists', 'danger')
return render_template(
'ipam/subnet_form.html',
title='New Subnet',
now=datetime.now()
title='New Subnet'
)
# Create new subnet with JSON string for active_hosts, not a Python list
subnet = Subnet(
cidr=cidr,
location=location,
active_hosts=json.dumps([]), # Convert empty list to JSON string
last_scanned=None,
auto_scan=auto_scan
)
db.session.add(subnet)
db.session.commit()
@ -76,70 +81,115 @@ def subnet_new():
return render_template(
'ipam/subnet_form.html',
title='New Subnet',
now=datetime.now()
title='New Subnet'
)
@bp.route('/subnet/<int:subnet_id>')
@login_required
def subnet_view(subnet_id):
"""View subnet details"""
"""View a specific subnet"""
subnet = Subnet.query.get_or_404(subnet_id)
# Get all servers in this subnet
servers = Server.query.filter_by(subnet_id=subnet_id).all()
# Get network info
network = ipaddress.ip_network(subnet.cidr)
total_ips = network.num_addresses - 2 # Excluding network and broadcast addresses
used_ips = len(servers)
usage_percent = (used_ips / total_ips) * 100 if total_ips > 0 else 0
# Parse CIDR for display
network = ipaddress.ip_network(subnet.cidr, strict=False)
subnet_info = {
'network_address': str(network.network_address),
'broadcast_address': str(network.broadcast_address),
'netmask': str(network.netmask),
'num_addresses': network.num_addresses,
'host_range': f"{str(network.network_address + 1)} - {str(network.broadcast_address - 1)}" if network.prefixlen < 31 else subnet.cidr
}
return render_template(
'ipam/subnet_view.html',
title=f'Subnet - {subnet.cidr}',
title=subnet.cidr,
subnet=subnet,
subnet_info=subnet_info,
servers=servers,
total_ips=total_ips,
used_ips=used_ips,
usage_percent=usage_percent,
now=datetime.now()
)
@bp.route('/subnet/<int:subnet_id>/scan')
@bp.route('/subnet/<int:subnet_id>/edit', methods=['GET', 'POST'])
@login_required
def subnet_edit(subnet_id):
"""Edit a subnet"""
subnet = Subnet.query.get_or_404(subnet_id)
if request.method == 'POST':
cidr = request.form.get('cidr')
location = request.form.get('location')
auto_scan = request.form.get('auto_scan') == 'on'
# Validate inputs
if not all([cidr, location]):
flash('All fields are required', 'danger')
return render_template('ipam/subnet_form.html',
title='Edit Subnet',
subnet=subnet,
edit_mode=True)
# Validate CIDR format
try:
ipaddress.ip_network(cidr, strict=False)
except ValueError:
flash('Invalid CIDR format', 'danger')
return render_template('ipam/subnet_form.html',
title='Edit Subnet',
subnet=subnet,
edit_mode=True)
# Update subnet
subnet.cidr = cidr
subnet.location = location
subnet.auto_scan = auto_scan
try:
db.session.commit()
flash(f'Subnet {cidr} has been updated', 'success')
return redirect(url_for('ipam.subnet_view', subnet_id=subnet.id))
except Exception as e:
db.session.rollback()
flash(f'Error updating subnet: {str(e)}', 'danger')
return render_template('ipam/subnet_form.html',
title='Edit Subnet',
subnet=subnet,
edit_mode=True)
@bp.route('/subnet/<int:subnet_id>/delete', methods=['POST'])
@login_required
def subnet_delete(subnet_id):
"""Delete a subnet"""
subnet = Subnet.query.get_or_404(subnet_id)
# Check if subnet has servers
servers_count = Server.query.filter_by(subnet_id=subnet_id).count()
if servers_count > 0:
flash(f'Cannot delete subnet {subnet.cidr}. It has {servers_count} servers assigned.', 'danger')
return redirect(url_for('ipam.subnet_view', subnet_id=subnet_id))
db.session.delete(subnet)
db.session.commit()
flash(f'Subnet {subnet.cidr} has been deleted', 'success')
return redirect(url_for('ipam.ipam_home'))
@bp.route('/subnet/<int:subnet_id>/scan', methods=['POST'])
@login_required
def subnet_scan(subnet_id):
"""Scan a subnet for active hosts"""
"""Manually scan a subnet"""
subnet = Subnet.query.get_or_404(subnet_id)
try:
results = scan(subnet.cidr, save_results=True)
flash(f'Scan completed for subnet {subnet.cidr}. Found {len(results)} active hosts.', 'success')
# Call the scan function with manual_trigger=True
scan(subnet, manual_trigger=True)
db.session.commit()
flash(f'Scan completed for subnet {subnet.cidr}', 'success')
except Exception as e:
flash(f'Error scanning subnet: {e}', 'danger')
db.session.rollback()
flash(f'Error scanning subnet: {str(e)}', 'danger')
return redirect(url_for('ipam.subnet_view', subnet_id=subnet_id))
@bp.route('/subnet/<int:subnet_id>/visualize')
@login_required
def subnet_visualize(subnet_id):
"""Visualize IP usage in a subnet"""
subnet = Subnet.query.get_or_404(subnet_id)
servers = Server.query.filter_by(subnet_id=subnet_id).all()
# Create a dictionary of used IPs
used_ips = {server.ip_address: server.hostname for server in servers}
# Get network info
network = ipaddress.ip_network(subnet.cidr)
total_ips = network.num_addresses - 2 # Excluding network and broadcast addresses
used_ip_count = len(servers)
return render_template(
'ipam/subnet_visualization.html',
title=f'Subnet Visualization - {subnet.cidr}',
subnet=subnet,
network=network,
used_ips=used_ips,
total_ips=total_ips,
used_ip_count=used_ip_count,
now=datetime.now()
)
return redirect(url_for('ipam.subnet_view', subnet_id=subnet_id))

View file

@ -1,23 +1,49 @@
from app.core.extensions import db
from app.core.models import Subnet, Server, App
from app.core.auth import User
from app.core.models import Subnet, Server, App, Port
from app.core.auth import User # Import User from auth module
import json
def seed_database():
# Example seed data for network objects
subnet = Subnet(cidr='192.168.1.0/24', location='Office', auto_scan=True)
server = Server(hostname='server1', ip_address='192.168.1.10', subnet=subnet)
app = App(name='Web App', server=server, documentation='# Welcome to Web App',
_ports='[{"port": 80, "type": "tcp", "desc": "Web"}]')
"""Add sample data to the database"""
# Create a default subnet if none exists
if Subnet.query.count() == 0:
subnet = Subnet(
cidr='192.168.1.0/24',
location='Office',
auto_scan=True,
active_hosts=json.dumps([])
)
db.session.add(subnet)
# Create a sample server
server = Server(
hostname='server1',
ip_address='192.168.1.10',
subnet=subnet,
documentation='# Server 1\n\nThis is a sample server.'
)
db.session.add(server)
# Create a sample app
app = App(
name='Web App',
server=server,
documentation='# Welcome to Web App\n\nThis is a sample application.'
)
db.session.add(app)
# Add some ports
ports = [
Port(app=app, port_number=80, protocol='TCP', description='HTTP'),
Port(app=app, port_number=443, protocol='TCP', description='HTTPS')
]
db.session.add_all(ports)
# Create a default user if none exists
if User.query.count() == 0:
admin = User(email="admin@example.com", is_admin=True)
admin.set_password("admin")
admin = User(username='admin', email='admin@example.com', is_admin=True)
admin.set_password('admin')
db.session.add(admin)
db.session.add(subnet)
db.session.add(server)
db.session.add(app)
try:
db.session.commit()

View file

@ -7,63 +7,41 @@ from app.core.extensions import db
from app.core.models import Subnet, Server
import json
import subprocess
import concurrent.futures
from datetime import datetime
import platform
def scan(cidr, max_threads=10, save_results=False):
def scan(subnet, manual_trigger=False):
"""
Scan a subnet for active hosts
Args:
cidr: The subnet in CIDR notation (e.g. "192.168.1.0/24")
max_threads: Maximum number of threads to use
save_results: Whether to save results to the database
Returns:
A list of dictionaries with IP, hostname, and status
subnet: The subnet object to scan
manual_trigger: If False, only scan if the subnet hasn't been scanned recently
"""
print(f"Starting scan of {cidr}")
network = ipaddress.ip_network(cidr)
# Skip if not auto scan and not manually triggered
if not subnet.auto_scan and not manual_trigger:
return False
# Skip network and broadcast addresses for IPv4
if network.version == 4:
hosts = list(network.hosts())
else:
# For IPv6, just take the first 100 addresses to avoid scanning too many
hosts = list(network.hosts())[:100]
active_hosts = []
# Split the hosts into chunks for multithreading
chunks = [[] for _ in range(max_threads)]
for i, host in enumerate(hosts):
chunks[i % max_threads].append(host)
# Initialize results
results = [[] for _ in range(max_threads)]
# Create and start threads
threads = []
for i in range(max_threads):
if chunks[i]: # Only start a thread if there are IPs to scan
t = threading.Thread(target=scan_worker, args=(chunks[i], results, i))
threads.append(t)
t.start()
# Wait for all threads to complete
for t in threads:
t.join()
# Combine results
all_results = []
for r in results:
all_results.extend(r)
# Save results to database if requested
if save_results:
try:
save_scan_results(cidr, all_results)
except Exception as e:
print(f"Error saving scan results: {e}")
print(f"Scan completed. Found {len(all_results)} active hosts.")
return all_results
try:
# Parse the CIDR notation
network = ipaddress.ip_network(subnet.cidr, strict=False)
# For each address in this network, ping it
for ip in network.hosts():
if ping(str(ip)):
active_hosts.append(str(ip))
# Update subnet with scan results
subnet.active_hosts = json.dumps(active_hosts)
subnet.last_scanned = datetime.utcnow()
return True
except Exception as e:
print(f"Error scanning subnet {subnet.cidr}: {str(e)}")
return False
def scan_worker(ip_list, results, index):
"""Worker function for threading"""
@ -76,13 +54,23 @@ def scan_worker(ip_list, results, index):
'status': 'up'
})
def ping(ip):
"""Ping an IP address and return True if it responds"""
def ping(host):
"""
Returns True if host responds to a ping request
"""
# Ping parameters based on OS
param = '-n' if platform.system().lower() == 'windows' else '-c'
# Build the command
command = ['ping', param, '1', '-w', '1', host]
try:
# Faster timeout (1 second)
subprocess.check_output(['ping', '-c', '1', '-W', '1', str(ip)], stderr=subprocess.STDOUT)
return True
except subprocess.CalledProcessError:
# Run the command and capture output
output = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=2)
# Return True if ping was successful
return output.returncode == 0
except subprocess.TimeoutExpired:
return False
except Exception:
return False
def get_hostname(ip):
@ -166,7 +154,7 @@ def schedule_subnet_scans():
# Start a thread for each subnet
thread = threading.Thread(
target=scan,
args=(subnet.cidr,),
args=(subnet,),
daemon=True
)
thread.start()

View file

@ -1,14 +1,35 @@
/* Custom styles for the app */
:root {
--background-color: #f5f8fa;
--text-color: #333;
--card-bg: #fff;
--border-color: #e3e8ee;
--sidebar-bg: #f0f2f5;
--sidebar-hover-bg: #e0e5ee;
--highlight-color: #3b82f6;
}
[data-bs-theme="dark"] {
--background-color: #1a2234;
--text-color: #e6e8eb;
--card-bg: #24304d;
--border-color: #374564;
--sidebar-bg: #151a27;
--sidebar-hover-bg: #1c2133;
--highlight-color: #3f8cff;
}
body {
background-color: var(--background-color);
color: var(--text-color);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f5f7fb;
color: #232e3c;
transition: background-color 0.3s ease;
}
.markdown-body {
padding: 1rem;
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.125);
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
}
@ -92,85 +113,160 @@ body {
/* Sidebar styles */
.sidebar {
width: 260px;
background-color: var(--sidebar-bg);
color: var(--text-color);
height: 100vh;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
background: #fff;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .15);
width: 250px;
z-index: 1000;
overflow-y: auto;
transition: all 0.3s;
}
.sidebar-brand {
padding: 1.5rem;
padding: 1.5rem 1rem;
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
height: 64px;
color: var(--text-color);
}
.sidebar-brand-text {
font-size: 1.25rem;
font-weight: 600;
margin-left: 0.75rem;
}
.sidebar-nav {
padding: 0.75rem 1.5rem;
margin-left: 0.5rem;
}
.sidebar-heading {
padding: 0.75rem 1rem 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: #8898aa;
letter-spacing: 0.04em;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-weight: 600;
color: var(--text-color);
opacity: 0.6;
}
.sidebar-item {
display: block;
padding: 0.675rem 1.2rem;
font-size: 0.875rem;
color: #525f7f;
border-radius: 0.375rem;
margin-bottom: 0.25rem;
font-weight: 500;
transition: all 0.15s ease;
padding: 0.5rem 1rem;
color: var(--text-color);
text-decoration: none;
border-radius: 0.25rem;
margin: 0.2rem 0.5rem;
transition: background-color 0.2s;
}
.sidebar-item:hover {
color: #5e72e4;
background: rgba(94, 114, 228, 0.1);
text-decoration: none;
background-color: var(--sidebar-hover-bg);
color: var(--text-color);
}
.sidebar-item.active {
color: #5e72e4;
background: rgba(94, 114, 228, 0.1);
background-color: var(--highlight-color);
color: white;
}
/* Main content */
.main-content {
margin-left: 250px;
padding: 1rem;
min-height: 100vh;
}
/* Header styles */
.page-header {
margin-bottom: 1.5rem;
}
.page-title {
font-weight: 600;
}
.main-content {
margin-left: 260px;
.page-pretitle {
color: #6c757d;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.05em;
}
/* Responsive sidebar */
@media (max-width: 992px) {
/* Auth pages */
body.auth-page {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
background-color: var(--background-color);
}
.auth-form {
max-width: 450px;
margin: 0 auto;
}
/* Port visualization */
.port-map {
overflow-x: auto;
}
.port-map-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
}
.port-item {
padding: 4px;
font-size: 10px;
text-align: center;
border-radius: 3px;
cursor: pointer;
user-select: none;
}
.port-item:hover {
opacity: 0.8;
}
/* Responsive tweaks */
@media (max-width: 768px) {
.sidebar {
left: -260px;
transition: left 0.3s ease;
transform: translateX(-100%);
}
.sidebar.show {
left: 0;
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
.main-content.sidebar-open {
margin-left: 250px;
}
}
/* Theme switch */
#theme-toggle {
width: 38px;
height: 38px;
position: relative;
}
.theme-icon-light {
display: none;
}
.theme-icon-dark {
display: inline-block;
}
[data-bs-theme="dark"] .theme-icon-light {
display: inline-block;
}
[data-bs-theme="dark"] .theme-icon-dark {
display: none;
}
/* Notification area */

View file

@ -58,6 +58,24 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
});
// Wait for DOM to be fully loaded
document.addEventListener('DOMContentLoaded', function () {
// Initialize theme toggle
initThemeToggle();
// Initialize clipboard functionality
initClipboard();
// Initialize port map tooltips
initTooltips();
// Initialize mobile sidebar
initMobileSidebar();
// Initialize notifications
initNotifications();
});
});
function initTiptapEditor(element) {
@ -134,9 +152,108 @@ function showNotification(message, type = 'info') {
notificationArea.appendChild(notification);
// Remove notification after 3 seconds
// Auto-remove after 5 seconds
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => notification.remove(), 150);
}, 3000);
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}
function initThemeToggle() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function () {
const currentTheme = document.documentElement.getAttribute('data-bs-theme') || 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-bs-theme', newTheme);
localStorage.setItem('theme', newTheme);
console.log(`Theme switched to ${newTheme} mode`);
});
}
// Load saved theme or use OS preference
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
document.documentElement.setAttribute('data-bs-theme', storedTheme);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark');
localStorage.setItem('theme', 'dark');
}
}
function initClipboard() {
// Add click handlers to any clipboard copy buttons
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', function () {
const textToCopy = this.getAttribute('data-clipboard-text');
if (textToCopy) {
navigator.clipboard.writeText(textToCopy)
.then(() => {
showNotification('Copied to clipboard!', 'success');
})
.catch(err => {
console.error('Failed to copy: ', err);
});
}
});
});
}
function initTooltips() {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
if (tooltips.length > 0) {
Array.from(tooltips).map(tooltipNode => new bootstrap.Tooltip(tooltipNode));
}
}
function initMobileSidebar() {
// Sidebar toggle for mobile
const sidebarToggler = document.querySelector('.sidebar-toggler');
if (sidebarToggler) {
sidebarToggler.addEventListener('click', function () {
document.querySelector('.sidebar').classList.toggle('show');
document.querySelector('.main-content').classList.toggle('sidebar-open');
});
}
}
function initNotifications() {
// Add flash messages as notifications
const flashMessages = document.querySelectorAll('.alert.flash-message');
flashMessages.forEach(message => {
setTimeout(() => {
const bsAlert = new bootstrap.Alert(message);
bsAlert.close();
}, 5000);
});
}
// For random port suggestion
async function suggestRandomPort(serverId) {
try {
const response = await fetch(`/api/servers/${serverId}/suggest_port`);
if (!response.ok) throw new Error('Failed to get port suggestion');
const data = await response.json();
if (data.port) {
// Copy to clipboard
navigator.clipboard.writeText(data.port.toString())
.then(() => {
showNotification(`Port ${data.port} copied to clipboard!`, 'success');
})
.catch(err => {
console.error('Failed to copy: ', err);
showNotification(`Suggested free port: ${data.port}`, 'info');
});
}
return data.port;
} catch (error) {
console.error('Error:', error);
showNotification('Failed to suggest port', 'danger');
return null;
}
}

View file

@ -0,0 +1,213 @@
{% extends "layout.html" %}
{% block content %}
<div class="container-xl">
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">
Edit Application: {{ app.name }}
</h2>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('dashboard.app_edit', app_id=app.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label required">Application Name</label>
<input type="text" class="form-control" name="name" value="{{ app.name }}" required>
</div>
<div class="mb-3">
<label class="form-label required">Server</label>
<select class="form-select" name="server_id" required>
<option value="">Select a server</option>
{% for server in servers %}
<option value="{{ server.id }}" {% if server.id==app.server_id %}selected{% endif %}>
{{ server.hostname }} ({{ server.ip_address }})
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Documentation</label>
<textarea class="form-control" name="documentation" rows="6"
placeholder="Use Markdown for formatting">{{ app.documentation }}</textarea>
<small class="form-hint">Supports Markdown formatting</small>
</div>
<!-- Port management section -->
<div class="mb-3">
<label class="form-label">Application Ports</label>
<div id="ports-container">
{% if app.ports %}
{% for port in app.ports %}
<div class="port-entry mb-2 row">
<div class="col-4">
<input type="number" class="form-control" name="port_numbers[]" value="{{ port.port_number }}"
placeholder="Port number" min="1" max="65535">
</div>
<div class="col-3">
<select class="form-select" name="protocols[]">
<option value="TCP" {% if port.protocol=='TCP' %}selected{% endif %}>TCP</option>
<option value="UDP" {% if port.protocol=='UDP' %}selected{% endif %}>UDP</option>
<option value="HTTP" {% if port.protocol=='HTTP' %}selected{% endif %}>HTTP</option>
<option value="HTTPS" {% if port.protocol=='HTTPS' %}selected{% endif %}>HTTPS</option>
</select>
</div>
<div class="col-4">
<input type="text" class="form-control" name="port_descriptions[]" value="{{ port.description }}"
placeholder="Description">
</div>
<div class="col-1">
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
</div>
</div>
{% endfor %}
{% else %}
<div class="port-entry mb-2 row">
<div class="col-4">
<input type="number" class="form-control" name="port_numbers[]" placeholder="Port number" min="1"
max="65535">
</div>
<div class="col-3">
<select class="form-select" name="protocols[]">
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
<option value="HTTP">HTTP</option>
<option value="HTTPS">HTTPS</option>
</select>
</div>
<div class="col-4">
<input type="text" class="form-control" name="port_descriptions[]" placeholder="Description">
</div>
<div class="col-1">
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
</div>
</div>
{% endif %}
</div>
<div class="mt-2">
<button type="button" id="add-port" class="btn btn-sm btn-outline-primary">
<i class="ti ti-plus me-1"></i> Add Port
</button>
<button type="button" id="suggest-port" class="btn btn-sm btn-outline-secondary ms-2">
<i class="ti ti-bolt me-1"></i> Suggest Free Port
</button>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<a href="{{ url_for('dashboard.server_view', server_id=app.server_id) }}" class="btn btn-outline-secondary">
Cancel
</a>
<button type="submit" class="btn btn-primary">
Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const portsContainer = document.getElementById('ports-container');
const addPortButton = document.getElementById('add-port');
const suggestPortButton = document.getElementById('suggest-port');
// Add new port field
addPortButton.addEventListener('click', function () {
const portEntry = document.createElement('div');
portEntry.className = 'port-entry mb-2 row';
portEntry.innerHTML = `
<div class="col-4">
<input type="number" class="form-control" name="port_numbers[]" placeholder="Port number" min="1" max="65535">
</div>
<div class="col-3">
<select class="form-select" name="protocols[]">
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
<option value="HTTP">HTTP</option>
<option value="HTTPS">HTTPS</option>
</select>
</div>
<div class="col-4">
<input type="text" class="form-control" name="port_descriptions[]" placeholder="Description">
</div>
<div class="col-1">
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
</div>
`;
portsContainer.appendChild(portEntry);
// Add event listener to the new remove button
const removeButton = portEntry.querySelector('.remove-port');
removeButton.addEventListener('click', function () {
portEntry.remove();
});
});
// Add event listeners to initial remove buttons
document.querySelectorAll('.remove-port').forEach(button => {
button.addEventListener('click', function () {
this.closest('.port-entry').remove();
});
});
// Suggest a free port
suggestPortButton.addEventListener('click', async function () {
try {
const serverId = document.querySelector('select[name="server_id"]').value;
if (!serverId) {
alert('Please select a server first');
return;
}
const response = await fetch(`/api/servers/${serverId}/suggest_port`);
if (!response.ok) throw new Error('Failed to get port suggestion');
const data = await response.json();
if (data.port) {
// Find the first empty port input or add a new one
let portInput = document.querySelector('input[name="port_numbers[]"]:not([value])');
if (!portInput) {
addPortButton.click();
portInput = document.querySelector('input[name="port_numbers[]"]:not([value])');
}
portInput.value = data.port;
// Copy to clipboard
navigator.clipboard.writeText(data.port.toString())
.then(() => {
showNotification('Port copied to clipboard!', 'success');
})
.catch(err => {
console.error('Failed to copy: ', err);
});
}
} catch (error) {
console.error('Error:', error);
showNotification('Failed to suggest port', 'danger');
}
});
});
</script>
{% endblock %}

View file

@ -46,12 +46,135 @@
placeholder="Use Markdown for formatting"></textarea>
<small class="form-hint">Supports Markdown formatting</small>
</div>
<div class="d-flex justify-content-end">
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-link me-2">Cancel</a>
<!-- Port management section -->
<div class="mb-3">
<label class="form-label">Application Ports</label>
<div id="ports-container">
<div class="port-entry mb-2 row">
<div class="col-4">
<input type="number" class="form-control" name="port_numbers[]" placeholder="Port number" min="1"
max="65535">
</div>
<div class="col-3">
<select class="form-select" name="protocols[]">
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
<option value="HTTP">HTTP</option>
<option value="HTTPS">HTTPS</option>
</select>
</div>
<div class="col-4">
<input type="text" class="form-control" name="port_descriptions[]" placeholder="Description">
</div>
<div class="col-1">
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
</div>
</div>
</div>
<div class="mt-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="add-port">
<i class="ti ti-plus me-1"></i> Add Port
</button>
<button type="button" class="btn btn-outline-secondary btn-sm ms-2" id="suggest-port">
<i class="ti ti-bulb me-1"></i> Suggest Free Port
</button>
</div>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">Save Application</button>
<a href="{{ url_for('dashboard.dashboard_home') }}" class="btn btn-outline-secondary ms-2">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const portsContainer = document.getElementById('ports-container');
const addPortButton = document.getElementById('add-port');
const suggestPortButton = document.getElementById('suggest-port');
// Add a new port field
addPortButton.addEventListener('click', function () {
const portEntry = document.createElement('div');
portEntry.className = 'port-entry mb-2 row';
portEntry.innerHTML = `
<div class="col-4">
<input type="number" class="form-control" name="port_numbers[]" placeholder="Port number" min="1" max="65535">
</div>
<div class="col-3">
<select class="form-select" name="protocols[]">
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
<option value="HTTP">HTTP</option>
<option value="HTTPS">HTTPS</option>
</select>
</div>
<div class="col-4">
<input type="text" class="form-control" name="port_descriptions[]" placeholder="Description">
</div>
<div class="col-1">
<button type="button" class="btn btn-outline-danger remove-port"><i class="ti ti-trash"></i></button>
</div>
`;
portsContainer.appendChild(portEntry);
// Add event listener to the new remove button
const removeButton = portEntry.querySelector('.remove-port');
removeButton.addEventListener('click', function () {
portEntry.remove();
});
});
// Add event listeners to initial remove buttons
document.querySelectorAll('.remove-port').forEach(button => {
button.addEventListener('click', function () {
this.closest('.port-entry').remove();
});
});
// Suggest a free port
suggestPortButton.addEventListener('click', async function () {
try {
const serverId = document.querySelector('select[name="server_id"]').value;
if (!serverId) {
alert('Please select a server first');
return;
}
const response = await fetch(`/api/servers/${serverId}/suggest_port`);
if (!response.ok) throw new Error('Failed to get port suggestion');
const data = await response.json();
if (data.port) {
// Find the first empty port input or add a new one
let portInput = document.querySelector('input[name="port_numbers[]"]:not([value])');
if (!portInput) {
addPortButton.click();
portInput = document.querySelector('input[name="port_numbers[]"]:not([value])');
}
portInput.value = data.port;
// Copy to clipboard
navigator.clipboard.writeText(data.port.toString())
.then(() => {
showNotification('Port copied to clipboard!', 'success');
})
.catch(err => {
console.error('Failed to copy: ', err);
});
}
} catch (error) {
console.error('Error:', error);
showNotification('Failed to suggest port', 'danger');
}
});
});
</script>
{% endblock %}

View file

@ -5,133 +5,201 @@
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">
Server Details
</div>
<h2 class="page-title">
{{ server.hostname }}
</h2>
<div class="text-muted mt-1">{{ server.ip_address }}</div>
</div>
<div class="col-auto ms-auto d-print-none">
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-link">
Back to Servers
</a>
<div class="btn-list">
<a href="{{ url_for('dashboard.server_edit', server_id=server.id) }}" class="btn btn-primary">
<i class="ti ti-edit me-1"></i> Edit
</a>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteServerModal">
<i class="ti ti-trash me-1"></i> Delete
</button>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">Documentation</h3>
</div>
<div class="card-body markdown-body">
{% if server.documentation %}
{{ markdown(server.documentation)|safe }}
{% else %}
<div class="text-center text-muted py-3">
No documentation available for this server.
</div>
{% endif %}
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h3 class="card-title">Applications</h3>
<a href="{{ url_for('dashboard.app_new') }}" class="btn btn-sm btn-primary">
Add Application
</a>
</div>
</div>
<div class="card-body">
{% if apps %}
<table class="table table-vcenter">
<thead>
<tr>
<th>Name</th>
<th>Ports</th>
</tr>
</thead>
<tbody>
{% for app in apps %}
<tr>
<td>{{ app.name }}</td>
<td>
{% for port in app.ports %}
<span class="badge bg-primary">
{{ port.port }}/{{ port.type }} {% if port.desc %}({{ port.desc }}){% endif %}
</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-3">
<div class="mb-3">No applications registered for this server</div>
<a href="{{ url_for('dashboard.app_new') }}" class="btn btn-outline-primary">
Add Application
</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3 class="card-title">Server Information</h3>
</div>
<div class="card-body">
<div class="mb-2">
<strong>Hostname:</strong> {{ server.hostname }}
</div>
<div class="mb-2">
<strong>IP Address:</strong> {{ server.ip_address }}
</div>
<div class="mb-2">
<strong>Subnet:</strong> {{ server.subnet.cidr if server.subnet else 'N/A' }}
</div>
<div class="mb-2">
<strong>Location:</strong> {{ server.subnet.location if server.subnet else 'N/A' }}
</div>
<div class="mb-2">
<strong>Created:</strong> {{ server.created_at.strftime('%Y-%m-%d') }}
</div>
<dl class="row">
<dt class="col-5">IP Address:</dt>
<dd class="col-7">{{ server.ip_address }}</dd>
<dt class="col-5">Subnet:</dt>
<dd class="col-7">
<a href="{{ url_for('ipam.subnet_view', subnet_id=server.subnet.id) }}">
{{ server.subnet.cidr }}
</a>
</dd>
<dt class="col-5">Location:</dt>
<dd class="col-7">{{ server.subnet.location }}</dd>
<dt class="col-5">Created:</dt>
<dd class="col-7">{{ server.created_at.strftime('%Y-%m-%d') }}</dd>
</dl>
</div>
</div>
<!-- Port Usage Map -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Open Ports</h3>
<div class="card-header d-flex align-items-center">
<h3 class="card-title">Port Usage</h3>
<div class="ms-auto">
<button id="get-random-port" class="btn btn-sm btn-outline-primary">
<i class="ti ti-clipboard-copy me-1"></i> Get Free Port
</button>
</div>
</div>
<div class="card-body">
{% if server.get_open_ports() %}
<div class="list-group list-group-flush">
{% for port in server.get_open_ports() %}
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-auto">
<span class="badge bg-primary">{{ port.port }}</span>
</div>
<div class="col">
<div class="text-truncate">
{{ port.type|upper }}
{% if port.desc %}
<span class="text-muted">{{ port.desc }}</span>
{% endif %}
<div class="port-map">
<div class="port-map-grid">
{% for i in range(1, 101) %}
{% set port_num = 8000 + i - 1 %}
{% set port_used = false %}
{% set port_app = "" %}
{% set port_color = "" %}
{% set tooltip = "" %}
{% for app in server.apps %}
{% for port in app.ports %}
{% if port.port_number == port_num %}
{% set port_used = true %}
{% set port_app = app.name %}
{% set port_color = "bg-" ~ ["primary", "success", "info", "warning", "danger"][(app.id % 5)] %}
{% set tooltip = app.name ~ " - " ~ port.description %}
{% endif %}
{% endfor %}
{% endfor %}
<div class="port-item {{ port_color if port_used else 'bg-light' }}" data-port="{{ port_num }}"
data-bs-toggle="tooltip" title="{{ tooltip if port_used else 'Free port: ' ~ port_num }}">
{{ port_num }}
</div>
{% endfor %}
</div>
</div>
<div class="mt-2 text-muted small">
<div class="d-flex flex-wrap">
<div class="me-3"><span class="badge bg-light">Port</span> Free</div>
<div><span class="badge bg-primary">Port</span> Used</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-8">
<!-- Applications -->
<div class="card">
<div class="card-header d-flex align-items-center">
<h3 class="card-title">Applications</h3>
<div class="ms-auto">
<a href="{{ url_for('dashboard.app_new', server_id=server.id) }}" class="btn btn-sm btn-primary">
<i class="ti ti-plus me-1"></i> Add Application
</a>
</div>
</div>
<div class="card-body">
{% if server.apps %}
<div class="accordion" id="applicationAccordion">
{% for app in server.apps %}
<div class="accordion-item">
<h2 class="accordion-header" id="heading{{ app.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapse{{ app.id }}" aria-expanded="false" aria-controls="collapse{{ app.id }}">
<span class="me-2">{{ app.name }}</span>
{% if app.ports %}
<div class="ms-auto d-flex">
{% for port in app.ports %}
<span class="badge bg-primary me-1">{{ port.port_number }}/{{ port.protocol }}</span>
{% endfor %}
</div>
{% endif %}
</button>
</h2>
<div id="collapse{{ app.id }}" class="accordion-collapse collapse" aria-labelledby="heading{{ app.id }}"
data-bs-parent="#applicationAccordion">
<div class="accordion-body">
<div class="d-flex justify-content-end mb-2">
<a href="{{ url_for('dashboard.app_edit', app_id=app.id) }}"
class="btn btn-sm btn-outline-primary me-2">
<i class="ti ti-edit"></i> Edit
</a>
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="confirmDeleteApp({{ app.id }}, '{{ app.name }}')">
<i class="ti ti-trash"></i> Delete
</button>
</div>
<!-- Ports -->
{% if app.ports %}
<div class="mb-3">
<h5>Ports</h5>
<div class="table-responsive">
<table class="table table-vcenter table-sm">
<thead>
<tr>
<th>Port</th>
<th>Protocol</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for port in app.ports %}
<tr>
<td>{{ port.port_number }}</td>
<td>{{ port.protocol }}</td>
<td>{{ port.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Documentation -->
{% if app.documentation %}
<div class="mt-3">
<h5>Documentation</h5>
<div class="markdown-body">
{{ app.documentation|markdown }}
</div>
</div>
{% else %}
<div class="text-muted">No documentation available</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-3">
No open ports detected.
<div class="empty">
<div class="empty-icon">
<i class="ti ti-apps"></i>
</div>
<p class="empty-title">No applications found</p>
<p class="empty-subtitle text-muted">
This server doesn't have any applications yet.
</p>
<div class="empty-action">
<a href="{{ url_for('dashboard.app_new', server_id=server.id) }}" class="btn btn-primary">
<i class="ti ti-plus me-1"></i> Add Application
</a>
</div>
</div>
{% endif %}
</div>
@ -139,4 +207,135 @@
</div>
</div>
</div>
<!-- Delete Server Modal -->
<div class="modal fade" id="deleteServerModal" tabindex="-1" aria-labelledby="deleteServerModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteServerModalLabel">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete server <strong>{{ server.hostname }}</strong>? This action cannot be undone.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form action="{{ url_for('dashboard.server_delete', server_id=server.id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">Delete Server</button>
</form>
</div>
</div>
</div>
</div>
<!-- Delete App Modal (created dynamically) -->
<div class="modal fade" id="deleteAppModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="deleteAppModalBody">
Are you sure you want to delete this application?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form id="deleteAppForm" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">Delete Application</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Initialize tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Random port generator
const getRandomPortBtn = document.getElementById('get-random-port');
if (getRandomPortBtn) {
getRandomPortBtn.addEventListener('click', async function () {
try {
const response = await fetch(`/api/servers/{{ server.id }}/suggest_port`);
if (!response.ok) throw new Error('Failed to get port suggestion');
const data = await response.json();
if (data.port) {
// Copy to clipboard
navigator.clipboard.writeText(data.port.toString())
.then(() => {
showNotification(`Port ${data.port} copied to clipboard!`, 'success');
})
.catch(err => {
console.error('Failed to copy: ', err);
showNotification(`Suggested free port: ${data.port}`, 'info');
});
}
} catch (error) {
console.error('Error:', error);
showNotification('Failed to suggest port', 'danger');
}
});
}
});
// Function to handle app deletion confirmation
function confirmDeleteApp(appId, appName) {
const modal = document.getElementById('deleteAppModal');
const modalBody = document.getElementById('deleteAppModalBody');
const deleteForm = document.getElementById('deleteAppForm');
modalBody.textContent = `Are you sure you want to delete application "${appName}"? This action cannot be undone.`;
deleteForm.action = `/dashboard/apps/${appId}/delete`;
const bsModal = new bootstrap.Modal(modal);
bsModal.show();
}
</script>
{% endblock %}
{% block styles %}
<style>
.port-map {
overflow-x: auto;
}
.port-map-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
}
.port-item {
padding: 4px;
font-size: 10px;
text-align: center;
border-radius: 3px;
cursor: pointer;
user-select: none;
}
.port-item:hover {
opacity: 0.8;
}
.markdown-body {
padding: 1rem;
background-color: var(--tblr-bg-surface);
border: 1px solid var(--tblr-border-color);
border-radius: 4px;
}
</style>
{% endblock %}

View file

@ -2,13 +2,13 @@
{% block content %}
<div class="container text-center py-5">
<div class="display-1 text-muted mb-3">404</div>
<div class="display-1 text-muted mb-5">404</div>
<h1 class="h2 mb-3">Page not found</h1>
<p class="h4 text-muted font-weight-normal mb-4">
We are sorry but the page you are looking for was not found.
We are sorry but the page you are looking for was not found
</p>
<a href="{{ url_for('dashboard.dashboard_home') }}" class="btn btn-primary">
<i class="ti ti-arrow-left me-2"></i> Return to dashboard
<i class="ti ti-arrow-left me-2"></i>Go back to dashboard
</a>
</div>
{% endblock %}

View file

@ -2,13 +2,13 @@
{% block content %}
<div class="container text-center py-5">
<div class="display-1 text-muted mb-3">500</div>
<h1 class="h2 mb-3">Internal Server Error</h1>
<div class="display-1 text-muted mb-5">500</div>
<h1 class="h2 mb-3">Server Error</h1>
<p class="h4 text-muted font-weight-normal mb-4">
Something went wrong on our end. Please try again later.
Oops, something went wrong on our end
</p>
<a href="{{ url_for('dashboard.dashboard_home') }}" class="btn btn-primary">
<i class="ti ti-arrow-left me-2"></i> Return to dashboard
<i class="ti ti-arrow-left me-2"></i>Go back to dashboard
</a>
</div>
{% endblock %}

View file

@ -5,55 +5,129 @@
<div class="page-header d-print-none">
<div class="row align-items-center">
<div class="col">
<div class="page-pretitle">
Subnet Details
</div>
<h2 class="page-title">
{{ subnet.cidr }}
{{ subnet.cidr }} - {{ subnet.location }}
</h2>
<div class="text-muted mt-1">{{ subnet.location }}</div>
</div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-list">
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
<i class="fas fa-search me-2"></i> Scan Subnet
</a>
<a href="{{ url_for('ipam.subnet_visualize', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
<i class="fas fa-chart-network me-2"></i> Visualize
</a>
<a href="{{ url_for('ipam.ipam_home') }}" class="btn btn-link">
Back to IPAM
<a href="{{ url_for('ipam.subnet_edit', subnet_id=subnet.id) }}" class="btn btn-primary">
<i class="ti ti-edit me-1"></i> Edit
</a>
<form method="POST" action="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-warning">
<i class="ti ti-search me-1"></i> Scan Now
</button>
</form>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteSubnetModal">
<i class="ti ti-trash me-1"></i> Delete
</button>
</div>
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show mt-3" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row mt-3">
<div class="col-md-8">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Registered Hosts</h3>
<h3 class="card-title">Subnet Information</h3>
</div>
<div class="card-body">
{% if servers %}
<table class="table table-vcenter">
<tr>
<td><strong>CIDR Notation</strong></td>
<td>{{ subnet.cidr }}</td>
</tr>
<tr>
<td><strong>Location</strong></td>
<td>{{ subnet.location }}</td>
</tr>
{% set network = get_ip_network(subnet.cidr) %}
{% if network %}
<tr>
<td><strong>Network Address</strong></td>
<td>{{ network.network_address }}</td>
</tr>
<tr>
<td><strong>Broadcast Address</strong></td>
<td>{{ network.broadcast_address if network.prefixlen < 31 else 'N/A' }}</td>
</tr>
<tr>
<td><strong>Netmask</strong></td>
<td>{{ network.netmask }}</td>
</tr>
<tr>
<td><strong>Host Range</strong></td>
<td>
{% if network.prefixlen < 31 %} {{ network.network_address + 1 }} - {{ network.broadcast_address - 1 }}
{% else %} {{ network.network_address }} - {{ network.broadcast_address }} {% endif %} </td>
</tr>
<tr>
<td><strong>Total Hosts</strong></td>
<td>
{% if network.prefixlen < 31 %} {{ network.num_addresses - 2 }} {% else %} {{ network.num_addresses }}
{% endif %} </td>
</tr>
{% endif %}
<tr>
<td><strong>Auto Scan</strong></td>
<td>{{ 'Yes' if subnet.auto_scan else 'No' }}</td>
</tr>
<tr>
<td><strong>Last Scanned</strong></td>
<td>{{ subnet.last_scanned|default('Never', true) }}</td>
</tr>
<tr>
<td><strong>Created</strong></td>
<td>{{ subnet.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Servers in Subnet</h3>
</div>
<div class="card-body">
{% if subnet.servers %}
<div class="table-responsive">
<table class="table table-vcenter">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Hostname</th>
<th>IP Address</th>
<th>Created</th>
<th class="w-1"></th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for server in servers %}
{% for server in subnet.servers %}
<tr>
<td>{{ server.hostname }}</td>
<td><a href="{{ url_for('dashboard.server_view', server_id=server.id) }}">{{ server.hostname }}</a>
</td>
<td>{{ server.ip_address }}</td>
<td>{{ server.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('dashboard.server_view', server_id=server.id) }}"
class="btn btn-sm btn-primary">
View
<i class="ti ti-eye"></i>
</a>
</td>
</tr>
@ -62,69 +136,51 @@
</table>
</div>
{% else %}
<div class="text-center py-3">
<div class="mb-3">No hosts registered in this subnet</div>
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-outline-primary">
Add New Server
</a>
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary ms-2">
Scan Subnet
</a>
<div class="empty">
<div class="empty-icon">
<i class="ti ti-server"></i>
</div>
<p class="empty-title">No servers in this subnet</p>
<p class="empty-subtitle text-muted">
You can add a new server to this subnet from the dashboard
</p>
<div class="empty-action">
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-primary">
<i class="ti ti-plus me-2"></i> Add New Server
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3 class="card-title">Subnet Information</h3>
</div>
<div class="card-body">
<div class="mb-2">
<strong>Network:</strong> {{ subnet.cidr }}
</div>
<div class="mb-2">
<strong>Location:</strong> {{ subnet.location }}
</div>
<div class="mb-2">
<strong>Total IPs:</strong> {{ total_ips }}
</div>
<div class="mb-2">
<strong>Used IPs:</strong> {{ used_ips }} ({{ '%.1f'|format(usage_percent) }}%)
</div>
<div class="mb-2">
<strong>Available IPs:</strong> {{ total_ips - used_ips }}
</div>
<div class="mb-2">
<strong>Auto Scan:</strong>
{% if subnet.auto_scan %}
<span class="badge bg-success">Enabled</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</div>
<div class="mb-2">
<strong>Created:</strong> {{ subnet.created_at.strftime('%Y-%m-%d') }}
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteSubnetModal" tabindex="-1" aria-labelledby="deleteSubnetModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteSubnetModalLabel">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Actions</h3>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
<i class="fas fa-search me-2"></i> Scan Now
</a>
<a href="{{ url_for('ipam.subnet_visualize', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
<i class="fas fa-chart-network me-2"></i> IP Visualization
</a>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the subnet {{ subnet.cidr }}?</p>
{% if subnet.servers %}
<div class="alert alert-danger">
<strong>Warning:</strong> This subnet has {{ subnet.servers|length }} servers assigned to it.
You must delete or reassign these servers before deleting the subnet.
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('ipam.subnet_delete', subnet_id=subnet.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger" {{ 'disabled' if subnet.servers }}>Delete Subnet</button>
</form>
</div>
</div>
</div>

View file

@ -16,6 +16,21 @@
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
{% block styles %}{% endblock %}
<script>
// Check for saved theme preference or respect OS preference
function initTheme() {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
document.documentElement.setAttribute('data-bs-theme', storedTheme);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark');
localStorage.setItem('theme', 'dark');
}
}
// Run before page load to prevent flash
initTheme();
</script>
</head>
<body class="{{ 'auth-page' if not current_user.is_authenticated else '' }}">
@ -84,6 +99,12 @@
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
</ul>
</div>
<div class="ms-auto me-3">
<button class="btn btn-icon" id="theme-toggle" aria-label="Toggle theme">
<span class="ti ti-moon theme-icon-light"></span>
<span class="ti ti-sun theme-icon-dark"></span>
</button>
</div>
</div>
</div>
</nav>

BIN
config/app-dev.db Normal file

Binary file not shown.

View file

@ -10,50 +10,56 @@ class Config:
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_DURATION = timedelta(days=14)
PERMANENT_SESSION_LIFETIME = timedelta(days=1)
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB max upload
# Security headers
SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'X-XSS-Protection': '1; mode=block',
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com 'unsafe-inline'; style-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com 'unsafe-inline'; font-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.gstatic.com data:; img-src 'self' data:;"
}
@staticmethod
def init_app(app):
pass
class DevelopmentConfig(Config):
"""Development config."""
DEBUG = True
SESSION_COOKIE_SECURE = False
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'development.db')
'sqlite:///' + os.path.join(basedir, 'app-dev.db')
SESSION_COOKIE_SECURE = False
class TestingConfig(Config):
"""Testing config."""
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'testing.db')
'sqlite:///' + os.path.join(basedir, 'app-test.db')
WTF_CSRF_ENABLED = False
SESSION_COOKIE_SECURE = False
class ProductionConfig(Config):
"""Production config."""
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'production.db')
'postgresql://user:password@localhost/production'
@classmethod
def init_app(cls, app):
Config.init_app(app)
# Production-specific logging
# Log to stdout/stderr
import logging
from logging.handlers import RotatingFileHandler
log_dir = os.path.join(basedir, '..', 'logs')
os.makedirs(log_dir, exist_ok=True)
file_handler = RotatingFileHandler(
os.path.join(log_dir, 'app.log'),
maxBytes=10485760, # 10MB
backupCount=10
)
file_handler = RotatingFileHandler('logs/netdocs.log', maxBytes=10240, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('App startup')
app.logger.info('NetDocs startup')
config = {
'development': DevelopmentConfig,

Binary file not shown.

101
run.py
View file

@ -3,6 +3,13 @@ import sys
import importlib.util
from flask import Flask, render_template
from app import create_app
from app.core.extensions import db
from app.core.models import Server, Subnet, App, Port
from app.core.auth import User # Import User from auth module
from datetime import datetime
import random
import string
import json
# Add the current directory to Python path
current_dir = os.path.abspath(os.path.dirname(__file__))
@ -60,12 +67,96 @@ def register_routes(app):
except ImportError as e:
print(f"Could not import IPAM blueprint: {e}")
# Create a development application instance
print("Starting Flask app with SQLite database...")
app = create_app('development')
@app.shell_context_processor
def make_shell_context():
return {
'db': db,
'User': User,
'Server': Server,
'Subnet': Subnet,
'App': App,
'Port': Port
}
def init_db():
"""Initialize database tables"""
with app.app_context():
db.create_all()
def create_admin_user():
"""Create an admin user if no users exist"""
with app.app_context():
if User.query.count() == 0:
admin = User(
username='admin',
email='admin@example.com',
is_admin=True
)
admin.set_password('admin')
db.session.add(admin)
db.session.commit()
print("Created admin user: admin@example.com (password: admin)")
# Update seed_data to use consistent structures
def seed_data():
"""Add some sample data to the database"""
with app.app_context():
# Only seed if the database is empty
if Subnet.query.count() == 0:
# Create sample subnets
subnet1 = Subnet(cidr='192.168.1.0/24', location='Office', active_hosts=json.dumps([]))
subnet2 = Subnet(cidr='10.0.0.0/24', location='Datacenter', active_hosts=json.dumps([]))
db.session.add_all([subnet1, subnet2])
db.session.commit()
# Create sample servers
server1 = Server(hostname='web-server', ip_address='192.168.1.10', subnet=subnet1)
server2 = Server(hostname='db-server', ip_address='192.168.1.11', subnet=subnet1)
server3 = Server(hostname='app-server', ip_address='10.0.0.5', subnet=subnet2)
db.session.add_all([server1, server2, server3])
db.session.commit()
# Create sample apps
app1 = App(name='Website', server=server1, documentation='# Company Website\nRunning on Nginx/PHP')
app2 = App(name='PostgreSQL', server=server2, documentation='# Database Server\nPostgreSQL 15')
app3 = App(name='API Service', server=server3, documentation='# REST API\nNode.js service')
db.session.add_all([app1, app2, app3])
db.session.commit()
# Create sample ports
port1 = Port(app=app1, port_number=80, protocol='TCP', description='HTTP')
port2 = Port(app=app1, port_number=443, protocol='TCP', description='HTTPS')
port3 = Port(app=app2, port_number=5432, protocol='TCP', description='PostgreSQL')
port4 = Port(app=app3, port_number=3000, protocol='TCP', description='Node.js API')
db.session.add_all([port1, port2, port3, port4])
db.session.commit()
print("Sample data has been added to the database")
if __name__ == '__main__':
# Initialize database if needed
if not os.path.exists('app.db') and 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
print("Database not found, initializing...")
try:
init_db()
create_admin_user()
# Uncomment to add sample data
# seed_data()
except Exception as e:
print(f"Error initializing database: {e}")
sys.exit(1)
# Run the application
try:
print("Starting Flask app with SQLite database...")
app = create_app('development')
app.run(debug=True, host='0.0.0.0', port=5000)
app.run(debug=True, port=5000)
except Exception as e:
print(f"Error starting Flask app: {e}")
import traceback
traceback.print_exc()
sys.exit(1)