batman (working version kinda)

This commit is contained in:
pika 2025-03-30 19:20:13 +02:00
commit 6dd38036e7
65 changed files with 3950 additions and 0 deletions

31
.devcontainer.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "Python 3 for Ditto",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"forwardPorts": [
8080
],
"customizations": {
"vscode": {
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint"
},
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"njpwerner.autodocstring"
]
}
},
"postCreateCommand": "pip install -r requirements.txt"
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Yohei Nakajima
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

154
app/__init__.py Normal file
View file

@ -0,0 +1,154 @@
from flask import Flask, g, redirect, url_for, render_template
import datetime
import os
def create_app(config_name='development'):
app = Flask(__name__,
template_folder='templates',
static_folder='static')
# Import config
try:
from config.settings import config
app.config.from_object(config.get(config_name, 'default'))
except ImportError:
# Fallback configuration
app.config['SECRET_KEY'] = 'dev-key-placeholder'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Create the app instance folder if it doesn't exist
# This is where SQLite database will be stored
os.makedirs(os.path.join(app.instance_path), exist_ok=True)
# Initialize extensions
from app.core.extensions import db, bcrypt, limiter, login_manager, csrf
db.init_app(app)
bcrypt.init_app(app)
limiter.init_app(app)
csrf.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):
# Make sure we're in app context
with app.app_context():
return User.query.get(int(user_id))
# Initialize CSRF protection
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
csrf.init_app(app)
# Request hooks
@app.before_request
def before_request():
g.user = None
from flask_login import current_user
if current_user.is_authenticated:
g.user = current_user
# Add datetime to all templates
g.now = datetime.datetime.utcnow()
@app.context_processor
def inject_now():
return {'now': datetime.datetime.utcnow()}
@app.after_request
def add_security_headers(response):
# Security headers
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['X-XSS-Protection'] = '1; mode=block'
# Update last_seen for the user
if hasattr(g, 'user') and g.user and g.user.is_authenticated:
g.user.last_seen = datetime.datetime.utcnow()
db.session.commit()
return response
# Add a basic index route that redirects to login or dashboard
@app.route('/')
def index():
from flask_login import current_user
if current_user.is_authenticated:
return redirect(url_for('dashboard.dashboard_home'))
return redirect(url_for('auth.login'))
# Register blueprints - order matters!
# First auth blueprint
from app.routes.auth import bp as auth_bp
app.register_blueprint(auth_bp)
print("Registered Auth blueprint")
# Then other blueprints
try:
from app.routes.dashboard import bp as dashboard_bp
app.register_blueprint(dashboard_bp)
print("Registered Dashboard blueprint")
except ImportError as e:
print(f"Could not import dashboard blueprint: {e}")
try:
from app.routes.ipam import bp as ipam_bp
app.register_blueprint(ipam_bp)
print("Registered IPAM blueprint")
except ImportError as e:
print(f"Could not import ipam blueprint: {e}")
try:
from app.routes.api import bp as api_bp
app.register_blueprint(api_bp)
print("Registered API blueprint")
except ImportError as e:
print(f"Could not import API blueprint: {e}")
# Create database tables
with app.app_context():
try:
db.create_all()
print("Database tables created successfully")
# Check if we need to seed the database
from app.core.auth import User
if User.query.count() == 0:
# Run the seed database function if we have no users
try:
from app.scripts.db_seed import seed_database
seed_database()
print("Database seeded with initial data")
# Create an admin user
admin = User(email="admin@example.com", is_admin=True)
admin.set_password("admin")
db.session.add(admin)
db.session.commit()
print("Admin user created: admin@example.com / admin")
except Exception as e:
print(f"Error seeding database: {e}")
except Exception as e:
print(f"Error with database setup: {e}")
# After all blueprint registrations, add error handlers
@app.errorhandler(404)
def page_not_found(e):
return render_template('errors/404.html', title='Page Not Found'), 404
@app.errorhandler(500)
def internal_server_error(e):
return render_template('errors/500.html', title='Server Error'), 500
@app.errorhandler(403)
def forbidden(e):
return render_template('errors/403.html', title='Forbidden'), 403
@app.errorhandler(401)
def unauthorized(e):
return render_template('errors/401.html', title='Unauthorized'), 401
return app

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

31
app/core/auth.py Normal file
View file

@ -0,0 +1,31 @@
from flask_login import LoginManager, UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from .extensions import db
from datetime import datetime
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
class User(UserMixin, db.Model):
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)
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 set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def get_id(self):
return str(self.id)
def __repr__(self):
return f'<User {self.email}>'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))

28
app/core/extensions.py Normal file
View file

@ -0,0 +1,28 @@
from flask_sqlalchemy import SQLAlchemy
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()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
csrf = CSRFProtect()
# 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"]
)

77
app/core/models.py Normal file
View file

@ -0,0 +1,77 @@
from .extensions import db
import json
from datetime import datetime
import ipaddress
class Subnet(db.Model):
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)
@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()
def __repr__(self):
return f'<Subnet {self.cidr}>'
class Server(db.Model):
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))
documentation = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=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
def __repr__(self):
return f'<Server {self.hostname}>'
class App(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(80))
server_id = db.Column(db.Integer, db.ForeignKey('server.id'))
server = db.relationship('Server', backref=db.backref('apps', lazy=True))
documentation = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 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 __repr__(self):
return f'<App {self.name}>'

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

199
app/routes/api.py Normal file
View file

@ -0,0 +1,199 @@
from flask import Blueprint, jsonify, request, abort
from flask_login import login_required
from app.core.models import Subnet, Server, App
from app.core.extensions import db
from app.scripts.ip_scanner import scan
bp = Blueprint('api', __name__, url_prefix='/api')
@bp.route('/subnets', methods=['GET'])
@login_required
def get_subnets():
"""Get all subnets"""
subnets = Subnet.query.all()
result = []
for subnet in subnets:
result.append({
'id': subnet.id,
'cidr': subnet.cidr,
'location': subnet.location,
'used_ips': subnet.used_ips,
'auto_scan': subnet.auto_scan,
'created_at': subnet.created_at.strftime('%Y-%m-%d %H:%M:%S')
})
return jsonify({'subnets': result})
@bp.route('/subnets/<int:subnet_id>', methods=['GET'])
@login_required
def get_subnet(subnet_id):
"""Get details for a specific subnet"""
subnet = Subnet.query.get_or_404(subnet_id)
servers = []
for server in Server.query.filter_by(subnet_id=subnet_id).all():
servers.append({
'id': server.id,
'hostname': server.hostname,
'ip_address': server.ip_address,
'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S')
})
result = {
'id': subnet.id,
'cidr': subnet.cidr,
'location': subnet.location,
'used_ips': subnet.used_ips,
'auto_scan': subnet.auto_scan,
'created_at': subnet.created_at.strftime('%Y-%m-%d %H:%M:%S'),
'servers': servers
}
return jsonify(result)
@bp.route('/subnets/<int:subnet_id>/scan', methods=['POST'])
@login_required
def api_subnet_scan(subnet_id):
"""Scan a subnet via API"""
subnet = Subnet.query.get_or_404(subnet_id)
try:
results = scan(subnet.cidr, save_results=True)
return jsonify({
'success': True,
'subnet': subnet.cidr,
'hosts_found': len(results),
'results': results
})
except Exception as e:
return jsonify({
'success': False,
'message': f'Error scanning subnet: {str(e)}'
}), 500
@bp.route('/servers', methods=['GET'])
@login_required
def get_servers():
"""Get all servers"""
servers = Server.query.all()
result = []
for server in servers:
result.append({
'id': server.id,
'hostname': server.hostname,
'ip_address': server.ip_address,
'subnet_id': server.subnet_id,
'created_at': server.created_at.strftime('%Y-%m-%d %H:%M:%S')
})
return jsonify({'servers': result})
@bp.route('/servers/<int:server_id>', methods=['GET'])
@login_required
def get_server(server_id):
"""Get details for a specific server"""
server = Server.query.get_or_404(server_id)
apps = []
for app in App.query.filter_by(server_id=server_id).all():
apps.append({
'id': app.id,
'name': app.name,
'created_at': app.created_at.strftime('%Y-%m-%d %H:%M:%S')
})
result = {
'id': server.id,
'hostname': server.hostname,
'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
}
return jsonify(result)
@bp.route('/apps', methods=['GET'])
@login_required
def get_apps():
"""Get all applications"""
apps = App.query.all()
result = []
for app in apps:
result.append({
'id': app.id,
'name': app.name,
'server_id': app.server_id,
'created_at': app.created_at.strftime('%Y-%m-%d %H:%M:%S')
})
return jsonify({'apps': result})
@bp.route('/apps/<int:app_id>', methods=['GET'])
@login_required
def get_app(app_id):
"""Get details for a specific application"""
app = App.query.get_or_404(app_id)
result = {
'id': app.id,
'name': app.name,
'server_id': app.server_id,
'documentation': app.documentation,
'created_at': app.created_at.strftime('%Y-%m-%d %H:%M:%S'),
'ports': app.ports
}
return jsonify(result)
@bp.route('/status', methods=['GET'])
def status():
return jsonify({'status': 'OK'})
@bp.route('/markdown-preview', methods=['POST'])
def markdown_preview():
data = request.json
md_content = data.get('markdown', '')
html = markdown.markdown(md_content)
return jsonify({'html': html})
@bp.route('/ports/suggest', methods=['GET'])
def suggest_ports():
app_type = request.args.get('type', '').lower()
# Common port suggestions based on app type
suggestions = {
'web': [
{'port': 80, 'type': 'tcp', 'desc': 'HTTP'},
{'port': 443, 'type': 'tcp', 'desc': 'HTTPS'}
],
'database': [
{'port': 3306, 'type': 'tcp', 'desc': 'MySQL'},
{'port': 5432, 'type': 'tcp', 'desc': 'PostgreSQL'},
{'port': 1521, 'type': 'tcp', 'desc': 'Oracle'}
],
'mail': [
{'port': 25, 'type': 'tcp', 'desc': 'SMTP'},
{'port': 143, 'type': 'tcp', 'desc': 'IMAP'},
{'port': 110, 'type': 'tcp', 'desc': 'POP3'}
],
'file': [
{'port': 21, 'type': 'tcp', 'desc': 'FTP'},
{'port': 22, 'type': 'tcp', 'desc': 'SFTP/SSH'},
{'port': 445, 'type': 'tcp', 'desc': 'SMB'}
]
}
if app_type in suggestions:
return jsonify(suggestions[app_type])
# Default suggestions
return jsonify([
{'port': 80, 'type': 'tcp', 'desc': 'HTTP'},
{'port': 22, 'type': 'tcp', 'desc': 'SSH'}
])

