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) os.makedirs(os.path.join(app.instance_path), exist_ok=True)
# Initialize extensions # 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) db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
bcrypt.init_app(app) bcrypt.init_app(app)
limiter.init_app(app)
csrf.init_app(app) csrf.init_app(app)
limiter.init_app(app)
# Initialize login manager # Initialize login manager
from app.core.auth import User from app.core.auth import User
login_manager.init_app(app)
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):

View file

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

View file

@ -1,28 +1,22 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_bcrypt import Bcrypt from flask_bcrypt import Bcrypt
from flask_limiter import Limiter from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
# Initialize extensions # Initialize extensions
db = SQLAlchemy() db = SQLAlchemy()
bcrypt = Bcrypt() migrate = Migrate()
login_manager = LoginManager() login_manager = LoginManager()
login_manager.login_view = 'auth.login' 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 bcrypt = Bcrypt()
try: csrf = CSRFProtect()
limiter = Limiter( 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, key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"] 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 import json
from datetime import datetime from datetime import datetime
import ipaddress import ipaddress
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
# 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'
class Subnet(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
cidr = db.Column(db.String(18), unique=True) # Format: "192.168.1.0/24" app_id = db.Column(db.Integer, db.ForeignKey('apps.id', ondelete='CASCADE'), nullable=False)
location = db.Column(db.String(80)) port_number = db.Column(db.Integer, nullable=False)
auto_scan = db.Column(db.Boolean, default=False) protocol = db.Column(db.String(10), default='TCP') # TCP, UDP, etc.
created_at = db.Column(db.DateTime, default=datetime.utcnow) description = db.Column(db.String(200))
@property # Relationship
def network(self): app = db.relationship('App', back_populates='ports')
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()
def __repr__(self): def __repr__(self):
return f'<Subnet {self.cidr}>' return f'<Port {self.port_number}/{self.protocol}>'
class Server(db.Model): class Server(db.Model):
__tablename__ = 'servers'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
hostname = db.Column(db.String(80), unique=True) hostname = db.Column(db.String(64), nullable=False)
ip_address = db.Column(db.String(15), unique=True) ip_address = db.Column(db.String(39), nullable=False) # IPv4 or IPv6
subnet_id = db.Column(db.Integer, db.ForeignKey('subnet.id')) subnet_id = db.Column(db.Integer, db.ForeignKey('subnets.id'), nullable=False)
subnet = db.relationship('Subnet', backref=db.backref('servers', lazy=True))
documentation = db.Column(db.Text) documentation = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow) 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 # Relationships
_ports = db.Column(db.Text, default='[]') subnet = db.relationship('Subnet', back_populates='servers')
apps = db.relationship('App', back_populates='server', cascade='all, delete-orphan')
@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
def __repr__(self): def __repr__(self):
return f'<Server {self.hostname}>' return f'<Server {self.hostname}>'
class Subnet(db.Model):
__tablename__ = 'subnets'
class App(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80)) cidr = db.Column(db.String(18), unique=True, nullable=False) # e.g., 192.168.1.0/24
server_id = db.Column(db.Integer, db.ForeignKey('server.id')) location = db.Column(db.String(64))
server = db.relationship('Server', backref=db.backref('apps', lazy=True)) active_hosts = db.Column(db.Text) # Store as JSON string
documentation = db.Column(db.Text) last_scanned = db.Column(db.DateTime)
auto_scan = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) 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 # Relationships
_ports = db.Column(db.Text, default='[]') servers = db.relationship('Server', back_populates='subnet')
def __repr__(self):
return f'<Subnet {self.cidr}>'
@property @property
def ports(self): def used_ips(self):
return json.loads(self._ports) if self._ports else [] """Number of IPs used in this subnet (servers)"""
return len(self.servers)
@ports.setter # Getter and setter for active_hosts as JSON
def ports(self, value): @property
self._ports = json.dumps(value) if value else '[]' 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): def __repr__(self):
return f'<App {self.name}>' 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 import Blueprint, jsonify, request, abort
from flask_login import login_required 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.core.extensions import db
from app.scripts.ip_scanner import scan from app.scripts.ip_scanner import scan
import random
import ipaddress
bp = Blueprint('api', __name__, url_prefix='/api') bp = Blueprint('api', __name__, url_prefix='/api')
@ -93,14 +95,24 @@ def get_servers():
@bp.route('/servers/<int:server_id>', methods=['GET']) @bp.route('/servers/<int:server_id>', methods=['GET'])
@login_required @login_required
def get_server(server_id): def get_server(server_id):
"""Get details for a specific server""" """Get a specific server"""
server = Server.query.get_or_404(server_id) server = Server.query.get_or_404(server_id)
apps = [] 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({ apps.append({
'id': app.id, 'id': app.id,
'name': app.name, 'name': app.name,
'ports': ports,
'created_at': app.created_at.strftime('%Y-%m-%d %H:%M:%S') '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, 'ip_address': server.ip_address,
'subnet_id': server.subnet_id, 'subnet_id': server.subnet_id,
'documentation': server.documentation, 'documentation': server.documentation,
'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S'), 'apps': apps,
'ports': server.ports, 'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S')
'apps': apps
} }
return jsonify(result) return jsonify(result)
@ -197,3 +208,102 @@ def suggest_ports():
{'port': 80, 'type': 'tcp', 'desc': 'HTTP'}, {'port': 80, 'type': 'tcp', 'desc': 'HTTP'},
{'port': 22, 'type': 'tcp', 'desc': 'SSH'} {'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']) @bp.route('/login', methods=['GET', 'POST'])
def login(): def login():
# If already logged in, redirect to dashboard """User login"""
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('dashboard.dashboard_home')) return redirect(url_for('dashboard.dashboard_home'))
@ -19,58 +19,64 @@ def login():
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
if user and user.check_password(password): if not user or not 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'))
flash('Invalid email or password', 'danger') flash('Invalid email or password', 'danger')
return render_template('auth/login.html', title='Login')
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') return render_template('auth/login.html', title='Login')
@bp.route('/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
def register(): def register():
# If already logged in, redirect to dashboard """User registration"""
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('dashboard.dashboard_home')) return redirect(url_for('dashboard.dashboard_home'))
if request.method == 'POST': if request.method == 'POST':
email = request.form.get('email') email = request.form.get('email')
username = request.form.get('username')
password = request.form.get('password') password = request.form.get('password')
password_confirm = request.form.get('password_confirm')
# Check if email already exists # Validation
existing_user = User.query.filter_by(email=email).first() if not email or not username or not password:
if existing_user: flash('All fields are required', 'danger')
return render_template('auth/register.html', title='Register')
if User.query.filter_by(email=email).first():
flash('Email already registered', 'danger') flash('Email already registered', 'danger')
return render_template('auth/register.html', title='Register') return render_template('auth/register.html', title='Register')
# Check if passwords match if User.query.filter_by(username=username).first():
if password != password_confirm: flash('Username already taken', 'danger')
flash('Passwords do not match', 'danger')
return render_template('auth/register.html', title='Register') return render_template('auth/register.html', title='Register')
# Create new user # Create new user
user = User(email=email) user = User(email=email, username=username)
user.set_password(password) user.set_password(password)
# Make first user an admin
if User.query.count() == 0:
user.is_admin = True
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
flash('Registration successful! Please log in.', 'success') flash('Registration successful! You are now logged in.', 'success')
return redirect(url_for('auth.login'))
# Auto-login after registration
login_user(user)
return redirect(url_for('dashboard.dashboard_home'))
return render_template('auth/register.html', title='Register') return render_template('auth/register.html', title='Register')
@bp.route('/logout') @bp.route('/logout')
@login_required @login_required
def logout(): def logout():
"""User logout"""
logout_user() logout_user()
flash('You have been logged out', 'info') flash('You have been logged out', 'info')
return redirect(url_for('auth.login')) 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 import Blueprint, render_template, redirect, url_for, request, flash, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
import markdown 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 app.core.extensions import db, limiter
from datetime import datetime from datetime import datetime
@ -196,22 +196,50 @@ def app_new():
server_id = request.form.get('server_id') server_id = request.form.get('server_id')
documentation = request.form.get('documentation', '') 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 # Basic validation
if not name or not server_id: if not name or not server_id:
flash('Please fill in all required fields', 'danger') flash('Please fill in all required fields', 'danger')
return render_template( return render_template(
'dashboard/app_form.html', 'dashboard/app_form.html',
title='New Application', title='New Application',
servers=servers, servers=servers
now=datetime.now()
) )
# Create new app
app = App( app = App(
name=name, name=name,
server_id=server_id, server_id=server_id,
documentation=documentation documentation=documentation
) )
db.session.add(app) 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() db.session.commit()
flash('Application created successfully', 'success') flash('Application created successfully', 'success')
@ -220,8 +248,7 @@ def app_new():
return render_template( return render_template(
'dashboard/app_form.html', 'dashboard/app_form.html',
title='New Application', title='New Application',
servers=servers, servers=servers
now=datetime.now()
) )
@bp.route('/app/<int:app_id>', methods=['GET']) @bp.route('/app/<int:app_id>', methods=['GET'])
@ -245,49 +272,69 @@ def app_view(app_id):
def app_edit(app_id): def app_edit(app_id):
"""Edit an existing application""" """Edit an existing application"""
app = App.query.get_or_404(app_id) app = App.query.get_or_404(app_id)
servers = Server.query.all()
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name') name = request.form.get('name')
server_id = request.form.get('server_id') server_id = request.form.get('server_id')
documentation = request.form.get('documentation', '') documentation = request.form.get('documentation', '')
# Process ports # Get port data from form
ports = [] port_numbers = request.form.getlist('port_numbers[]')
port_numbers = request.form.getlist('port[]') protocols = request.form.getlist('protocols[]')
port_types = request.form.getlist('port_type[]') port_descriptions = request.form.getlist('port_descriptions[]')
port_descs = request.form.getlist('port_desc[]')
for i in range(len(port_numbers)): # Validate inputs
if port_numbers[i]: if not all([name, server_id]):
port = { flash('All fields are required', 'danger')
'port': int(port_numbers[i]), return render_template('dashboard/app_form.html',
'type': port_types[i] if i < len(port_types) else 'tcp', title='Edit Application',
'desc': port_descs[i] if i < len(port_descs) else '', app=app,
'status': 'open' servers=servers,
} edit_mode=True)
ports.append(port)
# Update app # Update app
app.name = name app.name = name
app.server_id = server_id app.server_id = server_id
app.documentation = documentation app.documentation = documentation
app.ports = ports
# Delete existing ports and recreate them
# This simplifies handling additions, deletions, and updates
Port.query.filter_by(app_id=app.id).delete()
# 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() 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')
flash('Application updated successfully', 'success') return render_template('dashboard/app_form.html',
return redirect(url_for('dashboard.app_view', app_id=app.id)) title='Edit Application',
# 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, app=app,
servers=servers, servers=servers,
use_editor=True edit_mode=True)
)
@bp.route('/app/<int:app_id>/delete', methods=['POST']) @bp.route('/app/<int:app_id>/delete', methods=['POST'])
@login_required @login_required

