diff --git a/app/__init__.py b/app/__init__.py index 104b799..9c3210c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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): diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc index 56b4d20..0ea6980 100644 Binary files a/app/__pycache__/__init__.cpython-313.pyc and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/core/__pycache__/auth.cpython-313.pyc b/app/core/__pycache__/auth.cpython-313.pyc index d926197..5a7100f 100644 Binary files a/app/core/__pycache__/auth.cpython-313.pyc and b/app/core/__pycache__/auth.cpython-313.pyc differ diff --git a/app/core/__pycache__/extensions.cpython-313.pyc b/app/core/__pycache__/extensions.cpython-313.pyc index b546695..30787d3 100644 Binary files a/app/core/__pycache__/extensions.cpython-313.pyc and b/app/core/__pycache__/extensions.cpython-313.pyc differ diff --git a/app/core/__pycache__/models.cpython-313.pyc b/app/core/__pycache__/models.cpython-313.pyc index 24a2b32..e6b722a 100644 Binary files a/app/core/__pycache__/models.cpython-313.pyc and b/app/core/__pycache__/models.cpython-313.pyc differ diff --git a/app/core/auth.py b/app/core/auth.py index 4dd6cc5..8bcb78c 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -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'' + 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'' - @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) \ No newline at end of file diff --git a/app/core/extensions.py b/app/core/extensions.py index fcf78a5..6a55299 100644 --- a/app/core/extensions.py +++ b/app/core/extensions.py @@ -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"] +) diff --git a/app/core/models.py b/app/core/models.py index 599b84f..34de4a4 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -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'' - + return f'' 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'' - -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'' @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'' \ No newline at end of file diff --git a/app/core/template_filters.py b/app/core/template_filters.py new file mode 100644 index 0000000..09ecdcc --- /dev/null +++ b/app/core/template_filters.py @@ -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 \ No newline at end of file diff --git a/app/routes/__pycache__/api.cpython-313.pyc b/app/routes/__pycache__/api.cpython-313.pyc index d26b7f8..ffd4419 100644 Binary files a/app/routes/__pycache__/api.cpython-313.pyc and b/app/routes/__pycache__/api.cpython-313.pyc differ diff --git a/app/routes/__pycache__/auth.cpython-313.pyc b/app/routes/__pycache__/auth.cpython-313.pyc index 0922c44..08dccd5 100644 Binary files a/app/routes/__pycache__/auth.cpython-313.pyc and b/app/routes/__pycache__/auth.cpython-313.pyc differ diff --git a/app/routes/__pycache__/dashboard.cpython-313.pyc b/app/routes/__pycache__/dashboard.cpython-313.pyc index 0ec9961..59ff61c 100644 Binary files a/app/routes/__pycache__/dashboard.cpython-313.pyc and b/app/routes/__pycache__/dashboard.cpython-313.pyc differ diff --git a/app/routes/__pycache__/ipam.cpython-313.pyc b/app/routes/__pycache__/ipam.cpython-313.pyc index 3cbe4b7..b3316d2 100644 Binary files a/app/routes/__pycache__/ipam.cpython-313.pyc and b/app/routes/__pycache__/ipam.cpython-313.pyc differ diff --git a/app/routes/api.py b/app/routes/api.py index 4f2ff92..9fd0761 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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/', 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'} - ]) \ No newline at end of file + ]) + +@bp.route('/servers//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//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//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/', 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}) \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py index 1fe314c..2a41f8d 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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')) \ No newline at end of file diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 75539b5..ea731cb 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -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/', 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//delete', methods=['POST']) @login_required diff --git a/app/routes/ipam.py b/app/routes/ipam.py index 4039392..b6ad807 100644 --- a/app/routes/ipam.py +++ b/app/routes/ipam.py @@ -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/') @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//scan') +@bp.route('/subnet//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//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//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//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() - ) \ No newline at end of file + return redirect(url_for('ipam.subnet_view', subnet_id=subnet_id)) \ No newline at end of file diff --git a/app/scripts/__pycache__/ip_scanner.cpython-313.pyc b/app/scripts/__pycache__/ip_scanner.cpython-313.pyc index 57cd5dd..4977cae 100644 Binary files a/app/scripts/__pycache__/ip_scanner.cpython-313.pyc and b/app/scripts/__pycache__/ip_scanner.cpython-313.pyc differ diff --git a/app/scripts/db_seed.py b/app/scripts/db_seed.py index 8961144..6dcc278 100644 --- a/app/scripts/db_seed.py +++ b/app/scripts/db_seed.py @@ -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() diff --git a/app/scripts/ip_scanner.py b/app/scripts/ip_scanner.py index e54f5a5..b3f8cc2 100644 --- a/app/scripts/ip_scanner.py +++ b/app/scripts/ip_scanner.py @@ -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() diff --git a/app/static/css/app.css b/app/static/css/app.css index 3f56c06..b81fa30 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -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 */ diff --git a/app/static/js/app.js b/app/static/js/app.js index c349716..aca5674 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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; + } } diff --git a/app/templates/dashboard/app_edit.html b/app/templates/dashboard/app_edit.html new file mode 100644 index 0000000..c207dc3 --- /dev/null +++ b/app/templates/dashboard/app_edit.html @@ -0,0 +1,213 @@ +{% extends "layout.html" %} + +{% block content %} +
+ + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+ +
+ + +
+
+ + +
+
+ + + Supports Markdown formatting +
+ + +
+ +
+ {% if app.ports %} + {% for port in app.ports %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {% endfor %} + {% else %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {% endif %} +
+
+ + +
+
+ +
+ + Cancel + + +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/dashboard/app_form.html b/app/templates/dashboard/app_form.html index 7b53bbf..d104542 100644 --- a/app/templates/dashboard/app_form.html +++ b/app/templates/dashboard/app_form.html @@ -46,12 +46,135 @@ placeholder="Use Markdown for formatting"> Supports Markdown formatting -
- Cancel + + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+ +
+{% endblock %} + +{% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/app/templates/dashboard/server_view.html b/app/templates/dashboard/server_view.html index 9ef41e6..8182c55 100644 --- a/app/templates/dashboard/server_view.html +++ b/app/templates/dashboard/server_view.html @@ -5,133 +5,201 @@
-
-
-
-

Documentation

-
-
- {% if server.documentation %} - {{ markdown(server.documentation)|safe }} - {% else %} -
- No documentation available for this server. -
- {% endif %} -
-
- -
-
-
-

Applications

- - Add Application - -
-
-
- {% if apps %} - - - - - - - - - {% for app in apps %} - - - - - {% endfor %} - -
NamePorts
{{ app.name }} - {% for port in app.ports %} - - {{ port.port }}/{{ port.type }} {% if port.desc %}({{ port.desc }}){% endif %} - - {% endfor %} -
- {% else %} -
-
No applications registered for this server
- - Add Application - -
- {% endif %} -
-
-
-

Server Information

-
- Hostname: {{ server.hostname }} -
-
- IP Address: {{ server.ip_address }} -
-
- Subnet: {{ server.subnet.cidr if server.subnet else 'N/A' }} -
-
- Location: {{ server.subnet.location if server.subnet else 'N/A' }} -
-
- Created: {{ server.created_at.strftime('%Y-%m-%d') }} -
+
+
IP Address:
+
{{ server.ip_address }}
+ +
Subnet:
+
+ + {{ server.subnet.cidr }} + +
+ +
Location:
+
{{ server.subnet.location }}
+ +
Created:
+
{{ server.created_at.strftime('%Y-%m-%d') }}
+
+
-
-

Open Ports

+
+

Port Usage

+
+ +
- {% if server.get_open_ports() %} -
- {% for port in server.get_open_ports() %} -
-
-
- {{ port.port }} -
-
-
- {{ port.type|upper }} - {% if port.desc %} - {{ port.desc }} - {% endif %} +
+
+ {% 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 %} + +
+ {{ port_num }} +
+ {% endfor %} +
+
+
+
+
Port Free
+
Port Used
+
+
+
+
+
+ +
+ +
+
+

Applications

+ +
+
+ {% if server.apps %} +
+ {% for app in server.apps %} +
+

+ +

+
+
+
+ + Edit + + +
+ + + {% if app.ports %} +
+
Ports
+
+ + + + + + + + + + {% for port in app.ports %} + + + + + + {% endfor %} + +
PortProtocolDescription
{{ port.port_number }}{{ port.protocol }}{{ port.description }}
+
+
+ {% endif %} + + + {% if app.documentation %} +
+
Documentation
+
+ {{ app.documentation|markdown }} +
+
+ {% else %} +
No documentation available
+ {% endif %}
{% endfor %}
{% else %} -
- No open ports detected. +
+
+ +
+

No applications found

+

+ This server doesn't have any applications yet. +

+
{% endif %}
@@ -139,4 +207,135 @@
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} + +{% block styles %} + {% endblock %} \ No newline at end of file diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html index 77d505f..62832b5 100644 --- a/app/templates/errors/404.html +++ b/app/templates/errors/404.html @@ -2,13 +2,13 @@ {% block content %}
-
404
+
404

Page not found

- 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

- Return to dashboard + Go back to dashboard
{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/500.html b/app/templates/errors/500.html index 9a72e56..762f998 100644 --- a/app/templates/errors/500.html +++ b/app/templates/errors/500.html @@ -2,13 +2,13 @@ {% block content %}
-
500
-

Internal Server Error

+
500
+

Server Error

- Something went wrong on our end. Please try again later. + Oops, something went wrong on our end

- Return to dashboard + Go back to dashboard
{% endblock %} \ No newline at end of file diff --git a/app/templates/ipam/subnet_view.html b/app/templates/ipam/subnet_view.html index 88a8663..84624d1 100644 --- a/app/templates/ipam/subnet_view.html +++ b/app/templates/ipam/subnet_view.html @@ -5,55 +5,129 @@ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
-
+
-

Registered Hosts

+

Subnet Information

- {% if servers %} + + + + + + + + + + {% set network = get_ip_network(subnet.cidr) %} + {% if network %} + + + + + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + + + + + +
CIDR Notation{{ subnet.cidr }}
Location{{ subnet.location }}
Network Address{{ network.network_address }}
Broadcast Address{{ network.broadcast_address if network.prefixlen < 31 else 'N/A' }}
Netmask{{ network.netmask }}
Host Range + {% if network.prefixlen < 31 %} {{ network.network_address + 1 }} - {{ network.broadcast_address - 1 }} + {% else %} {{ network.network_address }} - {{ network.broadcast_address }} {% endif %}
Total Hosts + {% if network.prefixlen < 31 %} {{ network.num_addresses - 2 }} {% else %} {{ network.num_addresses }} + {% endif %}
Auto Scan{{ 'Yes' if subnet.auto_scan else 'No' }}
Last Scanned{{ subnet.last_scanned|default('Never', true) }}
Created{{ subnet.created_at.strftime('%Y-%m-%d %H:%M') }}
+
+
+
+ +
+
+
+

Servers in Subnet

+
+
+ {% if subnet.servers %}
- +
- - + - {% for server in servers %} + {% for server in subnet.servers %} - + - @@ -62,69 +136,51 @@
Hostname IP AddressCreatedActions
{{ server.hostname }}{{ server.hostname }} + {{ server.ip_address }}{{ server.created_at.strftime('%Y-%m-%d') }} - View +
{% else %} -
-
No hosts registered in this subnet
- - Add New Server - - - Scan Subnet - +
+
+ +
+

No servers in this subnet

+

+ You can add a new server to this subnet from the dashboard +

+
{% endif %}
+
+
-
-
-
-

Subnet Information

-
-
-
- Network: {{ subnet.cidr }} -
-
- Location: {{ subnet.location }} -
-
- Total IPs: {{ total_ips }} -
-
- Used IPs: {{ used_ips }} ({{ '%.1f'|format(usage_percent) }}%) -
-
- Available IPs: {{ total_ips - used_ips }} -
-
- Auto Scan: - {% if subnet.auto_scan %} - Enabled - {% else %} - Disabled - {% endif %} -
-
- Created: {{ subnet.created_at.strftime('%Y-%m-%d') }} -
-
+ + diff --git a/config/__pycache__/settings.cpython-313.pyc b/config/__pycache__/settings.cpython-313.pyc index d70e8e1..a0c0577 100644 Binary files a/config/__pycache__/settings.cpython-313.pyc and b/config/__pycache__/settings.cpython-313.pyc differ diff --git a/config/app-dev.db b/config/app-dev.db new file mode 100644 index 0000000..8cda1bc Binary files /dev/null and b/config/app-dev.db differ diff --git a/config/settings.py b/config/settings.py index b0eacb4..09d9581 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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, diff --git a/instance/development.db b/instance/development.db index c44c330..16f05f5 100644 Binary files a/instance/development.db and b/instance/development.db differ diff --git a/run.py b/run.py index e1dd1fd..6af2e8a 100644 --- a/run.py +++ b/run.py @@ -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() \ No newline at end of file + sys.exit(1) \ No newline at end of file