76
app/routes/auth.py Normal file
View file

@ -0,0 +1,76 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user, login_required
from werkzeug.security import generate_password_hash, check_password_hash
from app.core.extensions import db
from app.core.auth import User
bp = Blueprint('auth', __name__, url_prefix='/auth')
@bp.route('/login', methods=['GET', 'POST'])
def login():
# If already logged in, redirect to dashboard
if current_user.is_authenticated:
return redirect(url_for('dashboard.dashboard_home'))
if request.method == 'POST':
email = request.form.get('email')
password = request.form.get('password')
remember = 'remember' in request.form
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'))
flash('Invalid email or password', 'danger')
return render_template('auth/login.html', title='Login')
@bp.route('/register', methods=['GET', 'POST'])
def register():
# If already logged in, redirect to dashboard
if current_user.is_authenticated:
return redirect(url_for('dashboard.dashboard_home'))
if request.method == 'POST':
email = request.form.get('email')
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')
return render_template('auth/register.html', title='Register')
# Check if passwords match
if password != password_confirm:
flash('Passwords do not match', 'danger')
return render_template('auth/register.html', title='Register')
# Create new user
user = User(email=email)
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'))
return render_template('auth/register.html', title='Register')
@bp.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out', 'info')
return redirect(url_for('auth.login'))

303
app/routes/dashboard.py Normal file
View file

@ -0,0 +1,303 @@
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.extensions import db, limiter
from datetime import datetime
bp = Blueprint('dashboard', __name__, url_prefix='/dashboard')
@bp.route('/')
@login_required
def dashboard_home():
"""Main dashboard view showing server statistics"""
server_count = Server.query.count()
app_count = App.query.count()
subnet_count = Subnet.query.count()
# Get latest added servers
latest_servers = Server.query.order_by(Server.created_at.desc()).limit(5).all()
# Get subnets with usage stats
subnets = Subnet.query.all()
for subnet in subnets:
subnet.usage_percent = subnet.used_ips / 254 * 100 if subnet.cidr.endswith('/24') else 0
return render_template(
'dashboard/index.html',
title='Dashboard',
server_count=server_count,
app_count=app_count,
subnet_count=subnet_count,
latest_servers=latest_servers,
subnets=subnets,
now=datetime.now()
)
@bp.route('/servers')
@login_required
def server_list():
"""List all servers"""
servers = Server.query.order_by(Server.hostname).all()
return render_template(
'dashboard/server_list.html',
title='Servers',
servers=servers,
now=datetime.now()
)
@bp.route('/server/<int:server_id>')
@login_required
def server_view(server_id):
"""View server details"""
server = Server.query.get_or_404(server_id)
apps = App.query.filter_by(server_id=server_id).all()
return render_template(
'dashboard/server_view.html',
title=f'Server - {server.hostname}',
server=server,
apps=apps,
markdown=markdown.markdown,
now=datetime.now()
)
@bp.route('/server/new', methods=['GET', 'POST'])
@login_required
def server_new():
"""Create a new server"""
subnets = Subnet.query.all()
if request.method == 'POST':
hostname = request.form.get('hostname')
ip_address = request.form.get('ip_address')
subnet_id = request.form.get('subnet_id')
documentation = request.form.get('documentation', '')
# Basic validation
if not hostname or not ip_address or not subnet_id:
flash('Please fill in all required fields', 'danger')
return render_template(
'dashboard/server_form.html',
title='New Server',
subnets=subnets,
now=datetime.now()
)
# Check if hostname or IP already exists
if Server.query.filter_by(hostname=hostname).first():
flash('Hostname already exists', 'danger')
return render_template(
'dashboard/server_form.html',
title='New Server',
subnets=subnets,
now=datetime.now()
)
if Server.query.filter_by(ip_address=ip_address).first():
flash('IP address already exists', 'danger')
return render_template(
'dashboard/server_form.html',
title='New Server',
subnets=subnets,
now=datetime.now()
)
# Create new server
server = Server(
hostname=hostname,
ip_address=ip_address,
subnet_id=subnet_id,
documentation=documentation
)
db.session.add(server)
db.session.commit()
flash('Server created successfully', 'success')
return redirect(url_for('dashboard.server_view', server_id=server.id))
return render_template(
'dashboard/server_form.html',
title='New Server',
subnets=subnets,
now=datetime.now()
)
@bp.route('/server/<int:server_id>/edit', methods=['GET', 'POST'])
@login_required
def server_edit(server_id):
"""Edit an existing server"""
server = Server.query.get_or_404(server_id)
if request.method == 'POST':
hostname = request.form.get('hostname')
ip_address = request.form.get('ip_address')
subnet_id = request.form.get('subnet_id')
if not hostname or not ip_address or not subnet_id:
flash('All fields are required', 'danger')
return redirect(url_for('dashboard.server_edit', server_id=server_id))
# Check if hostname changed and already exists
if hostname != server.hostname and Server.query.filter_by(hostname=hostname).first():
flash('Hostname already exists', 'danger')
return redirect(url_for('dashboard.server_edit', server_id=server_id))
# Check if IP changed and already exists
if ip_address != server.ip_address and Server.query.filter_by(ip_address=ip_address).first():
flash('IP address already exists', 'danger')
return redirect(url_for('dashboard.server_edit', server_id=server_id))
# Update server
server.hostname = hostname
server.ip_address = ip_address
server.subnet_id = subnet_id
db.session.commit()
flash('Server updated successfully', 'success')
return redirect(url_for('dashboard.server_view', server_id=server.id))
# GET request - show form with current values
subnets = Subnet.query.all()
return render_template(
'dashboard/server_edit.html',
title=f'Edit Server - {server.hostname}',
server=server,
subnets=subnets
)
@bp.route('/server/<int:server_id>/delete', methods=['POST'])
@login_required
def server_delete(server_id):
"""Delete a server"""
server = Server.query.get_or_404(server_id)
# Delete all apps associated with this server
App.query.filter_by(server_id=server_id).delete()
# Delete the server
db.session.delete(server)
db.session.commit()
flash('Server deleted successfully', 'success')
return redirect(url_for('dashboard.dashboard_home'))
@bp.route('/app/new', methods=['GET', 'POST'])
@login_required
def app_new():
"""Create a new application"""
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', '')
# 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()
)
app = App(
name=name,
server_id=server_id,
documentation=documentation
)
db.session.add(app)
db.session.commit()
flash('Application created successfully', 'success')
return redirect(url_for('dashboard.server_view', server_id=server_id))
return render_template(
'dashboard/app_form.html',
title='New Application',
servers=servers,
now=datetime.now()
)
@bp.route('/app/<int:app_id>', methods=['GET'])
@login_required
def app_view(app_id):
"""View a specific application"""
app = App.query.get_or_404(app_id)
server = Server.query.get(app.server_id)
return render_template(
'dashboard/app_view.html',
title=f'Application - {app.name}',
app=app,
server=server,
markdown=markdown.markdown,
now=datetime.now()
)
@bp.route('/app/<int:app_id>/edit', methods=['GET', 'POST'])
@login_required
def app_edit(app_id):
"""Edit an existing application"""
app = App.query.get_or_404(app_id)
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[]')
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)
# Update app
app.name = name
app.server_id = server_id
app.documentation = documentation
app.ports = ports
db.session.commit()
flash('Application updated successfully', 'success')
return redirect(url_for('dashboard.app_view', app_id=app.id))
# 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
)
@bp.route('/app/<int:app_id>/delete', methods=['POST'])
@login_required
def app_delete(app_id):
"""Delete an application"""
app = App.query.get_or_404(app_id)
server_id = app.server_id
db.session.delete(app)
db.session.commit()
flash('Application deleted successfully', 'success')
return redirect(url_for('dashboard.server_view', server_id=server_id))

104
app/routes/importexport.py Normal file
View file