View file

@ -5,6 +5,7 @@ from app.core.extensions import db
from app.scripts.ip_scanner import scan from app.scripts.ip_scanner import scan
import ipaddress import ipaddress
from datetime import datetime from datetime import datetime
import json
bp = Blueprint('ipam', __name__, url_prefix='/ipam') bp = Blueprint('ipam', __name__, url_prefix='/ipam')
@ -16,7 +17,10 @@ def ipam_home():
# Calculate usage for each subnet # Calculate usage for each subnet
for subnet in subnets: 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( return render_template(
'ipam/index.html', 'ipam/index.html',
@ -32,42 +36,43 @@ def subnet_new():
if request.method == 'POST': if request.method == 'POST':
cidr = request.form.get('cidr') cidr = request.form.get('cidr')
location = request.form.get('location') location = request.form.get('location')
auto_scan = 'auto_scan' in request.form auto_scan = request.form.get('auto_scan') == 'on'
# Basic validation # Basic validation
if not cidr or not location: if not cidr or not location:
flash('Please fill in all required fields', 'danger') flash('Please fill in all required fields', 'danger')
return render_template( return render_template(
'ipam/subnet_form.html', 'ipam/subnet_form.html',
title='New Subnet', title='New Subnet'
now=datetime.now()
) )
# Check if valid CIDR # Validate CIDR format
try: try:
ipaddress.ip_network(cidr) ipaddress.ip_network(cidr, strict=False)
except ValueError: except ValueError:
flash('Invalid CIDR notation', 'danger') flash('Invalid CIDR format', 'danger')
return render_template( return render_template(
'ipam/subnet_form.html', 'ipam/subnet_form.html',
title='New Subnet', title='New Subnet'
now=datetime.now()
) )
# Check if subnet already exists # Check if CIDR already exists
if Subnet.query.filter_by(cidr=cidr).first(): if Subnet.query.filter_by(cidr=cidr).first():
flash('Subnet already exists', 'danger') flash('Subnet already exists', 'danger')
return render_template( return render_template(
'ipam/subnet_form.html', 'ipam/subnet_form.html',
title='New Subnet', title='New Subnet'
now=datetime.now()
) )
# Create new subnet with JSON string for active_hosts, not a Python list
subnet = Subnet( subnet = Subnet(
cidr=cidr, cidr=cidr,
location=location, location=location,
active_hosts=json.dumps([]), # Convert empty list to JSON string
last_scanned=None,
auto_scan=auto_scan auto_scan=auto_scan
) )
db.session.add(subnet) db.session.add(subnet)
db.session.commit() db.session.commit()
@ -76,70 +81,115 @@ def subnet_new():
return render_template( return render_template(
'ipam/subnet_form.html', 'ipam/subnet_form.html',
title='New Subnet', title='New Subnet'
now=datetime.now()
) )
@bp.route('/subnet/<int:subnet_id>') @bp.route('/subnet/<int:subnet_id>')
@login_required @login_required
def subnet_view(subnet_id): def subnet_view(subnet_id):
"""View subnet details""" """View a specific subnet"""
subnet = Subnet.query.get_or_404(subnet_id) subnet = Subnet.query.get_or_404(subnet_id)
# Get all servers in this subnet
servers = Server.query.filter_by(subnet_id=subnet_id).all() servers = Server.query.filter_by(subnet_id=subnet_id).all()
# Get network info # Parse CIDR for display
network = ipaddress.ip_network(subnet.cidr) network = ipaddress.ip_network(subnet.cidr, strict=False)
total_ips = network.num_addresses - 2 # Excluding network and broadcast addresses subnet_info = {
used_ips = len(servers) 'network_address': str(network.network_address),
usage_percent = (used_ips / total_ips) * 100 if total_ips > 0 else 0 '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( return render_template(
'ipam/subnet_view.html', 'ipam/subnet_view.html',
title=f'Subnet - {subnet.cidr}', title=subnet.cidr,
subnet=subnet, subnet=subnet,
subnet_info=subnet_info,
servers=servers, servers=servers,
total_ips=total_ips,
used_ips=used_ips,
usage_percent=usage_percent,
now=datetime.now() 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 @login_required
def subnet_scan(subnet_id): def subnet_scan(subnet_id):
"""Scan a subnet for active hosts""" """Manually scan a subnet"""
subnet = Subnet.query.get_or_404(subnet_id) subnet = Subnet.query.get_or_404(subnet_id)
try: try:
results = scan(subnet.cidr, save_results=True) # Call the scan function with manual_trigger=True
flash(f'Scan completed for subnet {subnet.cidr}. Found {len(results)} active hosts.', 'success') scan(subnet, manual_trigger=True)
db.session.commit()
flash(f'Scan completed for subnet {subnet.cidr}', 'success')
except Exception as e: 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)) 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()
)

View file

@ -1,24 +1,50 @@
from app.core.extensions import db from app.core.extensions import db
from app.core.models import Subnet, Server, App from app.core.models import Subnet, Server, App, Port
from app.core.auth import User from app.core.auth import User # Import User from auth module
import json
def seed_database(): def seed_database():
# Example seed data for network objects """Add sample data to the database"""
subnet = Subnet(cidr='192.168.1.0/24', location='Office', auto_scan=True) # Create a default subnet if none exists
server = Server(hostname='server1', ip_address='192.168.1.10', subnet=subnet) if Subnet.query.count() == 0:
app = App(name='Web App', server=server, documentation='# Welcome to Web App', subnet = Subnet(
_ports='[{"port": 80, "type": "tcp", "desc": "Web"}]') 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 # Create a default user if none exists
if User.query.count() == 0: if User.query.count() == 0:
admin = User(email="admin@example.com", is_admin=True) admin = User(username='admin', email='admin@example.com', is_admin=True)
admin.set_password("admin") admin.set_password('admin')
db.session.add(admin) db.session.add(admin)
db.session.add(subnet)
db.session.add(server)
db.session.add(app)
try: try:
db.session.commit() db.session.commit()
print("Database seeded successfully") print("Database seeded successfully")

View file

@ -7,63 +7,41 @@ from app.core.extensions import db
from app.core.models import Subnet, Server from app.core.models import Subnet, Server
import json import json
import subprocess 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 Scan a subnet for active hosts
Args: Args:
cidr: The subnet in CIDR notation (e.g. "192.168.1.0/24") subnet: The subnet object to scan
max_threads: Maximum number of threads to use manual_trigger: If False, only scan if the subnet hasn't been scanned recently
save_results: Whether to save results to the database
Returns:
A list of dictionaries with IP, hostname, and status
""" """
print(f"Starting scan of {cidr}") # Skip if not auto scan and not manually triggered
network = ipaddress.ip_network(cidr) if not subnet.auto_scan and not manual_trigger:
return False
# Skip network and broadcast addresses for IPv4 active_hosts = []
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]
# 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: try:
save_scan_results(cidr, all_results) # Parse the CIDR notation
except Exception as e: network = ipaddress.ip_network(subnet.cidr, strict=False)
print(f"Error saving scan results: {e}")
print(f"Scan completed. Found {len(all_results)} active hosts.") # For each address in this network, ping it
return all_results 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): def scan_worker(ip_list, results, index):
"""Worker function for threading""" """Worker function for threading"""
@ -76,13 +54,23 @@ def scan_worker(ip_list, results, index):
'status': 'up' 'status': 'up'
}) })
def ping(ip): def ping(host):
"""Ping an IP address and return True if it responds""" """
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: try:
# Faster timeout (1 second) # Run the command and capture output
subprocess.check_output(['ping', '-c', '1', '-W', '1', str(ip)], stderr=subprocess.STDOUT) output = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=2)
return True # Return True if ping was successful
except subprocess.CalledProcessError: return output.returncode == 0
except subprocess.TimeoutExpired:
return False
except Exception:
return False return False
def get_hostname(ip): def get_hostname(ip):
@ -166,7 +154,7 @@ def schedule_subnet_scans():
# Start a thread for each subnet # Start a thread for each subnet
thread = threading.Thread( thread = threading.Thread(
target=scan, target=scan,
args=(subnet.cidr,), args=(subnet,),
daemon=True daemon=True
) )
thread.start() thread.start()

View file

@ -1,14 +1,35 @@
/* Custom styles for the app */ /* 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 { body {
background-color: var(--background-color);
color: var(--text-color);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f5f7fb; transition: background-color 0.3s ease;
color: #232e3c;
} }
.markdown-body { .markdown-body {
padding: 1rem; padding: 1rem;
background-color: #fff; background-color: var(--card-bg);
border: 1px solid rgba(0, 0, 0, 0.125); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
} }
@ -92,85 +113,160 @@ body {
/* Sidebar styles */ /* Sidebar styles */
.sidebar { .sidebar {
width: 260px; background-color: var(--sidebar-bg);
color: var(--text-color);
height: 100vh;
position: fixed; position: fixed;
top: 0;
left: 0; left: 0;
bottom: 0; width: 250px;
z-index: 100; z-index: 1000;
background: #fff;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .15);
overflow-y: auto; overflow-y: auto;
transition: all 0.3s;
} }
.sidebar-brand { .sidebar-brand {
padding: 1.5rem; padding: 1.5rem 1rem;
font-size: 1.25rem;
font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
height: 64px; color: var(--text-color);
} }
.sidebar-brand-text { .sidebar-brand-text {
font-size: 1.25rem; margin-left: 0.5rem;
font-weight: 600;
margin-left: 0.75rem;
}
.sidebar-nav {
padding: 0.75rem 1.5rem;
} }
.sidebar-heading { .sidebar-heading {
padding: 0.75rem 1rem 0.5rem;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
color: #8898aa; font-weight: 600;
letter-spacing: 0.04em; color: var(--text-color);
margin-top: 1.5rem; opacity: 0.6;
margin-bottom: 0.75rem;
} }
.sidebar-item { .sidebar-item {
display: block; display: block;
padding: 0.675rem 1.2rem; padding: 0.5rem 1rem;
font-size: 0.875rem; color: var(--text-color);
color: #525f7f; text-decoration: none;
border-radius: 0.375rem; border-radius: 0.25rem;
margin-bottom: 0.25rem; margin: 0.2rem 0.5rem;
font-weight: 500; transition: background-color 0.2s;
transition: all 0.15s ease;
} }
.sidebar-item:hover { .sidebar-item:hover {
color: #5e72e4; background-color: var(--sidebar-hover-bg);
background: rgba(94, 114, 228, 0.1); color: var(--text-color);
text-decoration: none;
} }
.sidebar-item.active { .sidebar-item.active {
color: #5e72e4; background-color: var(--highlight-color);
background: rgba(94, 114, 228, 0.1); 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; font-weight: 600;
} }
.main-content { .page-pretitle {
margin-left: 260px; color: #6c757d;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.05em;
} }
/* Responsive sidebar */ /* Auth pages */
@media (max-width: 992px) { 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 { .sidebar {
left: -260px; transform: translateX(-100%);
transition: left 0.3s ease;
} }
.sidebar.show { .sidebar.show {
left: 0; transform: translateX(0);
} }
.main-content { .main-content {
margin-left: 0; 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 */ /* 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) { function initTiptapEditor(element) {
@ -134,9 +152,108 @@ function showNotification(message, type = 'info') {
notificationArea.appendChild(notification); notificationArea.appendChild(notification);
// Remove notification after 3 seconds // Auto-remove after 5 seconds
setTimeout(() => { setTimeout(() => {
notification.classList.remove('show'); if (notification.parentNode) {
setTimeout(() => notification.remove(), 150); notification.remove();
}, 3000); }
}, 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> placeholder="Use Markdown for formatting"></textarea>
<small class="form-hint">Supports Markdown formatting</small> <small class="form-hint">Supports Markdown formatting</small>
</div> </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> <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> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% 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="page-header d-print-none">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col"> <div class="col">
<div class="page-pretitle">
Server Details
</div>
<h2 class="page-title"> <h2 class="page-title">
{{ server.hostname }} {{ server.hostname }}
</h2> </h2>
<div class="text-muted mt-1">{{ server.ip_address }}</div>
</div> </div>
<div class="col-auto ms-auto d-print-none"> <div class="col-auto ms-auto d-print-none">
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-link"> <div class="btn-list">
Back to Servers <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> </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>
</div> </div>
<div class="row mt-3"> <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="col-md-4">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Server Information</h3> <h3 class="card-title">Server Information</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-2"> <dl class="row">
<strong>Hostname:</strong> {{ server.hostname }} <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>
<div class="mb-2">
<strong>IP Address:</strong> {{ server.ip_address }}
</div> </div>
<div class="mb-2">
<strong>Subnet:</strong> {{ server.subnet.cidr if server.subnet else 'N/A' }} <!-- Port Usage Map -->
<div class="card mt-3">
<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">
<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 class="mb-2">
<strong>Location:</strong> {{ server.subnet.location if server.subnet else 'N/A' }}
</div> </div>
<div class="mb-2">
<strong>Created:</strong> {{ server.created_at.strftime('%Y-%m-%d') }}
</div> </div>
</div> </div>
</div> </div>
<div class="card mt-3"> <div class="col-md-8">
<div class="card-header"> <!-- Applications -->
<h3 class="card-title">Open Ports</h3> <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>
<div class="card-body"> <div class="card-body">
{% if server.get_open_ports() %} {% if server.apps %}
<div class="list-group list-group-flush"> <div class="accordion" id="applicationAccordion">
{% for port in server.get_open_ports() %} {% for app in server.apps %}
<div class="list-group-item"> <div class="accordion-item">
<div class="row align-items-center"> <h2 class="accordion-header" id="heading{{ app.id }}">
<div class="col-auto"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
<span class="badge bg-primary">{{ port.port }}</span> 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> </div>
<div class="col">
<div class="text-truncate">
{{ port.type|upper }}
{% if port.desc %}
<span class="text-muted">{{ port.desc }}</span>
{% endif %} {% 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> </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> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="text-center text-muted py-3"> <div class="empty">
No open ports detected. <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> </div>
{% endif %} {% endif %}
</div> </div>
@ -139,4 +207,135 @@
</div> </div>
</div> </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 %} {% endblock %}

View file

@ -2,13 +2,13 @@
{% block content %} {% block content %}
<div class="container text-center py-5"> <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> <h1 class="h2 mb-3">Page not found</h1>
<p class="h4 text-muted font-weight-normal mb-4"> <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> </p>
<a href="{{ url_for('dashboard.dashboard_home') }}" class="btn btn-primary"> <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> </a>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -2,13 +2,13 @@
{% block content %} {% block content %}
<div class="container text-center py-5"> <div class="container text-center py-5">
<div class="display-1 text-muted mb-3">500</div> <div class="display-1 text-muted mb-5">500</div>
<h1 class="h2 mb-3">Internal Server Error</h1> <h1 class="h2 mb-3">Server Error</h1>
<p class="h4 text-muted font-weight-normal mb-4"> <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> </p>
<a href="{{ url_for('dashboard.dashboard_home') }}" class="btn btn-primary"> <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> </a>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -5,55 +5,129 @@
<div class="page-header d-print-none"> <div class="page-header d-print-none">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col"> <div class="col">
<div class="page-pretitle">
Subnet Details
</div>
<h2 class="page-title"> <h2 class="page-title">
{{ subnet.cidr }} {{ subnet.cidr }} - {{ subnet.location }}
</h2> </h2>
<div class="text-muted mt-1">{{ subnet.location }}</div>
</div> </div>
<div class="col-auto ms-auto d-print-none"> <div class="col-auto ms-auto d-print-none">
<div class="btn-list"> <div class="btn-list">
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary"> <a href="{{ url_for('ipam.subnet_edit', subnet_id=subnet.id) }}" class="btn btn-primary">
<i class="fas fa-search me-2"></i> Scan Subnet <i class="ti ti-edit me-1"></i> Edit
</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> </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>
</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="row mt-3">
<div class="col-md-8"> <div class="col-md-6">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Registered Hosts</h3> <h3 class="card-title">Subnet Information</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if servers %}
<div class="table-responsive">
<table class="table table-vcenter"> <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 card-table">
<thead> <thead>
<tr> <tr>
<th>Hostname</th> <th>Hostname</th>
<th>IP Address</th> <th>IP Address</th>
<th>Created</th> <th>Actions</th>
<th class="w-1"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for server in servers %} {% for server in subnet.servers %}
<tr> <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.ip_address }}</td>
<td>{{ server.created_at.strftime('%Y-%m-%d') }}</td>
<td> <td>
<a href="{{ url_for('dashboard.server_view', server_id=server.id) }}" <a href="{{ url_for('dashboard.server_view', server_id=server.id) }}"
class="btn btn-sm btn-primary"> class="btn btn-sm btn-primary">
View <i class="ti ti-eye"></i>
</a> </a>
</td> </td>
</tr> </tr>
@ -62,71 +136,53 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<div class="text-center py-3"> <div class="empty">
<div class="mb-3">No hosts registered in this subnet</div> <div class="empty-icon">
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-outline-primary"> <i class="ti ti-server"></i>
Add New Server </div>
</a> <p class="empty-title">No servers in this subnet</p>
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary ms-2"> <p class="empty-subtitle text-muted">
Scan Subnet 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> </a>
</div> </div>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="col-md-4"> </div>
<div class="card">
<div class="card-header"> <!-- Delete Confirmation Modal -->
<h3 class="card-title">Subnet Information</h3> <div class="modal fade" id="deleteSubnetModal" tabindex="-1" aria-labelledby="deleteSubnetModalLabel"
</div> aria-hidden="true">
<div class="card-body"> <div class="modal-dialog">
<div class="mb-2"> <div class="modal-content">
<strong>Network:</strong> {{ subnet.cidr }} <div class="modal-header">
</div> <h5 class="modal-title" id="deleteSubnetModalLabel">Confirm Deletion</h5>
<div class="mb-2"> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<strong>Location:</strong> {{ subnet.location }} </div>
</div> <div class="modal-body">
<div class="mb-2"> <p>Are you sure you want to delete the subnet {{ subnet.cidr }}?</p>
<strong>Total IPs:</strong> {{ total_ips }} {% if subnet.servers %}
</div> <div class="alert alert-danger">
<div class="mb-2"> <strong>Warning:</strong> This subnet has {{ subnet.servers|length }} servers assigned to it.
<strong>Used IPs:</strong> {{ used_ips }} ({{ '%.1f'|format(usage_percent) }}%) You must delete or reassign these servers before deleting the subnet.
</div> </div>
<div class="mb-2"> {% endif %}
<strong>Available IPs:</strong> {{ total_ips - used_ips }} </div>
</div> <div class="modal-footer">
<div class="mb-2"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<strong>Auto Scan:</strong> <form method="POST" action="{{ url_for('ipam.subnet_delete', subnet_id=subnet.id) }}">
{% if subnet.auto_scan %} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<span class="badge bg-success">Enabled</span> <button type="submit" class="btn btn-danger" {{ 'disabled' if subnet.servers }}>Delete Subnet</button>
{% else %} </form>
<span class="badge bg-secondary">Disabled</span> </div>
{% endif %} </div>
</div>
<div class="mb-2">
<strong>Created:</strong> {{ subnet.created_at.strftime('%Y-%m-%d') }}
</div>
</div>
</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>
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -16,6 +16,21 @@
<!-- Custom CSS --> <!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
{% block styles %}{% endblock %} {% 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> </head>
<body class="{{ 'auth-page' if not current_user.is_authenticated else '' }}"> <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> <li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
</ul> </ul>
</div> </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>
</div> </div>
</nav> </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_SECURE = True
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_DURATION = timedelta(days=14) 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 @staticmethod
def init_app(app): def init_app(app):
pass pass
class DevelopmentConfig(Config): class DevelopmentConfig(Config):
"""Development config."""
DEBUG = True DEBUG = True
SESSION_COOKIE_SECURE = False
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \ 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): class TestingConfig(Config):
"""Testing config."""
TESTING = True TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \ 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 WTF_CSRF_ENABLED = False
SESSION_COOKIE_SECURE = False
class ProductionConfig(Config): class ProductionConfig(Config):
"""Production config."""
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'production.db') 'postgresql://user:password@localhost/production'
@classmethod @classmethod
def init_app(cls, app): def init_app(cls, app):
Config.init_app(app) Config.init_app(app)
# Production-specific logging # Log to stdout/stderr
import logging import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
log_dir = os.path.join(basedir, '..', 'logs')
os.makedirs(log_dir, exist_ok=True)
file_handler = RotatingFileHandler( file_handler = RotatingFileHandler('logs/netdocs.log', maxBytes=10240, backupCount=10)
os.path.join(log_dir, 'app.log'),
maxBytes=10485760, # 10MB
backupCount=10
)
file_handler.setFormatter(logging.Formatter( file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
)) ))
file_handler.setLevel(logging.INFO) file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler) app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO) app.logger.setLevel(logging.INFO)
app.logger.info('App startup') app.logger.info('NetDocs startup')
config = { config = {
'development': DevelopmentConfig, 'development': DevelopmentConfig,

Binary file not shown.

101
run.py
View file

@ -3,6 +3,13 @@ import sys
import importlib.util import importlib.util
from flask import Flask, render_template from flask import Flask, render_template
from app import create_app 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 # Add the current directory to Python path
current_dir = os.path.abspath(os.path.dirname(__file__)) current_dir = os.path.abspath(os.path.dirname(__file__))
@ -60,12 +67,96 @@ def register_routes(app):
except ImportError as e: except ImportError as e:
print(f"Could not import IPAM blueprint: {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__': 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: try:
print("Starting Flask app with SQLite database...") init_db()
app = create_app('development') create_admin_user()
app.run(debug=True, host='0.0.0.0', port=5000) # Uncomment to add sample data
# seed_data()
except Exception as e:
print(f"Error initializing database: {e}")
sys.exit(1)
# Run the application
try:
app.run(debug=True, port=5000)
except Exception as e: except Exception as e:
print(f"Error starting Flask app: {e}") print(f"Error starting Flask app: {e}")
import traceback sys.exit(1)
traceback.print_exc()