@ -0,0 +1,104 @@
from flask import Blueprint, request, jsonify, make_response, render_template
from app.core.models import Subnet, Server, App
from app.core.extensions import db
import csv
import io
import datetime
bp = Blueprint('importexport', __name__, url_prefix='/import-export')
MODEL_MAP = {
'subnet': Subnet,
'server': Server,
'app': App
}
@bp.route('/export/<model_name>', methods=['GET'])
def export_model(model_name):
if model_name not in MODEL_MAP:
return jsonify({'error': 'Invalid model name'}), 400
model = MODEL_MAP[model_name]
instances = model.query.all()
# Create a CSV file in memory
output = io.StringIO()
writer = csv.writer(output)
# Get column names from model
columns = [column.name for column in model.__table__.columns]
writer.writerow(columns)
# Write data
for instance in instances:
row = [getattr(instance, column) for column in columns]
writer.writerow(row)
# Create response
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{model_name}_{timestamp}.csv"
response = make_response(output.getvalue())
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
response.headers['Content-type'] = 'text/csv'
return response
@bp.route('/import/<model_name>', methods=['GET', 'POST'])
def import_model(model_name):
if model_name not in MODEL_MAP:
return jsonify({'error': 'Invalid model name'}), 400
model = MODEL_MAP[model_name]
if request.method == 'GET':
# Show import form
return render_template('import_form.html', model_name=model_name)
# Process CSV upload
if 'file' not in request.files:
return jsonify({'error': 'No file part'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'No selected file'}), 400
if not file.filename.endswith('.csv'):
return jsonify({'error': 'File must be CSV format'}), 400
try:
# Read CSV
stream = io.StringIO(file.stream.read().decode("UTF8"), newline=None)
csv_reader = csv.reader(stream)
# Get headers
headers = next(csv_reader)
# Validate required columns
required_columns = [col.name for col in model.__table__.columns
if not col.nullable and col.name != 'id']
for col in required_columns:
if col not in headers:
return jsonify({'error': f'Required column {col} missing'}), 400
# Process rows
imported = 0
for row in csv_reader:
data = dict(zip(headers, row))
# Remove id to create new record
if 'id' in data:
del data['id']
# Create new instance
instance = model(**data)
db.session.add(instance)
imported += 1
db.session.commit()
return jsonify({'success': f'Imported {imported} records successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500

145
app/routes/ipam.py Normal file
View file

@ -0,0 +1,145 @@
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify
from flask_login import login_required
from app.core.models import Subnet, Server
from app.core.extensions import db
from app.scripts.ip_scanner import scan
import ipaddress
from datetime import datetime
bp = Blueprint('ipam', __name__, url_prefix='/ipam')
@bp.route('/')
@login_required
def ipam_home():
"""Main IPAM dashboard"""
subnets = Subnet.query.all()
# Calculate usage for each subnet
for subnet in subnets:
subnet.usage_percent = subnet.used_ips / 254 * 100 if subnet.cidr.endswith('/24') else 0
return render_template(
'ipam/index.html',
title='IPAM Dashboard',
subnets=subnets,
now=datetime.now()
)
@bp.route('/subnet/new', methods=['GET', 'POST'])
@login_required
def subnet_new():
"""Create a new subnet"""
if request.method == 'POST':
cidr = request.form.get('cidr')
location = request.form.get('location')
auto_scan = 'auto_scan' in request.form
# 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()
)
# Check if valid CIDR
try:
ipaddress.ip_network(cidr)
except ValueError:
flash('Invalid CIDR notation', 'danger')
return render_template(
'ipam/subnet_form.html',
title='New Subnet',
now=datetime.now()
)
# Check if subnet 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()
)
subnet = Subnet(
cidr=cidr,
location=location,
auto_scan=auto_scan
)
db.session.add(subnet)
db.session.commit()
flash('Subnet created successfully', 'success')
return redirect(url_for('ipam.subnet_view', subnet_id=subnet.id))
return render_template(
'ipam/subnet_form.html',
title='New Subnet',
now=datetime.now()
)
@bp.route('/subnet/<int:subnet_id>')
@login_required
def subnet_view(subnet_id):
"""View subnet details"""
subnet = Subnet.query.get_or_404(subnet_id)
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
return render_template(
'ipam/subnet_view.html',
title=f'Subnet - {subnet.cidr}',
subnet=subnet,
servers=servers,
total_ips=total_ips,
used_ips=used_ips,
usage_percent=usage_percent,
now=datetime.now()
)
@bp.route('/subnet/<int:subnet_id>/scan')
@login_required
def subnet_scan(subnet_id):
"""Scan a subnet for active hosts"""
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')
except Exception as e:
flash(f'Error scanning subnet: {e}', 'danger')
return redirect(url_for('ipam.subnet_view', subnet_id=subnet_id))
@bp.route('/subnet/<int:subnet_id>/visualize')
@login_required
def subnet_visualize(subnet_id):
"""Visualize IP usage in a subnet"""
subnet = Subnet.query.get_or_404(subnet_id)
servers = Server.query.filter_by(subnet_id=subnet_id).all()
# Create a dictionary of used IPs
used_ips = {server.ip_address: server.hostname for server in servers}
# Get network info
network = ipaddress.ip_network(subnet.cidr)
total_ips = network.num_addresses - 2 # Excluding network and broadcast addresses
used_ip_count = len(servers)
return render_template(
'ipam/subnet_visualization.html',
title=f'Subnet Visualization - {subnet.cidr}',
subnet=subnet,
network=network,
used_ips=used_ips,
total_ips=total_ips,
used_ip_count=used_ip_count,
now=datetime.now()
)

Binary file not shown.

Binary file not shown.

27
app/scripts/db_seed.py Normal file
View file

@ -0,0 +1,27 @@
from app.core.extensions import db
from app.core.models import Subnet, Server, App
from app.core.auth import User
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"}]')
# 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")
db.session.add(admin)
db.session.add(subnet)
db.session.add(server)
db.session.add(app)
try:
db.session.commit()
print("Database seeded successfully")
except Exception as e:
db.session.rollback()
print(f"Error seeding database: {e}")

178
app/scripts/ip_scanner.py Normal file
View file

@ -0,0 +1,178 @@
import socket
import threading
import ipaddress
import time
from concurrent.futures import ThreadPoolExecutor
from app.core.extensions import db
from app.core.models import Subnet, Server
import json
import subprocess
def scan(cidr, max_threads=10, save_results=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
"""
print(f"Starting scan of {cidr}")
network = ipaddress.ip_network(cidr)
# 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]
# 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
def scan_worker(ip_list, results, index):
"""Worker function for threading"""
for ip in ip_list:
if ping(ip):
hostname = get_hostname(ip)
results[index].append({
'ip': str(ip),
'hostname': hostname if hostname else str(ip),
'status': 'up'
})
def ping(ip):
"""Ping an IP address and return True if it responds"""
try:
# Faster timeout (1 second)
subprocess.check_output(['ping', '-c', '1', '-W', '1', str(ip)], stderr=subprocess.STDOUT)
return True
except subprocess.CalledProcessError:
return False
def get_hostname(ip):
"""Try to get the hostname for an IP address"""
try:
hostname = socket.gethostbyaddr(str(ip))[0]
return hostname
except (socket.herror, socket.gaierror):
return None
def is_host_active_ping(ip):
"""Simple ICMP ping test (platform dependent)"""
import platform
import subprocess
param = '-n' if platform.system().lower() == 'windows' else '-c'
command = ['ping', param, '1', '-w', '1', ip]
try:
return subprocess.call(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
except:
return False
def save_scan_results(cidr, results):
"""Save scan results to the database"""
from flask import current_app
# Need to be in application context
if not hasattr(current_app, 'app_context'):
print("Not in Flask application context, cannot save results")
return
try:
# Find subnet by CIDR
subnet = Subnet.query.filter_by(cidr=cidr).first()
if not subnet:
print(f"Subnet {cidr} not found in database")
return
# Get existing servers in this subnet
existing_servers = {server.ip_address: server for server in Server.query.filter_by(subnet_id=subnet.id).all()}
# Process scan results
for host in results:
ip = host['ip']
hostname = host['hostname']
# Check if server already exists
if ip in existing_servers:
# Update hostname if it was previously unknown
if existing_servers[ip].hostname == ip and hostname != ip:
existing_servers[ip].hostname = hostname
db.session.add(existing_servers[ip])
else:
# Create new server
server = Server(
hostname=hostname,
ip_address=ip,
subnet_id=subnet.id,
documentation=f"# {hostname}\n\nAutomatically discovered by network scan on {time.strftime('%Y-%m-%d %H:%M:%S')}"
)
db.session.add(server)
db.session.commit()
print(f"Saved scan results for {cidr}")
except Exception as e:
db.session.rollback()
print(f"Error saving scan results: {e}")
def schedule_subnet_scans():
"""Schedule automatic scans for subnets marked as auto_scan"""
from flask import current_app
with current_app.app_context():
try:
# Find all subnets with auto_scan enabled
subnets = Subnet.query.filter_by(auto_scan=True).all()
for subnet in subnets:
# Start a thread for each subnet
thread = threading.Thread(
target=scan,
args=(subnet.cidr,),
daemon=True
)
thread.start()
# Sleep briefly to avoid overloading
time.sleep(1)
except Exception as e:
print(f"Error scheduling subnet scans: {e}")

183
app/static/css/app.css Normal file
View file

@ -0,0 +1,183 @@
/* Custom styles for the app */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f5f7fb;
color: #232e3c;
}
.markdown-body {
padding: 1rem;
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.125);
border-radius: 4px;
}
.markdown-body h1 {
font-size: 1.75rem;
margin-top: 0;
}
.markdown-body h2 {
font-size: 1.5rem;
}
.markdown-body h3 {
font-size: 1.25rem;
}
.markdown-body pre {
background-color: #f6f8fa;
border-radius: 3px;
padding: 16px;
overflow: auto;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1rem;
}
.markdown-body th,
.markdown-body td {
padding: 8px;
border: 1px solid #ddd;
}
.markdown-body th {
background-color: #f8f9fa;
}
/* IP Grid for subnet visualization */
.ip-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
}
.ip-cell {
font-size: 0.75rem;
padding: 6px;
border-radius: 4px;
text-align: center;
border: 1px solid rgba(0, 0, 0, 0.125);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ip-cell.used {
background-color: rgba(234, 88, 12, 0.1);
border-color: rgba(234, 88, 12, 0.5);
}
.ip-cell.available {
background-color: rgba(5, 150, 105, 0.1);
border-color: rgba(5, 150, 105, 0.5);
}
/* Stats cards on dashboard */
.stats-card {
transition: transform 0.2s;
}
.stats-card:hover {
transform: translateY(-5px);
}
/* Custom navbar styles */
.navbar-brand {
font-weight: 600;
}
/* Sidebar styles */
.sidebar {
width: 260px;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
background: #fff;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .15);
overflow-y: auto;
}
.sidebar-brand {
padding: 1.5rem;
display: flex;
align-items: center;
height: 64px;
}
.sidebar-brand-text {
font-size: 1.25rem;
font-weight: 600;
margin-left: 0.75rem;
}
.sidebar-nav {
padding: 0.75rem 1.5rem;
}
.sidebar-heading {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: #8898aa;
letter-spacing: 0.04em;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.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;
}
.sidebar-item:hover {
color: #5e72e4;
background: rgba(94, 114, 228, 0.1);
text-decoration: none;
}
.sidebar-item.active {
color: #5e72e4;
background: rgba(94, 114, 228, 0.1);
font-weight: 600;
}
.main-content {
margin-left: 260px;
}
/* Responsive sidebar */
@media (max-width: 992px) {
.sidebar {
left: -260px;
transition: left 0.3s ease;
}
.sidebar.show {
left: 0;
}
.main-content {
margin-left: 0;
}
}
/* Notification area */
#notification-area {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
width: 300px;
}

View file

@ -0,0 +1,14 @@
/* GitHub Markdown CSS (simplified version) */
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 1000px;
margin: 0 auto;
padding: 45px;
}
@media (max-width: 767px) {
.markdown-body {
padding: 15px;
}
}

142
app/static/js/app.js Normal file
View file

@ -0,0 +1,142 @@
document.addEventListener('DOMContentLoaded', () => {
console.log('App script loaded.');
// Initialize Tiptap editor if element exists
const editorElement = document.getElementById('editor');
if (editorElement) {
initTiptapEditor(editorElement);
}
// Add Bootstrap tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Close flash messages after 5 seconds
setTimeout(function () {
var alerts = document.querySelectorAll('.alert:not(.alert-persistent)');
alerts.forEach(function (alert) {
var bsAlert = bootstrap.Alert.getInstance(alert);
if (bsAlert) {
bsAlert.close();
} else {
alert.classList.add('fade');
setTimeout(function () {
alert.remove();
}, 150);
}
});
}, 5000);
// Add event listener for subnet scan buttons with HTMX
document.body.addEventListener('htmx:afterOnLoad', function (event) {
if (event.detail.xhr.status === 200) {
showNotification('Subnet scan started successfully', 'success');
}
});
// Add markdown preview for documentation fields
const docTextareas = document.querySelectorAll('textarea[name="documentation"]');
docTextareas.forEach(function (textarea) {
// Only if preview container exists
const previewContainer = document.getElementById('markdown-preview');
if (previewContainer && textarea) {
textarea.addEventListener('input', function () {
// Use the server to render the markdown (safer)
fetch('/api/markdown-preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: textarea.value })
})
.then(response => response.json())
.then(data => {
previewContainer.innerHTML = data.html;
});
});
}
});
});
function initTiptapEditor(element) {
// Load required Tiptap scripts
const editorContainer = document.getElementById('editor-container');
const preview = document.getElementById('markdown-preview');
// Initialize the Tiptap editor
const { Editor } = window.tiptap;
const { StarterKit } = window.tiptapExtensions;
const editor = new Editor({
element: element,
extensions: [
StarterKit
],
content: element.getAttribute('data-content') || '',
onUpdate: ({ editor }) => {
// Update preview with current content
if (preview) {
const markdown = editor.getHTML();
fetch('/api/markdown-preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ markdown: markdown })
})
.then(response => response.json())
.then(data => {
preview.innerHTML = data.html;
});
}
}
});
// Store editor reference
window.editor = editor;
// Form submission handling
const form = element.closest('form');
if (form) {
form.addEventListener('submit', () => {
const contentInput = form.querySelector('input[name="content"]');
if (contentInput) {
contentInput.value = editor.getHTML();
}
});
}
}
// Copy to clipboard function
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function () {
// Success notification
showNotification('Copied to clipboard!', 'success');
}, function (err) {
// Error notification
showNotification('Could not copy text', 'danger');
});
}
// Show notification
function showNotification(message, type = 'info') {
const notificationArea = document.getElementById('notification-area');
if (!notificationArea) return;
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show`;
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
notificationArea.appendChild(notification);
// Remove notification after 3 seconds
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => notification.remove(), 150);
}, 3000);
}

View file

@ -0,0 +1,9 @@
function copyToClipboard(text) {
const elem = document.createElement('textarea');
elem.value = text;
document.body.appendChild(elem);
elem.select();
document.execCommand('copy');
document.body.removeChild(elem);
alert('Copied to clipboard');
}

View file

@ -0,0 +1,50 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-sm mt-5">
<div class="card-body p-5">
<div class="text-center mb-4">
<h2 class="card-title">Login</h2>
<p class="text-muted">Sign in to your account</p>
</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" 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('auth.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-4">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-4 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">Remember me</label>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
<div class="mt-4 text-center">
<p>Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-sm mt-5">
<div class="card-body p-5">
<div class="text-center mb-4">
<h2 class="card-title">Register</h2>
<p class="text-muted">Create a new account</p>
</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" 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('auth.register') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-4">
<label for="password_confirm" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit" class="btn btn-primary w-100">Register</button>
</form>
<div class="mt-4 text-center">
<p>Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a></p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,173 @@
<form hx-post="{{ action_url }}" hx-target="#content">
<div class="form-group mb-3">
<label class="form-label">App Name</label>
<input type="text" name="name" class="form-control" value="{{ app.name if app else '' }}" required>
</div>
<div class="form-group mb-3">
<label class="form-label">App Type</label>
<select id="app-type" name="app_type" class="form-select">
<option value="">-- Select Type --</option>
<option value="web">Web Application</option>
<option value="database">Database</option>
<option value="mail">Mail Server</option>
<option value="file">File Server</option>
</select>
</div>
<div class="form-group mb-3">
<label class="form-label">Server</label>
<select name="server_id" class="form-select" required>
{% for server in servers %}
<option value="{{ server.id }}" {% if app and app.server_id==server.id %}selected{% endif %}>
{{ server.hostname }} ({{ server.ip_address }})
</option>
{% endfor %}
</select>
</div>
<div class="form-group mb-3">
<label class="form-label">Documentation</label>
{% include "components/markdown_editor.html" with content=app.documentation if app else "" %}
</div>
<div class="card mb-3">
<div class="card-header">
<div class="d-flex">
<h3 class="card-title">Ports</h3>
<div class="ms-auto">
<button type="button" class="btn btn-sm btn-outline-primary" id="suggest-ports">
Suggest Ports
</button>
</div>
</div>
</div>
<div class="card-body">
<div id="ports-container">
{% if app and app.ports %}
{% for port in app.ports %}
<div class="port-entry mb-2 row">
<div class="col-3">
<input type="number" name="port[]" class="form-control" value="{{ port.port }}" placeholder="Port" min="1"
max="65535">
</div>
<div class="col-3">
<select name="port_type[]" class="form-select">
<option value="tcp" {% if port.type=='tcp' %}selected{% endif %}>TCP</option>
<option value="udp" {% if port.type=='udp' %}selected{% endif %}>UDP</option>
</select>
</div>
<div class="col-5">
<input type="text" name="port_desc[]" class="form-control" value="{{ port.desc }}"
placeholder="Description">
</div>
<div class="col-1">
<button type="button" class="btn btn-sm btn-danger remove-port">×</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="port-entry mb-2 row">
<div class="col-3">
<input type="number" name="port[]" class="form-control" placeholder="Port" min="1" max="65535">
</div>
<div class="col-3">
<select name="port_type[]" class="form-select">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="col-5">
<input type="text" name="port_desc[]" class="form-control" placeholder="Description">
</div>
<div class="col-1">
<button type="button" class="btn btn-sm btn-danger remove-port">×</button>
</div>
</div>
{% endif %}
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="add-port">
Add Port
</button>
</div>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Add new port entry
document.getElementById('add-port').addEventListener('click', function () {
const template = document.querySelector('.port-entry').cloneNode(true);
const inputs = template.querySelectorAll('input, select');
inputs.forEach(input => input.value = '');
if (inputs[0].type === 'number') inputs[0].value = '';
document.getElementById('ports-container').appendChild(template);
// Re-attach remove handlers
attachRemoveHandlers();
});
// Remove port entry
function attachRemoveHandlers() {
document.querySelectorAll('.remove-port').forEach(button => {
button.onclick = function () {
const portEntries = document.querySelectorAll('.port-entry');
if (portEntries.length > 1) {
this.closest('.port-entry').remove();
} else {
const inputs = this.closest('.port-entry').querySelectorAll('input, select');
inputs.forEach(input => input.value = '');
}
};
});
}
attachRemoveHandlers();
// Port suggestions based on app type
document.getElementById('suggest-ports').addEventListener('click', function () {
const appType = document.getElementById('app-type').value;
fetch(`/api/ports/suggest?type=${appType}`)
.then(response => response.json())
.then(data => {
// Clear existing ports
const portsContainer = document.getElementById('ports-container');
portsContainer.innerHTML = '';
// Add suggested ports
data.forEach(port => {
const template = document.createElement('div');
template.className = 'port-entry mb-2 row';
template.innerHTML = `
<div class="col-3">
<input type="number" name="port[]" class="form-control" value="${port.port}" min="1" max="65535">
</div>
<div class="col-3">
<select name="port_type[]" class="form-select">
<option value="tcp" ${port.type === 'tcp' ? 'selected' : ''}>TCP</option>
<option value="udp" ${port.type === 'udp' ? 'selected' : ''}>UDP</option>
</select>
</div>
<div class="col-5">
<input type="text" name="port_desc[]" class="form-control" value="${port.desc}" placeholder="Description">
</div>
<div class="col-1">
<button type="button" class="btn btn-sm btn-danger remove-port">×</button>
</div>
`;
portsContainer.appendChild(template);
});
// Re-attach remove handlers
attachRemoveHandlers();
});
});
});
</script>

View file

@ -0,0 +1,105 @@
<div class="markdown-editor">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="editor-toolbar">
<button type="button" data-action="bold" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-bold"></i>
</button>
<button type="button" data-action="italic" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-italic"></i>
</button>
<button type="button" data-action="heading" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-h-1"></i>
</button>
<button type="button" data-action="bulletList" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-list"></i>
</button>
<button type="button" data-action="orderedList" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-list-numbers"></i>
</button>
<button type="button" data-action="link" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-link"></i>
</button>
<button type="button" data-action="code" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-code"></i>
</button>
</div>
<div id="editor-container" class="mt-2">
<div id="editor"></div>
</div>
<input type="hidden" name="{{ field_name }}" id="markdown-content" value="{{ content }}">
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Preview</h3>
</div>
<div class="card-body p-0">
<div id="markdown-preview" class="markdown-body p-3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Initialize Tiptap editor
const editor = new window.tiptap.Editor({
element: document.getElementById('editor'),
extensions: [
window.tiptapStarterKit.StarterKit,
window.tiptapLink.Link,
window.tiptapCodeBlock.CodeBlock,
window.tiptapImage.Image
],
content: document.getElementById('markdown-content').value || '',
onUpdate: ({ editor }) => {
// Update the hidden input with the markdown content
const markdown = editor.storage.markdown.getMarkdown();
document.getElementById('markdown-content').value = markdown;
// Update preview
updatePreview(markdown);
}
});
// Initialize the preview with the current content
updatePreview(editor.storage.markdown.getMarkdown());
// Initialize the toolbar buttons
document.querySelectorAll('.editor-toolbar button').forEach(button => {
button.addEventListener('click', () => {
const action = button.dataset.action;
if (action === 'link') {
const url = prompt('URL');
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
} else if (action === 'heading') {
editor.chain().focus().toggleHeading({ level: 1 }).run();
} else {
editor.chain().focus()[`toggle${action.charAt(0).toUpperCase() + action.slice(1)}`]().run();
}
});
});
// Function to update preview
function updatePreview(markdown) {
const converter = new showdown.Converter({
tables: true,
tasklists: true,
strikethrough: true,
ghCodeBlocks: true
});
const html = converter.makeHtml(markdown);
document.getElementById('markdown-preview').innerHTML = html;
}
});
</script>

View file

@ -0,0 +1,6 @@
<div class="port-row" hx-target="this">
<span>{{ port.number }}/{{ port.protocol }}</span>
<button hx-get="/port/{{ port.id }}/copy" class="btn btn-sm">
Kopieren
</button>
</div>

View file

@ -0,0 +1,9 @@
{% extends "layout.html" %}
{% block content %}
<h1>Dashboard</h1>
<div id="server-list">
<!-- Server list will be dynamically loaded here -->
</div>
<script src="/static/js/app.js"></script>
{% endblock %}

View file

@ -0,0 +1,57 @@
{% 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">
Add New Application
</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_new') }}">
<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" 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 }}">{{ 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"></textarea>
<small class="form-hint">Supports Markdown formatting</small>
</div>
<div class="d-flex justify-content-end">
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-link me-2">Cancel</a>
<button type="submit" class="btn btn-primary">Save Application</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,125 @@
{% 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">Dashboard</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-list">
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-primary d-none d-sm-inline-block">
<i class="ti ti-plus"></i> New Server
</a>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-4">
<div class="card stats-card">
<div class="card-body p-4 text-center">
<div class="h1 m-0">{{ server_count }}</div>
<div class="text-muted mb-3">Servers</div>
<div class="d-flex justify-content-center">
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-sm btn-primary">
View All
</a>
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-sm btn-outline-primary ms-2">
Add New
</a>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stats-card">
<div class="card-body p-4 text-center">
<div class="h1 m-0">{{ subnet_count }}</div>
<div class="text-muted mb-3">Subnets</div>
<div class="d-flex justify-content-center">
<a href="{{ url_for('ipam.ipam_home') }}" class="btn btn-sm btn-primary">
View All
</a>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stats-card">
<div class="card-body p-4 text-center">
<div class="h1 m-0">{{ app_count }}</div>
<div class="text-muted mb-3">Applications</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Recent Servers</h3>
</div>
<div class="card-body">
{% if latest_servers %}
<div class="list-group list-group-flush">
{% for server in latest_servers %}
<a href="{{ url_for('dashboard.server_view', server_id=server.id) }}"
class="list-group-item list-group-item-action">
<div class="row align-items-center">
<div class="col-auto">
<span class="avatar bg-primary text-white">
{{ server.hostname[0].upper() }}
</span>
</div>
<div class="col text-truncate">
<div class="d-block text-truncate">{{ server.hostname }}</div>
<div class="text-muted text-truncate small">{{ server.ip_address }}</div>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center py-3">
<div class="mb-3">No servers added yet</div>
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-outline-primary">
Add Your First Server
</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Subnet Utilization</h3>
</div>
<div class="card-body">
{% if subnets %}
{% for subnet in subnets %}
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<div>{{ subnet.cidr }}</div>
<div>{{ subnet.used_ips }} / 254 IPs</div>
</div>
<div class="progress">
<div class="progress-bar bg-primary" style="width: {{ subnet.usage_percent }}%"></div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-3">
<div class="mb-3">No subnets added yet</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,61 @@
{% 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">
Add New Server
</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.server_new') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label required">Hostname</label>
<input type="text" class="form-control" name="hostname" required>
</div>
<div class="mb-3">
<label class="form-label required">IP Address</label>
<input type="text" class="form-control" name="ip_address" placeholder="192.168.1.10" required>
</div>
<div class="mb-3">
<label class="form-label required">Subnet</label>
<select class="form-select" name="subnet_id" required>
<option value="">Select a subnet</option>
{% for subnet in subnets %}
<option value="{{ subnet.id }}">{{ subnet.cidr }} ({{ subnet.location }})</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"></textarea>
<small class="form-hint">Supports Markdown formatting</small>
</div>
<div class="d-flex justify-content-end">
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-link me-2">Cancel</a>
<button type="submit" class="btn btn-primary">Save Server</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,62 @@
{% 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">
Servers
</h2>
</div>
<div class="col-auto ms-auto">
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i> Add Server
</a>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
{% if servers %}
<div class="table-responsive">
<table class="table table-vcenter table-hover">
<thead>
<tr>
<th>Hostname</th>
<th>IP Address</th>
<th>Subnet</th>
<th>Created</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
{% for server in servers %}
<tr>
<td>{{ server.hostname }}</td>
<td>{{ server.ip_address }}</td>
<td>{{ server.subnet.cidr if server.subnet else 'N/A' }}</td>
<td>{{ server.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('dashboard.server_view', server_id=server.id) }}" class="btn btn-sm btn-primary">
View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<div class="mb-3">No servers added yet</div>
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-primary">
Add Your First Server
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,142 @@
{% 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">
{{ server.hostname }}
</h2>
<div class="text-muted mt-1">{{ server.ip_address }}</div>
</div>
<div class="col-auto ms-auto d-print-none">
<a href="{{ url_for('dashboard.server_list') }}" class="btn btn-link">
Back to Servers
</a>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">Documentation</h3>
</div>
<div class="card-body markdown-body">
{% if server.documentation %}
{{ markdown(server.documentation)|safe }}
{% else %}
<div class="text-center text-muted py-3">
No documentation available for this server.
</div>
{% endif %}
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h3 class="card-title">Applications</h3>
<a href="{{ url_for('dashboard.app_new') }}" class="btn btn-sm btn-primary">
Add Application
</a>
</div>
</div>
<div class="card-body">
{% if apps %}
<table class="table table-vcenter">
<thead>
<tr>
<th>Name</th>
<th>Ports</th>
</tr>
</thead>
<tbody>
{% for app in apps %}
<tr>
<td>{{ app.name }}</td>
<td>
{% for port in app.ports %}
<span class="badge bg-primary">
{{ port.port }}/{{ port.type }} {% if port.desc %}({{ port.desc }}){% endif %}
</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center py-3">
<div class="mb-3">No applications registered for this server</div>
<a href="{{ url_for('dashboard.app_new') }}" class="btn btn-outline-primary">
Add Application
</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3 class="card-title">Server Information</h3>
</div>
<div class="card-body">
<div class="mb-2">
<strong>Hostname:</strong> {{ server.hostname }}
</div>
<div class="mb-2">
<strong>IP Address:</strong> {{ server.ip_address }}
</div>
<div class="mb-2">
<strong>Subnet:</strong> {{ server.subnet.cidr if server.subnet else 'N/A' }}
</div>
<div class="mb-2">
<strong>Location:</strong> {{ server.subnet.location if server.subnet else 'N/A' }}
</div>
<div class="mb-2">
<strong>Created:</strong> {{ server.created_at.strftime('%Y-%m-%d') }}
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Open Ports</h3>
</div>
<div class="card-body">
{% if server.get_open_ports() %}
<div class="list-group list-group-flush">
{% for port in server.get_open_ports() %}
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-auto">
<span class="badge bg-primary">{{ port.port }}</span>
</div>
<div class="col">
<div class="text-truncate">
{{ port.type|upper }}
{% if port.desc %}
<span class="text-muted">{{ port.desc }}</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-3">
No open ports detected.
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

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

View file

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

View file

@ -0,0 +1,56 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<h1>Import {{ model_name|capitalize }} Data</h1>
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data" id="import-form">
<div class="mb-3">
<label class="form-label">CSV File</label>
<input type="file" name="file" class="form-control" accept=".csv" required>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">Import</button>
<a href="/import-export/template/{{ model_name }}" class="btn btn-outline-secondary">
Download Template
</a>
</div>
</form>
<div id="import-result" class="mt-3" style="display: none;"></div>
</div>
</div>
</div>
<script>
document.getElementById('import-form').addEventListener('submit', function (e) {
e.preventDefault();
const formData = new FormData(this);
fetch('/import-export/import/{{ model_name }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
const resultDiv = document.getElementById('import-result');
resultDiv.style.display = 'block';
if (data.error) {
resultDiv.innerHTML = `<div class="alert alert-danger">${data.error}</div>`;
} else {
resultDiv.innerHTML = `<div class="alert alert-success">${data.success}</div>`;
}
})
.catch(error => {
console.error('Error:', error);
document.getElementById('import-result').innerHTML =
'<div class="alert alert-danger">An error occurred during import</div>';
document.getElementById('import-result').style.display = 'block';
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,60 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<h1>IPAM Dashboard</h1>
<div class="card">
<div class="card-header">
<h3 class="card-title">Subnetz-Übersicht</h3>
<div class="card-actions">
<button class="btn btn-primary" hx-get="/ipam/subnet/new" hx-target="#modal-container">
Neues Subnetz
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>CIDR</th>
<th>Standort</th>
<th>IP-Belegung</th>
<th>Auto-Scan</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for subnet in subnets %}
<tr>
<td>{{ subnet.cidr }}</td>
<td>{{ subnet.location }}</td>
<td>
<div class="progress progress-sm">
{% set percentage = (subnet.used_ips / subnet.total_ips * 100) | int %}
<div class="progress-bar bg-blue" style="width: {{ percentage }}%" role="progressbar"
aria-valuenow="{{ percentage }}" aria-valuemin="0" aria-valuemax="100">
{{ percentage }}%
</div>
</div>
</td>
<td>{{ "Ja" if subnet.auto_scan else "Nein" }}</td>
<td>
<a href="/ipam/subnet/{{ subnet.id }}" class="btn btn-sm btn-outline-primary">Details</a>
<button hx-post="/ipam/subnet/{{ subnet.id }}/scan" hx-swap="none"
class="btn btn-sm btn-outline-secondary" {% if not subnet.auto_scan %}disabled{% endif %}>
Scan starten
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div id="modal-container"></div>
{% endblock %}

View file

@ -0,0 +1,81 @@
{% 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">
IP Address Management
</h2>
</div>
<div class="col-auto ms-auto">
<a href="{{ url_for('ipam.subnet_new') }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i> Add Subnet
</a>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
{% if subnets %}
<div class="table-responsive">
<table class="table table-vcenter table-hover">
<thead>
<tr>
<th>Subnet</th>
<th>Location</th>
<th>Usage</th>
<th>Auto Scan</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
{% for subnet in subnets %}
<tr>
<td>{{ subnet.cidr }}</td>
<td>{{ subnet.location }}</td>
<td>
<div class="d-flex align-items-center">
<div class="me-2">{{ subnet.used_ips }}/254</div>
<div class="progress flex-grow-1" style="height: 5px;">
<div class="progress-bar bg-primary" style="width: {{ subnet.usage_percent }}%"></div>
</div>
</div>
</td>
<td>
{% if subnet.auto_scan %}
<span class="badge bg-success">Enabled</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</td>
<td>
<div class="btn-group">
<a href="{{ url_for('ipam.subnet_view', subnet_id=subnet.id) }}" class="btn btn-sm btn-primary">
View
</a>
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}"
class="btn btn-sm btn-outline-primary">
Scan
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<div class="mb-3">No subnets added yet</div>
<a href="{{ url_for('ipam.subnet_new') }}" class="btn btn-primary">
Add Your First Subnet
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,149 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
<div class="d-flex mb-3">
<div>
<h1>Subnetz: {{ subnet.cidr }}</h1>
<p>Standort: {{ subnet.location }}</p>
</div>
<div class="ms-auto">
<button class="btn btn-outline-primary" hx-get="/ipam/subnet/{{ subnet.id }}/export" hx-swap="none">
CSV-Export
</button>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h3 class="card-title">Subnetz-Details</h3>
<div class="mb-3">
<label class="form-label">Netzwerk-Adresse</label>
<div>{{ subnet.cidr.split('/')[0] }}</div>
</div>
<div class="mb-3">
<label class="form-label">Präfixlänge</label>
<div>{{ subnet.cidr.split('/')[1] }}</div>
</div>
<div class="mb-3">
<label class="form-label">Erste nutzbare IP</label>
<div>{{ first_ip }}</div>
</div>
<div class="mb-3">
<label class="form-label">Letzte nutzbare IP</label>
<div>{{ last_ip }}</div>
</div>
<div class="mb-3">
<label class="form-label">Gesamte IPs</label>
<div>{{ total_ips }}</div>
</div>
<div class="mb-3">
<label class="form-label">Belegte IPs</label>
<div>{{ used_ips }}</div>
</div>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">IP-Belegung</h3>
</div>
<div class="card-body">
<div id="subnet-visualization" class="subnet-map">
<!-- Wird via JavaScript befüllt -->
<div class="text-center p-3">
<div class="spinner-border text-blue" role="status"></div>
<div>Lade Daten...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Load the subnet visualization
fetch('/ipam/subnet/{{ subnet.id }}/visualization')
.then(response => response.json())
.then(data => {
const container = document.getElementById('subnet-visualization');
container.innerHTML = '';
// Create a grid of IP boxes
const grid = document.createElement('div');
grid.className = 'ip-grid';
data.forEach(ip => {
const box = document.createElement('div');
box.className = `ip-box ${ip.status}`;
box.title = ip.hostname ? `${ip.ip} - ${ip.hostname}` : ip.ip;
const addressSpan = document.createElement('span');
addressSpan.className = 'ip-address';
addressSpan.textContent = ip.ip.split('.')[3]; // Only show last octet
box.appendChild(addressSpan);
if (ip.hostname) {
const hostnameSpan = document.createElement('span');
hostnameSpan.className = 'ip-hostname';
hostnameSpan.textContent = ip.hostname;
box.appendChild(hostnameSpan);
}
grid.appendChild(box);
});
container.appendChild(grid);
})
.catch(error => {
console.error('Error loading subnet visualization:', error);
document.getElementById('subnet-visualization').innerHTML =
'<div class="alert alert-danger">Fehler beim Laden der Visualisierung</div>';
});
});
</script>
<style>
.ip-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 5px;
}
.ip-box {
border: 1px solid #ccc;
padding: 5px;
text-align: center;
font-size: 12px;
border-radius: 3px;
}
.ip-box.used {
background-color: #e6f2ff;
border-color: #99c2ff;
}
.ip-box.available {
background-color: #f2f2f2;
}
.ip-address {
display: block;
font-weight: bold;
}
.ip-hostname {
display: block;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
{% endblock %}

View file

@ -0,0 +1,51 @@
{% 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">
Add New Subnet
</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('ipam.subnet_new') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label required">CIDR Notation</label>
<input type="text" class="form-control" name="cidr" placeholder="192.168.1.0/24" required>
<small class="form-hint">Example: 192.168.1.0/24</small>
</div>
<div class="mb-3">
<label class="form-label required">Location</label>
<input type="text" class="form-control" name="location" placeholder="Office, Datacenter, etc." required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="auto_scan" name="auto_scan">
<label class="form-check-label" for="auto_scan">Enable automatic scanning</label>
</div>
<div class="d-flex justify-content-end">
<a href="{{ url_for('ipam.ipam_home') }}" class="btn btn-link me-2">Cancel</a>
<button type="submit" class="btn btn-primary">Save Subnet</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,132 @@
{% 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">
{{ subnet.cidr }}
</h2>
<div class="text-muted mt-1">{{ subnet.location }}</div>
</div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-list">
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
<i class="fas fa-search me-2"></i> Scan Subnet
</a>
<a href="{{ url_for('ipam.subnet_visualize', subnet_id=subnet.id) }}" class="btn btn-outline-primary">
<i class="fas fa-chart-network me-2"></i> Visualize
</a>
<a href="{{ url_for('ipam.ipam_home') }}" class="btn btn-link">
Back to IPAM
</a>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">Registered Hosts</h3>
</div>
<div class="card-body">
{% if servers %}
<div class="table-responsive">
<table class="table table-vcenter">
<thead>
<tr>
<th>Hostname</th>
<th>IP Address</th>
<th>Created</th>
<th class="w-1"></th>
</tr>
</thead>
<tbody>
{% for server in servers %}
<tr>
<td>{{ server.hostname }}</td>
<td>{{ server.ip_address }}</td>
<td>{{ server.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('dashboard.server_view', server_id=server.id) }}"
class="btn btn-sm btn-primary">
View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-3">
<div class="mb-3">No hosts registered in this subnet</div>
<a href="{{ url_for('dashboard.server_new') }}" class="btn btn-outline-primary">
Add New Server
</a>
<a href="{{ url_for('ipam.subnet_scan', subnet_id=subnet.id) }}" class="btn btn-outline-primary ms-2">
Scan Subnet
</a>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3 class="card-title">Subnet Information</h3>
</div>
<div class="card-body">
<div class="mb-2">
<strong>Network:</strong> {{ subnet.cidr }}
</div>
<div class="mb-2">
<strong>Location:</strong> {{ subnet.location }}
</div>
<div class="mb-2">
<strong>Total IPs:</strong> {{ total_ips }}
</div>
<div class="mb-2">
<strong>Used IPs:</strong> {{ used_ips }} ({{ '%.1f'|format(usage_percent) }}%)
</div>
<div class="mb-2">
<strong>Available IPs:</strong> {{ total_ips - used_ips }}
</div>
<div class="mb-2">
<strong>Auto Scan:</strong>
{% if subnet.auto_scan %}
<span class="badge bg-success">Enabled</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</div>
<div class="mb-2">
<strong>Created:</strong> {{ subnet.created_at.strftime('%Y-%m-%d') }}
</div>
</div>
</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>
{% endblock %}

View file

@ -0,0 +1,206 @@
{% 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">
IP Visualization - {{ subnet.cidr }}
</h2>
<div class="text-muted mt-1">{{ subnet.location }}</div>
</div>
<div class="col-auto ms-auto d-print-none">
<a href="{{ url_for('ipam.subnet_view', subnet_id=subnet.id) }}" class="btn btn-link">
Back to Subnet
</a>
</div>
</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">IP Address Map</h3>
<div>
<span class="badge bg-success me-2">Available: {{ total_ips - used_ip_count }}</span>
<span class="badge bg-danger">Used: {{ used_ip_count }}</span>
</div>
</div>
</div>
<div class="card-body">
<div class="ip-grid">
{% set network_parts = subnet.cidr.split('/')[0].split('.') %}
{% set network_prefix = network_parts[0] + '.' + network_parts[1] + '.' + network_parts[2] + '.' %}
{% for i in range(1, 255) %}
{% set ip = network_prefix + i|string %}
{% if ip in used_ips %}
<div class="ip-cell used" title="{{ used_ips[ip] }}">
{{ ip }}
</div>
{% else %}
<div class="ip-cell available" title="Available">
{{ ip }}
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
<script>
document.addEventListener('DOMContentLoaded', function () {
loadIpMap();
// Handle scan button response
document.body.addEventListener('htmx:afterRequest', function (event) {
if (event.detail.target.matches('button[hx-post^="/ipam/subnet/"][hx-post$="/scan"]')) {
if (event.detail.successful) {
const toast = document.createElement('div');
toast.className = 'toast align-items-center show position-fixed bottom-0 end-0 m-3';
toast.setAttribute('role', 'alert');
toast.setAttribute('aria-live', 'assertive');
toast.setAttribute('aria-atomic', 'true');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
Subnet scan started. Results will be available shortly.
</div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
document.body.appendChild(toast);
// Auto-remove toast after 5 seconds
setTimeout(() => {
toast.remove();
}, 5000);
// Reload IP map after a delay to allow scan to complete
setTimeout(() => {
loadIpMap();
}, 3000);
}
}
});
});
function loadIpMap() {
const ipMap = document.getElementById('ip-map');
const loadingIndicator = document.getElementById('loading-indicator');
fetch('/ipam/subnet/{{ subnet.id }}/visualization')
.then(response => response.json())
.then(data => {
// Hide loading indicator
loadingIndicator.style.display = 'none';
// Create IP grid
const grid = document.createElement('div');
grid.className = 'ip-grid';
// Add each IP address to the grid
data.forEach(ip => {
const cell = document.createElement('div');
cell.className = `ip-cell ${ip.status}`;
// Add IP address
const ipAddress = document.createElement('span');
ipAddress.className = 'ip-address';
ipAddress.textContent = ip.ip;
cell.appendChild(ipAddress);
// Add hostname if exists
if (ip.hostname) {
const hostname = document.createElement('span');
hostname.className = 'ip-hostname';
hostname.textContent = ip.hostname;
cell.appendChild(hostname);
}
// Add click handler for details
cell.addEventListener('click', () => {
if (ip.status === 'used') {
window.location.href = `/dashboard/server/${ip.server_id}`;
}
});
grid.appendChild(cell);
});
ipMap.appendChild(grid);
})
.catch(error => {
console.error('Error loading subnet visualization:', error);
document.getElementById('ip-map').innerHTML =
'<div class="alert alert-danger">Error loading subnet visualization. Please try again later.</div>';
document.getElementById('loading-indicator').style.display = 'none';
});
}
</script>
<style>
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.ip-grid-container {
min-height: 400px;
position: relative;
}
.ip-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 4px;
}
.ip-cell {
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 4px;
text-align: center;
cursor: pointer;
height: 50px;
display: flex;
flex-direction: column;
justify-content: center;
font-size: 12px;
transition: all 0.2s ease;
}
.ip-cell:hover {
transform: scale(1.05);
z-index: 10;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.ip-cell.used {
background-color: rgba(32, 107, 196, 0.1);
border-color: rgba(32, 107, 196, 0.5);
}
.ip-cell.available {
background-color: rgba(5, 150, 105, 0.1);
border-color: rgba(5, 150, 105, 0.5);
}
.ip-address {
font-weight: bold;
}
.ip-hostname {
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

147
app/templates/layout.html Normal file
View file

@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title if title else 'Network Documentation' }}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Tabler Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@2.22.0/tabler-icons.min.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
{% block styles %}{% endblock %}
</head>
<body class="{{ 'auth-page' if not current_user.is_authenticated else '' }}">
<!-- Notification Area -->
<div id="notification-area"></div>
{% if current_user.is_authenticated %}
<!-- Sidebar for authenticated users -->
<aside class="sidebar">
<div class="sidebar-brand">
<span class="ti ti-network"></span>
<span class="sidebar-brand-text">NetDocs</span>
</div>
<div class="sidebar-nav">
<div class="sidebar-heading">Main</div>
<a href="{{ url_for('dashboard.dashboard_home') }}"
class="sidebar-item {{ 'active' if request.endpoint == 'dashboard.dashboard_home' }}">
<span class="ti ti-dashboard me-2"></span> Dashboard
</a>
<a href="{{ url_for('dashboard.server_list') }}"
class="sidebar-item {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.server') }}">
<span class="ti ti-server me-2"></span> Servers
</a>
<a href="{{ url_for('ipam.ipam_home') }}"
class="sidebar-item {{ 'active' if request.endpoint and request.endpoint.startswith('ipam.') }}">
<span class="ti ti-network me-2"></span> IPAM
</a>
<div class="sidebar-heading">Management</div>
<a href="{{ url_for('dashboard.server_new') }}" class="sidebar-item">
<span class="ti ti-plus me-2"></span> Add Server
</a>
<a href="{{ url_for('ipam.subnet_new') }}" class="sidebar-item">
<span class="ti ti-plus me-2"></span> Add Subnet
</a>
<div class="sidebar-heading">User</div>
<a href="{{ url_for('auth.logout') }}" class="sidebar-item">
<span class="ti ti-logout me-2"></span> Logout
</a>
</div>
</aside>
<!-- Main Content for authenticated users -->
<div class="main-content">
<!-- Top Navbar -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom mb-4">
<div class="container-fluid">
<button class="sidebar-toggler btn btn-outline-secondary d-lg-none me-2">
<span class="ti ti-menu-2"></span>
</button>
<span class="navbar-brand d-none d-lg-block">
{{ title if title else 'Network Documentation' }}
</span>
<div class="d-flex align-items-center">
<div class="dropdown">
<a href="#" class="d-flex align-items-center text-decoration-none dropdown-toggle"
id="user-dropdown" data-bs-toggle="dropdown" aria-expanded="false">
<span class="d-none d-md-inline me-2">{{ current_user.email }}</span>
<span class="ti ti-user"></span>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="user-dropdown">
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a></li>
</ul>
</div>
</div>
</div>
</nav>
<!-- Flash Messages -->
<div class="container-fluid mt-3">
{% 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 %}
</div>
{% else %}
<!-- Simple flash message container for non-authenticated users -->
<div class="container mt-3">
{% 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 %}
</div>
{% endif %}
<!-- ONLY ONE CONTENT BLOCK FOR BOTH AUTHENTICATED AND NON-AUTHENTICATED STATES -->
<div class="{{ 'py-4' if current_user.is_authenticated else '' }}">
{% block content %}{% endblock %}
</div>
{% if current_user.is_authenticated %}
</div> <!-- End of main-content div that was opened for authenticated users -->
{% endif %}
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- HTMX for dynamic content -->
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
// Sidebar toggle for mobile - using modern event listener approach
const sidebarToggler = document.querySelector('.sidebar-toggler');
if (sidebarToggler) {
sidebarToggler.addEventListener('click', () => {
document.querySelector('.sidebar').classList.toggle('show');
});
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

15
app/templates/server.html Normal file
View file

@ -0,0 +1,15 @@
{% extends 'layout.html' %}
{% block content %}
<h2>Server Details - {{ server.hostname }}</h2>
<div>
<p><strong>IP Address:</strong> {{ server.ip_address }}</p>
<p><strong>Open Ports:</strong> {{ server.get_open_ports() | join(', ') }}</p>
<div class="markdown-body">
<h3>Documentation:</h3>
<div id="markdown-preview">
{{ markdown(server.documentation) | safe }}
</div>
</div>
</div>
{% endblock %}

25
config/Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install gunicorn psycopg2-binary redis
# Copy application code
COPY . .
# Set environment variables
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Run the application
CMD ["gunicorn", "-w", "4", "-b", ":8000", "app:create_app()"]

Binary file not shown.

53
config/docker-compose.yml Normal file
View file

@ -0,0 +1,53 @@
version: '3'
services:
app:
build:
context: ..
dockerfile: config/Dockerfile
command: gunicorn -w 4 -b :8000 "app:create_app()" --access-logfile - --error-logfile -
volumes:
- ../app:/app/app
environment:
- FLASK_APP=app
- FLASK_ENV=production
- DATABASE_URL=postgresql://user:password@db:5432/app_db
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=${SECRET_KEY:-default_secret_key_change_in_production}
depends_on:
- db
- redis
restart: unless-stopped
nginx:
image: nginx:1.25
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/conf.d:/etc/nginx/conf.d
- ./nginx/ssl:/etc/nginx/ssl
depends_on:
- app
restart: unless-stopped
db:
image: postgres:15
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: app_db
volumes:
- pg_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
pg_data:
redis_data:

64
config/settings.py Normal file
View file

@ -0,0 +1,64 @@
import os
from datetime import timedelta
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
"""Base config."""
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-placeholder')
SQLALCHEMY_TRACK_MODIFICATIONS = False
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_DURATION = timedelta(days=14)
PERMANENT_SESSION_LIFETIME = timedelta(days=1)
@staticmethod
def init_app(app):
pass
class DevelopmentConfig(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')
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'testing.db')
WTF_CSRF_ENABLED = False
class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(basedir, '..', 'instance', 'production.db')
@classmethod
def init_app(cls, app):
Config.init_app(app)
# Production-specific logging
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.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')
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}

BIN
instance/development.db Normal file

Binary file not shown.

BIN
instance/production.db Normal file

Binary file not shown.

44
requirements.txt Normal file
View file

@ -0,0 +1,44 @@
# Flask and core extensions
Flask==2.3.3
flask-sqlalchemy==3.1.1
flask-login==0.6.3
flask-limiter==3.5.0
flask-bcrypt==1.0.1
flask-wtf==1.2.1
ipaddress==1.0.23
gunicorn==21.2.0
Werkzeug==2.3.7
Jinja2==3.1.2
MarkupSafe==2.1.3
itsdangerous==2.1.2
# SQLAlchemy==2.0.23
WTForms==3.1.0
python-dotenv==1.0.0
markdown==3.5.1
# SQLAlchemy - use newer version for Python 3.13 compatibility
SQLAlchemy>=2.0.27
# Security
Flask-Bcrypt>=1.0.1
itsdangerous>=2.1.2
python-dotenv>=1.0.0
Flask-Limiter>=3.5.0
# Removed psycopg2-binary dependency
# Caching
redis>=5.0.1
# Utilities
requests>=2.31.0
markdown>=3.5.1
ipaddress>=1.0.23
gunicorn>=21.2.0
# WSGI
gunicorn==21.2.0
# Testing
pytest>=7.4.3
pytest-flask>=1.3.0

0
routes/__init__.py Normal file
View file

71
run.py Normal file
View file

@ -0,0 +1,71 @@
import os
import sys
import importlib.util
from flask import Flask, render_template
from app import create_app
# Add the current directory to Python path
current_dir = os.path.abspath(os.path.dirname(__file__))
sys.path.insert(0, current_dir)
def create_basic_app():
"""Create a Flask app without database dependencies"""
app = Flask(__name__,
template_folder=os.path.join(current_dir, 'app', 'templates'),
static_folder=os.path.join(current_dir, 'app', 'static'))
# Basic configuration
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-key-placeholder')
app.config['DEBUG'] = True
# Register basic routes
register_routes(app)
# Add a fallback index route if no routes match
@app.route('/')
def index():
return "Your Network Management Flask Application is running! Navigate to /dashboard to see content."
return app
def register_routes(app):
"""Register blueprints without database dependencies"""
routes_dir = os.path.join(current_dir, 'app', 'routes')
# Check if routes directory exists
if not os.path.isdir(routes_dir):
print(f"Warning: Routes directory {routes_dir} not found")
return
# Try to register API blueprint which is simplest
try:
from app.routes.api import bp as api_bp
app.register_blueprint(api_bp)
print("Registered API blueprint")
except Exception as e:
print(f"Could not register API blueprint: {e}")
# Try to register other blueprints with basic error handling
try:
from app.routes.dashboard import bp as dashboard_bp
app.register_blueprint(dashboard_bp)
print("Registered dashboard blueprint")
except ImportError as e:
print(f"Could not import dashboard blueprint: {e}")
try:
from app.routes.ipam import bp as ipam_bp
app.register_blueprint(ipam_bp)
print("Registered IPAM blueprint")
except ImportError as e:
print(f"Could not import IPAM blueprint: {e}")
if __name__ == '__main__':
try:
print("Starting Flask app with SQLite database...")
app = create_app('development')
app.run(debug=True, host='0.0.0.0', port=5000)
except Exception as e:
print(f"Error starting Flask app: {e}")
import traceback
traceback.print_exc()

47
scripts/setup.sh Normal file
View file

@ -0,0 +1,47 @@
#!/bin/bash
# Setup script for Docker deployment
# Create SSL certificates for NGINX
mkdir -p config/nginx/ssl
if [ ! -f config/nginx/ssl/server.crt ]; then
echo "Generating self-signed SSL certificates..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout config/nginx/ssl/server.key \
-out config/nginx/ssl/server.crt \
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"
fi
# Create empty .env file if it doesn't exist
if [ ! -f .env ]; then
echo "Creating .env file with default values..."
cat > .env << EOF
# Flask application settings
FLASK_APP=app
FLASK_ENV=production
SECRET_KEY=$(openssl rand -hex 24)
# Database settings
DATABASE_URL=postgresql://user:password@db:5432/app_db
# Redis settings
REDIS_URL=redis://redis:6379/0
# Email settings
MAIL_SERVER=smtp.example.com
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_USERNAME=user@example.com
MAIL_PASSWORD=password
MAIL_DEFAULT_SENDER=user@example.com
# Security settings
HTTPS_ENABLED=True
EOF
fi
# Create uploads directory
mkdir -p app/uploads
echo "Setup complete. You can now run 'docker-compose up -d' to start the application."

24
tests/conftest.py Normal file
View file

@ -0,0 +1,24 @@
import pytest
from app import create_app
from app.core.extensions import db as _db
@pytest.fixture
def app():
app = create_app('testing')
app.config['TESTING'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
with app.app_context():
_db.create_all()
yield app
_db.session.remove()
_db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def db(app):
with app.app_context():
yield _db

4
tests/pytest.ini Normal file
View file

@ -0,0 +1,4 @@
[pytest]
minversion = 6.0
cache_dir = .pytest_cache
addopts = --strict-markers

11
tests/test_app.py Normal file
View file

@ -0,0 +1,11 @@
import pytest
from app.core import models
# Example test
@pytest.mark.parametrize("cidr, expected", [
('192.168.1.0/24', 256),
('10.0.0.0/8', 16777216),
])
def test_subnet_cidr_parsing(cidr, expected):
subnet = models.Subnet(cidr=cidr)
assert subnet.used_ips == expected

51
tests/test_models.py Normal file
View file

@ -0,0 +1,51 @@
import pytest
from app.core.models import Subnet, Server, App
import ipaddress
def test_subnet_cidr_validation():
"""Test CIDR format validation for Subnet model"""
# Valid CIDR formats
valid_cidrs = [
'192.168.1.0/24',
'10.0.0.0/8',
'172.16.0.0/16'
]
for cidr in valid_cidrs:
subnet = Subnet(cidr=cidr, location='Test')
# This shouldn't raise an exception
ipaddress.ip_network(subnet.cidr)
# Invalid CIDR formats should raise ValueError
invalid_cidrs = [
'192.168.1.0', # Missing mask
'192.168.1.0/33', # Invalid mask
'256.0.0.0/24' # Invalid IP
]
for cidr in invalid_cidrs:
subnet = Subnet(cidr=cidr, location='Test')
with pytest.raises(ValueError):
ipaddress.ip_network(subnet.cidr)
def test_server_open_ports(app):
"""Test the get_open_ports method"""
# Create test server with app having specific ports
subnet = Subnet(cidr='192.168.1.0/24', location='Test')
server = Server(hostname='test-server', ip_address='192.168.1.10', subnet=subnet)
app1 = App(
name='Test App',
server=server,
ports=[
{'port': 80, 'type': 'tcp', 'status': 'open', 'desc': 'HTTP'},
{'port': 443, 'type': 'tcp', 'status': 'open', 'desc': 'HTTPS'},
{'port': 8080, 'type': 'tcp', 'status': 'closed', 'desc': 'Alt HTTP'}
]
)
# The get_open_ports should only return ports with status 'open'
open_ports = server.get_open_ports()
assert len(open_ports) == 2
assert {'port': 80, 'type': 'tcp', 'status': 'open', 'desc': 'HTTP'} in open_ports
assert {'port': 443, 'type': 'tcp', 'status': 'open', 'desc': 'HTTPS'} in open_ports
assert {'port': 8080, 'type': 'tcp', 'status': 'closed', 'desc': 'Alt HTTP'} not in open_ports

62
tests/test_routes.py Normal file
View file

@ -0,0 +1,62 @@
import pytest
from flask import url_for
def test_dashboard_home(client):
"""Test dashboard home route"""
response = client.get('/dashboard/')
assert response.status_code == 200
assert b"Dashboard" in response.data
def test_server_view(client, app):
"""Test server detail view"""
# Create test server
from app.core.models import Subnet, Server
from app.core.extensions import db
with app.app_context():
subnet = Subnet(cidr='192.168.1.0/24', location='Test')
server = Server(hostname='test-server', ip_address='192.168.1.10', subnet=subnet)
db.session.add(subnet)
db.session.add(server)
db.session.commit()
# Test viewing the server
response = client.get(f'/dashboard/server/{server.id}')
assert response.status_code == 200
assert b'test-server' in response.data
assert b'192.168.1.10' in response.data
def test_api_status(client):
"""Test API status endpoint"""
response = client.get('/api/status')
assert response.status_code == 200
json_data = response.get_json()
assert json_data['status'] == 'OK'
def test_markdown_preview(client):
"""Test markdown preview API"""
md_content = "# Test Heading\nThis is a test."
response = client.post('/api/markdown-preview',
json={'markdown': md_content})
assert response.status_code == 200
json_data = response.get_json()
assert '<h1>Test Heading</h1>' in json_data['html']
assert '<p>This is a test.</p>' in json_data['html']
def test_subnet_scan(client, app):
"""Test subnet scanning API"""
# Create test subnet
from app.core.models import Subnet
from app.core.extensions import db
with app.app_context():
subnet = Subnet(cidr='192.168.1.0/24', location='Test', auto_scan=True)
db.session.add(subnet)
db.session.commit()
# Test scanning endpoint
response = client.post(f'/ipam/subnet/{subnet.id}/scan')
assert response.status_code == 200
json_data = response.get_json()
assert json_data['status'] == 'scanning'
assert json_data['subnet_id'] == subnet.id

7
wsgi.py Normal file
View file

@ -0,0 +1,7 @@
from app import create_app
# Create application instance - production
app = create_app('production')
if __name__ == '__main__':
app.run()