batman
This commit is contained in:
commit
66f9ce3614
33 changed files with 2271 additions and 0 deletions
62
app/__init__.py
Normal file
62
app/__init__.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
"""
|
||||
NetViz application factory.
|
||||
"""
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_talisman import Talisman
|
||||
from flask_seasurf import SeaSurf
|
||||
|
||||
from app.extensions import db, migrate, login_manager, limiter, mail, session
|
||||
from app.utils.security import get_secure_headers
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
"""Create and configure the Flask application using the factory pattern."""
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
|
||||
# Load config
|
||||
if test_config is None:
|
||||
app.config.from_object("app.config.Config")
|
||||
else:
|
||||
app.config.from_mapping(test_config)
|
||||
|
||||
# Ensure instance folder exists
|
||||
try:
|
||||
os.makedirs(app.instance_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
limiter.init_app(app)
|
||||
mail.init_app(app)
|
||||
session.init_app(app)
|
||||
|
||||
# Security headers
|
||||
if app.config.get("SECURE_HEADERS_ENABLED", False):
|
||||
Talisman(app, **get_secure_headers())
|
||||
|
||||
# CSRF protection
|
||||
csrf = SeaSurf(app)
|
||||
|
||||
# Register blueprints
|
||||
from app.auth import auth_bp
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
from app.core import core_bp
|
||||
app.register_blueprint(core_bp)
|
||||
|
||||
from app.api import api_bp
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
# Error handlers
|
||||
from app.utils.error_handlers import register_error_handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# CLI commands
|
||||
from app.utils.commands import register_commands
|
||||
register_commands(app)
|
||||
|
||||
return app
|
8
app/api/__init__.py
Normal file
8
app/api/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
"""
|
||||
API blueprint for JSON endpoints.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
api_bp = Blueprint("api", __name__, url_prefix="/api/v1")
|
||||
|
||||
from app.api import routes # noqa
|
134
app/api/routes.py
Normal file
134
app/api/routes.py
Normal file
|
@ -0,0 +1,134 @@
|
|||
"""
|
||||
API routes for the NetViz application.
|
||||
"""
|
||||
from flask import jsonify, request, abort
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app.api import api_bp
|
||||
from app.models.network import Network, Subnet, Device, FirewallRule
|
||||
from app.extensions import db, limiter
|
||||
|
||||
|
||||
@api_bp.route("/health")
|
||||
def health_check():
|
||||
"""Health check endpoint."""
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@api_bp.route("/networks")
|
||||
@login_required
|
||||
@limiter.limit("60/minute")
|
||||
def get_networks():
|
||||
"""Get all networks for the current user."""
|
||||
networks = Network.query.filter_by(user_id=current_user.id).all()
|
||||
return jsonify([{
|
||||
"id": network.id,
|
||||
"name": network.name,
|
||||
"description": network.description,
|
||||
"created_at": network.created_at.isoformat(),
|
||||
"updated_at": network.updated_at.isoformat(),
|
||||
"subnet_count": len(network.subnets),
|
||||
"device_count": len(network.devices)
|
||||
} for network in networks])
|
||||
|
||||
|
||||
@api_bp.route("/networks/<int:network_id>")
|
||||
@login_required
|
||||
@limiter.limit("60/minute")
|
||||
def get_network(network_id):
|
||||
"""Get a specific network."""
|
||||
network = Network.query.get_or_404(network_id)
|
||||
|
||||
# Security check
|
||||
if network.user_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
return jsonify(network.to_dict())
|
||||
|
||||
|
||||
@api_bp.route("/networks/<int:network_id>/export")
|
||||
@login_required
|
||||
@limiter.limit("10/minute")
|
||||
def export_network(network_id):
|
||||
"""Export a network as JSON."""
|
||||
network = Network.query.get_or_404(network_id)
|
||||
|
||||
# Security check
|
||||
if network.user_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
return jsonify(network.to_dict())
|
||||
|
||||
|
||||
@api_bp.route("/networks/import", methods=["POST"])
|
||||
@login_required
|
||||
@limiter.limit("5/minute")
|
||||
def import_network():
|
||||
"""Import a network from JSON."""
|
||||
if not request.is_json:
|
||||
return jsonify({"error": "Request must be JSON"}), 400
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
try:
|
||||
# Create network
|
||||
network = Network.from_dict(data, current_user.id)
|
||||
db.session.add(network)
|
||||
db.session.flush() # Get ID without committing
|
||||
|
||||
# Create subnets
|
||||
for subnet_data in data.get("subnets", []):
|
||||
subnet = Subnet(
|
||||
name=subnet_data["name"],
|
||||
cidr=subnet_data["cidr"],
|
||||
vlan=subnet_data.get("vlan"),
|
||||
description=subnet_data.get("description", ""),
|
||||
network_id=network.id
|
||||
)
|
||||
db.session.add(subnet)
|
||||
|
||||
db.session.flush()
|
||||
|
||||
# Create devices
|
||||
for device_data in data.get("devices", []):
|
||||
device = Device(
|
||||
name=device_data["name"],
|
||||
ip_address=device_data.get("ip_address"),
|
||||
mac_address=device_data.get("mac_address"),
|
||||
device_type=device_data.get("device_type"),
|
||||
os=device_data.get("os"),
|
||||
description=device_data.get("description", ""),
|
||||
network_id=network.id,
|
||||
subnet_id=device_data.get("subnet_id")
|
||||
)
|
||||
if "properties" in device_data:
|
||||
device.set_properties(device_data["properties"])
|
||||
db.session.add(device)
|
||||
|
||||
# Create firewall rules
|
||||
for rule_data in data.get("firewall_rules", []):
|
||||
rule = FirewallRule(
|
||||
name=rule_data["name"],
|
||||
source=rule_data["source"],
|
||||
destination=rule_data["destination"],
|
||||
protocol=rule_data.get("protocol"),
|
||||
port_range=rule_data.get("port_range"),
|
||||
action=rule_data["action"],
|
||||
description=rule_data.get("description", ""),
|
||||
network_id=network.id
|
||||
)
|
||||
db.session.add(rule)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
"message": "Network imported successfully",
|
||||
"network_id": network.id
|
||||
}), 201
|
||||
|
||||
except KeyError as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"error": f"Missing required field: {str(e)}"}), 400
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({"error": str(e)}), 400
|
8
app/auth/__init__.py
Normal file
8
app/auth/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
"""
|
||||
Authentication blueprint for user management.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
|
||||
from app.auth import routes # noqa
|
95
app/auth/forms.py
Normal file
95
app/auth/forms.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
"""
|
||||
Forms for authentication and user management.
|
||||
"""
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, BooleanField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
"""Form for user login."""
|
||||
username = StringField("Username", validators=[DataRequired()])
|
||||
password = PasswordField("Password", validators=[DataRequired()])
|
||||
remember_me = BooleanField("Remember Me")
|
||||
submit = SubmitField("Sign In")
|
||||
|
||||
|
||||
class RegistrationForm(FlaskForm):
|
||||
"""Form for user registration."""
|
||||
username = StringField("Username", validators=[
|
||||
DataRequired(),
|
||||
Length(min=3, max=64)
|
||||
])
|
||||
email = StringField("Email", validators=[
|
||||
DataRequired(),
|
||||
Email(),
|
||||
Length(max=120)
|
||||
])
|
||||
password = PasswordField("Password", validators=[
|
||||
DataRequired(),
|
||||
Length(min=8, message="Password must be at least 8 characters long")
|
||||
])
|
||||
password2 = PasswordField("Confirm Password", validators=[
|
||||
DataRequired(),
|
||||
EqualTo("password", message="Passwords must match")
|
||||
])
|
||||
submit = SubmitField("Register")
|
||||
|
||||
def validate_username(self, username):
|
||||
"""Validate that the username is not already taken."""
|
||||
user = User.query.filter_by(username=username.data).first()
|
||||
if user is not None:
|
||||
raise ValidationError("Username already taken. Please choose a different one.")
|
||||
|
||||
def validate_email(self, email):
|
||||
"""Validate that the email is not already registered."""
|
||||
user = User.query.filter_by(email=email.data).first()
|
||||
if user is not None:
|
||||
raise ValidationError("Email already registered. Please use a different email or reset your password.")
|
||||
|
||||
def validate_password(self, password):
|
||||
"""Validate password complexity."""
|
||||
pwd = password.data
|
||||
|
||||
# Check for minimum complexity
|
||||
has_upper = any(c.isupper() for c in pwd)
|
||||
has_lower = any(c.islower() for c in pwd)
|
||||
has_digit = any(c.isdigit() for c in pwd)
|
||||
has_special = any(not c.isalnum() for c in pwd)
|
||||
|
||||
if not (has_upper and has_lower and has_digit):
|
||||
raise ValidationError("Password must contain uppercase, lowercase, and digit characters.")
|
||||
|
||||
|
||||
class ResetPasswordRequestForm(FlaskForm):
|
||||
"""Form to request a password reset."""
|
||||
email = StringField("Email", validators=[DataRequired(), Email()])
|
||||
submit = SubmitField("Request Password Reset")
|
||||
|
||||
|
||||
class ResetPasswordForm(FlaskForm):
|
||||
"""Form to reset password."""
|
||||
password = PasswordField("New Password", validators=[
|
||||
DataRequired(),
|
||||
Length(min=8, message="Password must be at least 8 characters long")
|
||||
])
|
||||
password2 = PasswordField("Confirm New Password", validators=[
|
||||
DataRequired(),
|
||||
EqualTo("password", message="Passwords must match")
|
||||
])
|
||||
submit = SubmitField("Reset Password")
|
||||
|
||||
def validate_password(self, password):
|
||||
"""Validate password complexity."""
|
||||
pwd = password.data
|
||||
|
||||
# Check for minimum complexity
|
||||
has_upper = any(c.isupper() for c in pwd)
|
||||
has_lower = any(c.islower() for c in pwd)
|
||||
has_digit = any(c.isdigit() for c in pwd)
|
||||
has_special = any(not c.isalnum() for c in pwd)
|
||||
|
||||
if not (has_upper and has_lower and has_digit):
|
||||
raise ValidationError("Password must contain uppercase, lowercase, and digit characters.")
|
127
app/auth/routes.py
Normal file
127
app/auth/routes.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
"""
|
||||
Authentication routes for user management.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from flask import render_template, redirect, url_for, flash, request, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.urls import url_parse
|
||||
|
||||
from app.auth import auth_bp
|
||||
from app.auth.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm, ResetPasswordForm
|
||||
from app.models.user import User
|
||||
from app.extensions import db, limiter
|
||||
from app.utils.email import send_password_reset_email
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
@limiter.limit("10/minute")
|
||||
def login():
|
||||
"""User login route."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("core.index"))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(username=form.username.data).first()
|
||||
|
||||
if user is None or not user.check_password(form.password.data):
|
||||
if user:
|
||||
user.record_login(success=False)
|
||||
flash("Invalid username or password", "danger")
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
if not user.active:
|
||||
flash("This account has been deactivated", "danger")
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
if user.is_account_locked():
|
||||
flash("This account is temporarily locked due to too many failed login attempts", "danger")
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
user.record_login(success=True)
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
|
||||
next_page = request.args.get("next")
|
||||
if not next_page or url_parse(next_page).netloc != "":
|
||||
next_page = url_for("core.index")
|
||||
|
||||
return redirect(next_page)
|
||||
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
|
||||
@auth_bp.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout route."""
|
||||
logout_user()
|
||||
flash("You have been logged out", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["GET", "POST"])
|
||||
@limiter.limit("5/hour")
|
||||
def register():
|
||||
"""User registration route."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("core.index"))
|
||||
|
||||
form = RegistrationForm()
|
||||
if form.validate_on_submit():
|
||||
user = User(
|
||||
username=form.username.data,
|
||||
email=form.email.data
|
||||
)
|
||||
user.set_password(form.password.data)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash("Registration successful! You can now log in.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
return render_template("auth/register.html", form=form)
|
||||
|
||||
|
||||
@auth_bp.route("/reset-password-request", methods=["GET", "POST"])
|
||||
@limiter.limit("5/hour")
|
||||
def reset_password_request():
|
||||
"""Request a password reset."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("core.index"))
|
||||
|
||||
form = ResetPasswordRequestForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
if user:
|
||||
send_password_reset_email(user)
|
||||
|
||||
# Always show the same message to prevent user enumeration
|
||||
flash("If your email address is registered, you will receive instructions to reset your password", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
return render_template("auth/reset_password_request.html", form=form)
|
||||
|
||||
|
||||
@auth_bp.route("/reset-password/<token>", methods=["GET", "POST"])
|
||||
@limiter.limit("5/hour")
|
||||
def reset_password(token):
|
||||
"""Reset password with token."""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("core.index"))
|
||||
|
||||
user = User.query.filter_by(reset_token=token).first()
|
||||
|
||||
if not user or not user.is_reset_token_valid(token):
|
||||
flash("The password reset link is invalid or has expired", "danger")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
form = ResetPasswordForm()
|
||||
if form.validate_on_submit():
|
||||
user.set_password(form.password.data)
|
||||
user.clear_reset_token()
|
||||
|
||||
flash("Your password has been reset", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
return render_template("auth/reset_password.html", form=form)
|
61
app/config.py
Normal file
61
app/config.py
Normal file
|
@ -0,0 +1,61 @@
|
|||
"""
|
||||
Configuration settings for the NetViz application.
|
||||
"""
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration class."""
|
||||
# Flask
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-not-for-production")
|
||||
SECURITY_PASSWORD_SALT = os.environ.get("SECURITY_PASSWORD_SALT", "dev-salt-not-for-production")
|
||||
|
||||
# Database
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///netviz.db")
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# Session
|
||||
SESSION_TYPE = os.environ.get("SESSION_TYPE", "filesystem")
|
||||
SESSION_PERMANENT = True
|
||||
SESSION_USE_SIGNER = True
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=1)
|
||||
|
||||
if SESSION_TYPE == "redis":
|
||||
SESSION_REDIS = os.environ.get("SESSION_REDIS")
|
||||
|
||||
# Security headers
|
||||
SECURE_HEADERS_ENABLED = os.environ.get("SECURE_HEADERS_ENABLED", "False").lower() == "true"
|
||||
|
||||
# Rate limiting
|
||||
RATELIMIT_STORAGE_URL = os.environ.get("RATELIMIT_STORAGE_URL", "memory://")
|
||||
RATELIMIT_DEFAULT = os.environ.get("RATELIMIT_DEFAULT", "200/day;50/hour;10/minute")
|
||||
RATELIMIT_HEADERS_ENABLED = True
|
||||
|
||||
# Mail
|
||||
MAIL_SERVER = os.environ.get("MAIL_SERVER")
|
||||
MAIL_PORT = int(os.environ.get("MAIL_PORT", 587))
|
||||
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS", "True").lower() == "true"
|
||||
MAIL_USE_SSL = os.environ.get("MAIL_USE_SSL", "False").lower() == "true"
|
||||
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
|
||||
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
|
||||
MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER")
|
||||
|
||||
# Application settings
|
||||
MAX_NETWORKS_PER_USER = 50
|
||||
MAX_HOSTS_PER_NETWORK = 500
|
||||
PASSWORD_RESET_TIMEOUT = 3600 # seconds
|
||||
ACCOUNT_LOCKOUT_THRESHOLD = 5 # attempts
|
||||
ACCOUNT_LOCKOUT_DURATION = 1800 # seconds
|
||||
|
||||
|
||||
class TestConfig(Config):
|
||||
"""Test configuration."""
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
||||
WTF_CSRF_ENABLED = False
|
||||
RATELIMIT_ENABLED = False
|
8
app/core/__init__.py
Normal file
8
app/core/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
"""
|
||||
Core functionality blueprint for the NetViz application.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
core_bp = Blueprint("core", __name__)
|
||||
|
||||
from app.core import routes # noqa
|
95
app/core/forms.py
Normal file
95
app/core/forms.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
"""
|
||||
Forms for the core functionality of the NetViz application.
|
||||
"""
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, TextAreaField, IntegerField, SelectField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length, Optional, NumberRange, ValidationError
|
||||
import ipaddress
|
||||
|
||||
|
||||
class NetworkForm(FlaskForm):
|
||||
"""Form for creating and editing networks."""
|
||||
name = StringField("Network Name", validators=[
|
||||
DataRequired(),
|
||||
Length(min=3, max=128)
|
||||
])
|
||||
description = TextAreaField("Description", validators=[Optional()])
|
||||
submit = SubmitField("Save Network")
|
||||
|
||||
|
||||
class SubnetForm(FlaskForm):
|
||||
"""Form for creating and editing subnets."""
|
||||
name = StringField("Subnet Name", validators=[
|
||||
DataRequired(),
|
||||
Length(min=3, max=128)
|
||||
])
|
||||
cidr = StringField("CIDR Notation", validators=[DataRequired()])
|
||||
vlan = IntegerField("VLAN ID", validators=[
|
||||
Optional(),
|
||||
NumberRange(min=0, max=4095)
|
||||
])
|
||||
description = TextAreaField("Description", validators=[Optional()])
|
||||
submit = SubmitField("Save Subnet")
|
||||
|
||||
def validate_cidr(self, cidr):
|
||||
"""Validate CIDR notation."""
|
||||
try:
|
||||
ipaddress.IPv4Network(cidr.data, strict=False)
|
||||
except ValueError:
|
||||
try:
|
||||
ipaddress.IPv6Network(cidr.data, strict=False)
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid CIDR notation. Example formats: 192.168.1.0/24 or 2001:db8::/64")
|
||||
|
||||
|
||||
class DeviceForm(FlaskForm):
|
||||
"""Form for creating and editing devices."""
|
||||
name = StringField("Device Name", validators=[
|
||||
DataRequired(),
|
||||
Length(min=3, max=128)
|
||||
])
|
||||
ip_address = StringField("IP Address", validators=[Optional()])
|
||||
mac_address = StringField("MAC Address", validators=[Optional()])
|
||||
device_type = SelectField("Device Type", choices=[
|
||||
("server", "Server"),
|
||||
("router", "Router"),
|
||||
("switch", "Switch"),
|
||||
("firewall", "Firewall"),
|
||||
("client", "Client"),
|
||||
("other", "Other")
|
||||
])
|
||||
os = StringField("Operating System", validators=[Optional()])
|
||||
subnet_id = SelectField("Subnet", coerce=int, validators=[Optional()])
|
||||
description = TextAreaField("Description", validators=[Optional()])
|
||||
submit = SubmitField("Save Device")
|
||||
|
||||
def validate_ip_address(self, ip_address):
|
||||
"""Validate IP address format."""
|
||||
if ip_address.data:
|
||||
try:
|
||||
ipaddress.ip_address(ip_address.data)
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid IP address format")
|
||||
|
||||
|
||||
class FirewallRuleForm(FlaskForm):
|
||||
"""Form for creating and editing firewall rules."""
|
||||
name = StringField("Rule Name", validators=[
|
||||
DataRequired(),
|
||||
Length(min=3, max=128)
|
||||
])
|
||||
source = StringField("Source", validators=[DataRequired()])
|
||||
destination = StringField("Destination", validators=[DataRequired()])
|
||||
protocol = SelectField("Protocol", choices=[
|
||||
("any", "Any"),
|
||||
("tcp", "TCP"),
|
||||
("udp", "UDP"),
|
||||
("icmp", "ICMP")
|
||||
])
|
||||
port_range = StringField("Port Range", validators=[Optional()])
|
||||
action = SelectField("Action", choices=[
|
||||
("allow", "Allow"),
|
||||
("deny", "Deny")
|
||||
])
|
||||
description = TextAreaField("Description", validators=[Optional()])
|
||||
submit = SubmitField("Save Rule")
|
151
app/core/routes.py
Normal file
151
app/core/routes.py
Normal file
|
@ -0,0 +1,151 @@
|
|||
"""
|
||||
Core routes for the NetViz application.
|
||||
"""
|
||||
from flask import render_template, redirect, url_for, flash, request, current_app, abort
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app.core import core_bp
|
||||
from app.core.forms import NetworkForm, SubnetForm, DeviceForm, FirewallRuleForm
|
||||
from app.models.network import Network, Subnet, Device, FirewallRule
|
||||
from app.extensions import db
|
||||
from app.utils.visualization import generate_network_diagram
|
||||
|
||||
|
||||
@core_bp.route("/")
|
||||
def index():
|
||||
"""Landing page route."""
|
||||
if current_user.is_authenticated:
|
||||
networks = Network.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template("core/dashboard.html", networks=networks)
|
||||
|
||||
return render_template("core/index.html")
|
||||
|
||||
|
||||
@core_bp.route("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""User dashboard route."""
|
||||
networks = Network.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template("core/dashboard.html", networks=networks)
|
||||
|
||||
|
||||
# Network CRUD routes
|
||||
@core_bp.route("/networks/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_network():
|
||||
"""Create a new network."""
|
||||
form = NetworkForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
network = Network(
|
||||
name=form.name.data,
|
||||
description=form.description.data,
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
db.session.add(network)
|
||||
db.session.commit()
|
||||
|
||||
flash("Network created successfully", "success")
|
||||
return redirect(url_for("core.view_network", network_id=network.id))
|
||||
|
||||
return render_template("core/network_form.html", form=form, title="Create Network")
|
||||
|
||||
|
||||
@core_bp.route("/networks/<int:network_id>")
|
||||
@login_required
|
||||
def view_network(network_id):
|
||||
"""View a network and its details."""
|
||||
network = Network.query.get_or_404(network_id)
|
||||
|
||||
# Check if the user owns this network
|
||||
if network.user_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
# Generate network visualization
|
||||
diagram = generate_network_diagram(network)
|
||||
|
||||
return render_template(
|
||||
"core/network_view.html",
|
||||
network=network,
|
||||
diagram=diagram,
|
||||
subnets=network.subnets,
|
||||
devices=network.devices,
|
||||
firewall_rules=network.firewall_rules
|
||||
)
|
||||
|
||||
|
||||
@core_bp.route("/networks/<int:network_id>/edit", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_network(network_id):
|
||||
"""Edit an existing network."""
|
||||
network = Network.query.get_or_404(network_id)
|
||||
|
||||
# Check if the user owns this network
|
||||
if network.user_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
form = NetworkForm(obj=network)
|
||||
|
||||
if form.validate_on_submit():
|
||||
network.name = form.name.data
|
||||
network.description = form.description.data
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash("Network updated successfully", "success")
|
||||
return redirect(url_for("core.view_network", network_id=network.id))
|
||||
|
||||
return render_template("core/network_form.html", form=form, title="Edit Network", network=network)
|
||||
|
||||
|
||||
@core_bp.route("/networks/<int:network_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_network(network_id):
|
||||
"""Delete a network."""
|
||||
network = Network.query.get_or_404(network_id)
|
||||
|
||||
# Check if the user owns this network
|
||||
if network.user_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
db.session.delete(network)
|
||||
db.session.commit()
|
||||
|
||||
flash("Network deleted successfully", "success")
|
||||
return redirect(url_for("core.dashboard"))
|
||||
|
||||
|
||||
# Subnet routes
|
||||
@core_bp.route("/networks/<int:network_id>/subnets/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def create_subnet(network_id):
|
||||
"""Create a new subnet within a network."""
|
||||
network = Network.query.get_or_404(network_id)
|
||||
|
||||
# Check if the user owns this network
|
||||
if network.user_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
form = SubnetForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
subnet = Subnet(
|
||||
name=form.name.data,
|
||||
cidr=form.cidr.data,
|
||||
vlan=form.vlan.data,
|
||||
description=form.description.data,
|
||||
network_id=network.id
|
||||
)
|
||||
|
||||
db.session.add(subnet)
|
||||
db.session.commit()
|
||||
|
||||
flash("Subnet created successfully", "success")
|
||||
return redirect(url_for("core.view_network", network_id=network.id))
|
||||
|
||||
return render_template("core/subnet_form.html", form=form, title="Create Subnet", network=network)
|
||||
|
||||
|
||||
# Similar routes for devices, firewall rules, etc.
|
||||
# For brevity, not all routes are shown here but would follow the same pattern
|
28
app/extensions.py
Normal file
28
app/extensions.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
"""
|
||||
Flask extensions for the NetViz application.
|
||||
"""
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_mail import Mail
|
||||
from flask_session import Session
|
||||
|
||||
# Database
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
|
||||
# Authentication
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = "auth.login"
|
||||
login_manager.login_message_category = "info"
|
||||
|
||||
# Rate limiting
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# Email
|
||||
mail = Mail()
|
||||
|
||||
# Server-side sessions
|
||||
session = Session()
|
175
app/models/network.py
Normal file
175
app/models/network.py
Normal file
|
@ -0,0 +1,175 @@
|
|||
"""
|
||||
Network models for topology visualization.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
import json
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Network(db.Model):
|
||||
"""Network model representing a network topology."""
|
||||
__tablename__ = "networks"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Ownership
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||
owner = db.relationship("User", back_populates="networks")
|
||||
|
||||
# Related objects
|
||||
subnets = db.relationship("Subnet", back_populates="network", cascade="all, delete-orphan")
|
||||
devices = db.relationship("Device", back_populates="network", cascade="all, delete-orphan")
|
||||
firewall_rules = db.relationship("FirewallRule", back_populates="network", cascade="all, delete-orphan")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert network to dictionary for API responses and exports."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"subnets": [subnet.to_dict() for subnet in self.subnets],
|
||||
"devices": [device.to_dict() for device in self.devices],
|
||||
"firewall_rules": [rule.to_dict() for rule in self.firewall_rules]
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Convert network to JSON string for exports."""
|
||||
return json.dumps(self.to_dict(), indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any], user_id: int) -> "Network":
|
||||
"""Create a network from a dictionary (for imports)."""
|
||||
network = cls(
|
||||
name=data["name"],
|
||||
description=data.get("description", ""),
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
return network
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Network {self.name}>"
|
||||
|
||||
|
||||
class Subnet(db.Model):
|
||||
"""Subnet model representing a network subnet."""
|
||||
__tablename__ = "subnets"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
cidr = db.Column(db.String(64), nullable=False)
|
||||
vlan = db.Column(db.Integer)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Parent network
|
||||
network_id = db.Column(db.Integer, db.ForeignKey("networks.id"), nullable=False)
|
||||
network = db.relationship("Network", back_populates="subnets")
|
||||
|
||||
# Devices in this subnet
|
||||
devices = db.relationship("Device", back_populates="subnet")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert subnet to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"cidr": self.cidr,
|
||||
"vlan": self.vlan,
|
||||
"description": self.description
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Subnet {self.cidr}>"
|
||||
|
||||
|
||||
class Device(db.Model):
|
||||
"""Device model representing a network device or host."""
|
||||
__tablename__ = "devices"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
ip_address = db.Column(db.String(64))
|
||||
mac_address = db.Column(db.String(64))
|
||||
device_type = db.Column(db.String(64)) # server, router, switch, etc.
|
||||
os = db.Column(db.String(128))
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Parent network
|
||||
network_id = db.Column(db.Integer, db.ForeignKey("networks.id"), nullable=False)
|
||||
network = db.relationship("Network", back_populates="devices")
|
||||
|
||||
# Subnet membership
|
||||
subnet_id = db.Column(db.Integer, db.ForeignKey("subnets.id"))
|
||||
subnet = db.relationship("Subnet", back_populates="devices")
|
||||
|
||||
# Additional properties stored as JSON
|
||||
properties = db.Column(db.Text)
|
||||
|
||||
def get_properties(self) -> Dict[str, Any]:
|
||||
"""Get device properties from JSON field."""
|
||||
if not self.properties:
|
||||
return {}
|
||||
return json.loads(self.properties)
|
||||
|
||||
def set_properties(self, properties: Dict[str, Any]) -> None:
|
||||
"""Set device properties as JSON."""
|
||||
self.properties = json.dumps(properties)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert device to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"ip_address": self.ip_address,
|
||||
"mac_address": self.mac_address,
|
||||
"device_type": self.device_type,
|
||||
"os": self.os,
|
||||
"description": self.description,
|
||||
"subnet_id": self.subnet_id,
|
||||
"properties": self.get_properties()
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Device {self.name} ({self.ip_address})>"
|
||||
|
||||
|
||||
class FirewallRule(db.Model):
|
||||
"""Firewall rule model for documenting network security policies."""
|
||||
__tablename__ = "firewall_rules"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
source = db.Column(db.String(128), nullable=False)
|
||||
destination = db.Column(db.String(128), nullable=False)
|
||||
protocol = db.Column(db.String(16)) # tcp, udp, icmp, any
|
||||
port_range = db.Column(db.String(64)) # e.g., "80,443" or "1024-2048"
|
||||
action = db.Column(db.String(16), nullable=False) # allow, deny
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# Parent network
|
||||
network_id = db.Column(db.Integer, db.ForeignKey("networks.id"), nullable=False)
|
||||
network = db.relationship("Network", back_populates="firewall_rules")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert firewall rule to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"source": self.source,
|
||||
"destination": self.destination,
|
||||
"protocol": self.protocol,
|
||||
"port_range": self.port_range,
|
||||
"action": self.action,
|
||||
"description": self.description
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<FirewallRule {self.name}: {self.source} to {self.destination}>"
|
106
app/models/user.py
Normal file
106
app/models/user.py
Normal file
|
@ -0,0 +1,106 @@
|
|||
"""
|
||||
User model for authentication and authorization.
|
||||
"""
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
import uuid
|
||||
|
||||
from flask_login import UserMixin
|
||||
|
||||
from app.extensions import db, login_manager
|
||||
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
"""User model for authentication and authorization."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(128), nullable=False)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
last_login = db.Column(db.DateTime)
|
||||
|
||||
# For account lockout/security
|
||||
failed_login_count = db.Column(db.Integer, default=0)
|
||||
locked_until = db.Column(db.DateTime)
|
||||
|
||||
# Password reset
|
||||
reset_token = db.Column(db.String(64), unique=True, index=True)
|
||||
reset_token_expires_at = db.Column(db.DateTime)
|
||||
|
||||
# Relationships
|
||||
networks = db.relationship("Network", back_populates="owner", cascade="all, delete-orphan")
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
"""Hash and set the user's password."""
|
||||
# Generate a salt and hash the password
|
||||
self.password_hash = bcrypt.hashpw(
|
||||
password.encode("utf-8"), bcrypt.gensalt()
|
||||
).decode("utf-8")
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""Verify the provided password against the stored hash."""
|
||||
return bcrypt.checkpw(
|
||||
password.encode("utf-8"), self.password_hash.encode("utf-8")
|
||||
)
|
||||
|
||||
def generate_reset_token(self, expires_in: int = 3600) -> str:
|
||||
"""Generate a password reset token that expires after the specified time."""
|
||||
self.reset_token = str(uuid.uuid4())
|
||||
self.reset_token_expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
|
||||
db.session.commit()
|
||||
return self.reset_token
|
||||
|
||||
def clear_reset_token(self) -> None:
|
||||
"""Clear the reset token after it's been used."""
|
||||
self.reset_token = None
|
||||
self.reset_token_expires_at = None
|
||||
db.session.commit()
|
||||
|
||||
def is_reset_token_valid(self, token: str) -> bool:
|
||||
"""Check if the provided reset token is valid and not expired."""
|
||||
return (
|
||||
self.reset_token == token
|
||||
and self.reset_token_expires_at is not None
|
||||
and self.reset_token_expires_at > datetime.utcnow()
|
||||
)
|
||||
|
||||
def record_login(self, success: bool) -> None:
|
||||
"""Record a login attempt."""
|
||||
if success:
|
||||
self.last_login = datetime.utcnow()
|
||||
self.failed_login_count = 0
|
||||
self.locked_until = None
|
||||
else:
|
||||
self.failed_login_count += 1
|
||||
if self.failed_login_count >= 5: # Threshold from config
|
||||
self.locked_until = datetime.utcnow() + timedelta(minutes=30) # Duration from config
|
||||
|
||||
db.session.commit()
|
||||
|
||||
def is_account_locked(self) -> bool:
|
||||
"""Check if the account is locked due to too many failed login attempts."""
|
||||
if self.locked_until is None:
|
||||
return False
|
||||
|
||||
if datetime.utcnow() > self.locked_until:
|
||||
self.failed_login_count = 0
|
||||
self.locked_until = None
|
||||
db.session.commit()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username}>"
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id: str) -> Optional[User]:
|
||||
"""Load a user from the database by ID."""
|
||||
return User.query.get(int(user_id))
|
79
app/templates/auth/login.html
Normal file
79
app/templates/auth/login.html
Normal file
|
@ -0,0 +1,79 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - NetViz{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Or
|
||||
<a href="{{ url_for('auth.register') }}" class="font-medium text-primary hover:text-blue-500">
|
||||
register a new account
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6" method="POST" action="{{ url_for('auth.login') }}">
|
||||
{{ form.csrf_token }}
|
||||
<input type="hidden" name="remember" value="true">
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="username" class="sr-only">Username</label>
|
||||
{{ form.username(class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300
|
||||
dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white
|
||||
dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10
|
||||
sm:text-sm", placeholder="Username") }}
|
||||
{% if form.username.errors %}
|
||||
<p class="text-red-500 text-xs mt-1">{{ form.username.errors[0] }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="sr-only">Password</label>
|
||||
{{ form.password(class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300
|
||||
dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white
|
||||
dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10
|
||||
sm:text-sm", placeholder="Password") }}
|
||||
{% if form.password.errors %}
|
||||
<p class="text-red-500 text-xs mt-1">{{ form.password.errors[0] }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
{{ form.remember_me(class="h-4 w-4 text-primary focus:ring-primary border-gray-300 dark:border-gray-700
|
||||
rounded") }}
|
||||
<label for="remember_me" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<a href="{{ url_for('auth.reset_password_request') }}" class="font-medium text-primary hover:text-blue-500">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||
<!-- Lock icon -->
|
||||
<svg class="h-5 w-5 text-blue-500 group-hover:text-blue-400" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
166
app/templates/base.html
Normal file
166
app/templates/base.html
Normal file
|
@ -0,0 +1,166 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}NetViz{% endblock %}</title>
|
||||
<meta name="description" content="Secure Network Documentation & Visualization Tool">
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#3B82F6', /* blue-500 */
|
||||
secondary: '#10B981', /* emerald-500 */
|
||||
accent: '#8B5CF6', /* violet-500 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dark mode preference
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<style>
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body class="h-full bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100">
|
||||
<header class="bg-white dark:bg-gray-800 shadow-md">
|
||||
<nav class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<a href="{{ url_for('core.index') }}" class="text-2xl font-bold text-primary">
|
||||
NetViz
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('core.dashboard') }}"
|
||||
class="border-transparent text-gray-500 dark:text-gray-300 hover:text-gray-700 hover:dark:text-white hover:border-primary inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
Dashboard
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="#"
|
||||
class="border-transparent text-gray-500 dark:text-gray-300 hover:text-gray-700 hover:dark:text-white hover:border-primary inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
Documentation
|
||||
</a>
|
||||
<a href="#"
|
||||
class="border-transparent text-gray-500 dark:text-gray-300 hover:text-gray-700 hover:dark:text-white hover:border-primary inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||||
About
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:items-center">
|
||||
<!-- Dark mode toggle -->
|
||||
<button type="button" id="dark-mode-toggle"
|
||||
class="p-1 rounded-full text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 block dark:hidden" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 hidden dark:block" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User menu -->
|
||||
<div class="ml-3 relative">
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">{{ current_user.username }}</span>
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-primary hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<a href="{{ url_for('auth.login') }}"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-primary hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
Login
|
||||
</a>
|
||||
<a href="{{ url_for('auth.register') }}"
|
||||
class="ml-2 inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 text-sm leading-4 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
Register
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<!-- Flash messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-4 p-4 rounded-md
|
||||
{% if category == 'success' %}bg-green-50 dark:bg-green-900 text-green-800 dark:text-green-100
|
||||
{% elif category == 'danger' %}bg-red-50 dark:bg-red-900 text-red-800 dark:text-red-100
|
||||
{% elif category == 'warning' %}bg-yellow-50 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-100
|
||||
{% else %}bg-blue-50 dark:bg-blue-900 text-blue-800 dark:text-blue-100{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Main content -->
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="bg-white dark:bg-gray-800 py-6 mt-10">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<p class="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
© {{ current_year }} NetViz. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Dark mode toggle script -->
|
||||
<script>
|
||||
document.getElementById('dark-mode-toggle').addEventListener('click', function () {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
localStorage.theme = 'light'
|
||||
} else {
|
||||
document.documentElement.classList.add('dark')
|
||||
localStorage.theme = 'dark'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
0
app/templates/core/dashboard.html
Normal file
0
app/templates/core/dashboard.html
Normal file
194
app/templates/core/network_view.html
Normal file
194
app/templates/core/network_view.html
Normal file
|
@ -0,0 +1,194 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network.name }} - NetViz{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<div class="flex items-center justify-between flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ network.name }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Created: {{ network.created_at.strftime('%Y-%m-%d %H:%M') }} |
|
||||
Last updated: {{ network.updated_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex mt-2 sm:mt-0 space-x-2">
|
||||
<a href="{{ url_for('core.edit_network', network_id=network.id) }}"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
Edit
|
||||
</a>
|
||||
<form action="{{ url_for('api.export_network', network_id=network.id) }}" method="GET">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
Export JSON
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick="deleteNetwork()"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">
|
||||
Network Details
|
||||
</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ network.description or "No description provided." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Network Visualization -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Network Visualization</h3>
|
||||
<div class="mt-4 bg-gray-100 dark:bg-gray-900 p-2 rounded-md overflow-auto">
|
||||
<img src="{{ diagram }}" alt="Network Diagram" class="mx-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subnets -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Subnets</h3>
|
||||
<a href="{{ url_for('core.create_subnet', network_id=network.id) }}"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
Add Subnet
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if subnets %}
|
||||
<div class="mt-4 flex flex-col">
|
||||
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div class="shadow overflow-hidden border-b border-gray-200 dark:border-gray-700 sm:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
CIDR
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
VLAN
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th scope="col" class="relative px-6 py-3">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for subnet in subnets %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ subnet.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
|
||||
{{ subnet.cidr }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
|
||||
{{ subnet.vlan or "-" }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-300">
|
||||
{{ subnet.description or "-" }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="#" class="text-primary hover:text-blue-700">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-4 bg-gray-50 dark:bg-gray-700 p-4 rounded-md text-center">
|
||||
<p class="text-gray-600 dark:text-gray-300">No subnets defined yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Similar sections for devices and firewall rules omitted for brevity -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation modal - hidden by default -->
|
||||
<div id="delete-modal" class="fixed z-10 inset-0 overflow-y-auto hidden" aria-labelledby="modal-title" role="dialog"
|
||||
aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<div
|
||||
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div
|
||||
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg class="h-6 w-6 text-red-600 dark:text-red-300" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
|
||||
Delete Network
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Are you sure you want to delete the network "{{ network.name }}"? This action cannot be undone, and all
|
||||
associated data will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<form action="{{ url_for('core.delete_network', network_id=network.id) }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick="closeDeleteModal()"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function deleteNetwork() {
|
||||
document.getElementById('delete-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('delete-modal').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
75
app/templates/emails/reset_password.html
Normal file
75
app/templates/emails/reset_password.html
Normal file
|
@ -0,0 +1,75 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #3B82F6;
|
||||
color: white;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0 0 5px 5px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #3B82F6;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>NetViz Password Reset</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hello {{ user.username }},</p>
|
||||
|
||||
<p>We received a request to reset your password for your NetViz account. If you made this request, please click the
|
||||
button below to reset your password:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ reset_url }}" class="button">Reset Password</a>
|
||||
</p>
|
||||
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
|
||||
<p>If you did not request a password reset, please ignore this email or contact support if you have concerns about
|
||||
your account security.</p>
|
||||
|
||||
<p>Best regards,<br>The NetViz Team</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This is an automated message, please do not reply to this email.</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
14
app/templates/emails/reset_password.txt
Normal file
14
app/templates/emails/reset_password.txt
Normal file
|
@ -0,0 +1,14 @@
|
|||
Hello {{ user.username }},
|
||||
|
||||
We received a request to reset your password for your NetViz account. If you made this request, please click on the link below to reset your password:
|
||||
|
||||
{{ reset_url }}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you did not request a password reset, please ignore this email or contact support if you have concerns about your account security.
|
||||
|
||||
Best regards,
|
||||
The NetViz Team
|
||||
|
||||
This is an automated message, please do not reply to this email.
|
23
app/templates/errors/403.html
Normal file
23
app/templates/errors/403.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Forbidden - NetViz{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8 text-center">
|
||||
<h1 class="text-9xl font-extrabold text-amber-500">403</h1>
|
||||
<h2 class="mt-6 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
You don't have permission to access this resource.
|
||||
</p>
|
||||
<div class="mt-5">
|
||||
<a href="{{ url_for('core.index') }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
Return to Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
23
app/templates/errors/404.html
Normal file
23
app/templates/errors/404.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Page Not Found - NetViz{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8 text-center">
|
||||
<h1 class="text-9xl font-extrabold text-primary">404</h1>
|
||||
<h2 class="mt-6 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div class="mt-5">
|
||||
<a href="{{ url_for('core.index') }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
Return to Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
0
app/templates/errors/500.html
Normal file
0
app/templates/errors/500.html
Normal file
49
app/utils/commands.py
Normal file
49
app/utils/commands.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
"""
|
||||
CLI commands for the NetViz application.
|
||||
"""
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def register_commands(app):
|
||||
"""
|
||||
Register CLI commands with the Flask application.
|
||||
|
||||
Args:
|
||||
app: The Flask application
|
||||
"""
|
||||
@app.cli.command("create-admin")
|
||||
@click.argument("username")
|
||||
@click.argument("email")
|
||||
@click.password_option()
|
||||
@with_appcontext
|
||||
def create_admin(username, email, password):
|
||||
"""Create an admin user."""
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if user:
|
||||
click.echo(f"User {username} already exists.")
|
||||
return
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
is_admin=True
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
click.echo(f"Admin user {username} created successfully.")
|
||||
|
||||
@app.cli.command("init-db")
|
||||
@with_appcontext
|
||||
def init_db():
|
||||
"""Initialize the database."""
|
||||
db.create_all()
|
||||
click.echo("Database initialized.")
|
56
app/utils/email.py
Normal file
56
app/utils/email.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
"""
|
||||
Email utilities for the NetViz application.
|
||||
"""
|
||||
from flask import current_app, render_template
|
||||
from flask_mail import Message
|
||||
from threading import Thread
|
||||
|
||||
from app.extensions import mail
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def send_async_email(app, msg):
|
||||
"""Send email asynchronously."""
|
||||
with app.app_context():
|
||||
mail.send(msg)
|
||||
|
||||
|
||||
def send_email(subject, sender, recipients, text_body, html_body):
|
||||
"""
|
||||
Send an email.
|
||||
|
||||
Args:
|
||||
subject: Email subject
|
||||
sender: Sender email address
|
||||
recipients: List of recipient email addresses
|
||||
text_body: Plain text email body
|
||||
html_body: HTML email body
|
||||
"""
|
||||
msg = Message(subject, sender=sender, recipients=recipients)
|
||||
msg.body = text_body
|
||||
msg.html = html_body
|
||||
|
||||
# Send email asynchronously to not block the request
|
||||
Thread(
|
||||
target=send_async_email,
|
||||
args=(current_app._get_current_object(), msg)
|
||||
).start()
|
||||
|
||||
|
||||
def send_password_reset_email(user: User):
|
||||
"""
|
||||
Send a password reset email to a user.
|
||||
|
||||
Args:
|
||||
user: The user requesting password reset
|
||||
"""
|
||||
token = user.generate_reset_token()
|
||||
reset_url = f"{current_app.config['SERVER_NAME']}/auth/reset-password/{token}"
|
||||
|
||||
send_email(
|
||||
subject="[NetViz] Reset Your Password",
|
||||
sender=current_app.config['MAIL_DEFAULT_SENDER'],
|
||||
recipients=[user.email],
|
||||
text_body=render_template("email/reset_password.txt", user=user, reset_url=reset_url),
|
||||
html_body=render_template("email/reset_password.html", user=user, reset_url=reset_url)
|
||||
)
|
32
app/utils/error_handlers.py
Normal file
32
app/utils/error_handlers.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
"""
|
||||
Error handlers for the NetViz application.
|
||||
"""
|
||||
import traceback
|
||||
from flask import render_template, current_app
|
||||
|
||||
|
||||
def register_error_handlers(app):
|
||||
"""
|
||||
Register error handlers for the application.
|
||||
|
||||
Args:
|
||||
app: The Flask application
|
||||
"""
|
||||
@app.errorhandler(403)
|
||||
def forbidden_error(error):
|
||||
return render_template('errors/403.html'), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error):
|
||||
return render_template('errors/404.html'), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
# Log the error
|
||||
current_app.logger.error(f"Server Error: {error}")
|
||||
current_app.logger.error(traceback.format_exc())
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
@app.errorhandler(429)
|
||||
def ratelimit_error(error):
|
||||
return render_template('errors/429.html'), 429
|
71
app/utils/security.py
Normal file
71
app/utils/security.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
"""
|
||||
Security utilities for the NetViz application.
|
||||
"""
|
||||
from typing import Dict, Any
|
||||
import secrets
|
||||
import string
|
||||
|
||||
|
||||
def get_secure_headers() -> Dict[str, Any]:
|
||||
"""
|
||||
Get secure headers configuration for Flask-Talisman.
|
||||
|
||||
Returns:
|
||||
Dict with security header configuration
|
||||
"""
|
||||
return {
|
||||
'content_security_policy': {
|
||||
'default-src': "'self'",
|
||||
'img-src': "'self' data:",
|
||||
'style-src': "'self' 'unsafe-inline'", # Needed for Tailwind
|
||||
'script-src': "'self' 'unsafe-inline'", # Needed for HTMX
|
||||
'font-src': "'self'"
|
||||
},
|
||||
'force_https': False, # Set to True in production
|
||||
'strict_transport_security': True,
|
||||
'strict_transport_security_max_age': 31536000,
|
||||
'strict_transport_security_include_subdomains': True,
|
||||
'referrer_policy': 'strict-origin-when-cross-origin',
|
||||
'frame_options': 'DENY',
|
||||
'session_cookie_secure': False, # Set to True in production
|
||||
'session_cookie_http_only': True
|
||||
}
|
||||
|
||||
|
||||
def generate_password() -> str:
|
||||
"""
|
||||
Generate a secure random password.
|
||||
|
||||
Returns:
|
||||
A secure random password string
|
||||
"""
|
||||
alphabet = string.ascii_letters + string.digits + string.punctuation
|
||||
password = ''.join(secrets.choice(alphabet) for _ in range(16))
|
||||
return password
|
||||
|
||||
|
||||
def sanitize_input(input_string: str) -> str:
|
||||
"""
|
||||
Sanitize user input to prevent XSS attacks.
|
||||
|
||||
Args:
|
||||
input_string: The input string to sanitize
|
||||
|
||||
Returns:
|
||||
Sanitized string
|
||||
"""
|
||||
# Replace problematic characters with HTML entities
|
||||
replacements = {
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'\\': '\',
|
||||
'\n': '<br>',
|
||||
}
|
||||
|
||||
for char, replacement in replacements.items():
|
||||
input_string = input_string.replace(char, replacement)
|
||||
|
||||
return input_string
|
90
app/utils/visualization.py
Normal file
90
app/utils/visualization.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
"""
|
||||
Network visualization utilities for the NetViz application.
|
||||
"""
|
||||
import networkx as nx
|
||||
import matplotlib.pyplot as plt
|
||||
import io
|
||||
import base64
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.models.network import Network
|
||||
|
||||
|
||||
def generate_network_diagram(network: Network) -> str:
|
||||
"""
|
||||
Generate a network diagram for visualization.
|
||||
|
||||
Args:
|
||||
network: The Network object to visualize
|
||||
|
||||
Returns:
|
||||
Base64 encoded PNG image of the network diagram
|
||||
"""
|
||||
# Create a directed graph
|
||||
G = nx.DiGraph()
|
||||
|
||||
# Add nodes for subnets
|
||||
for subnet in network.subnets:
|
||||
G.add_node(f"subnet-{subnet.id}",
|
||||
label=f"{subnet.name}\n{subnet.cidr}",
|
||||
type="subnet")
|
||||
|
||||
# Add nodes for devices
|
||||
for device in network.devices:
|
||||
G.add_node(f"device-{device.id}",
|
||||
label=f"{device.name}\n{device.ip_address or ''}",
|
||||
type="device")
|
||||
|
||||
# Connect devices to their subnets
|
||||
if device.subnet_id:
|
||||
G.add_edge(f"device-{device.id}", f"subnet-{device.subnet_id}")
|
||||
|
||||
# Add firewall rules as edges
|
||||
for rule in network.firewall_rules:
|
||||
# For simplicity, we're assuming source and destination are device IPs
|
||||
# In a real implementation, you'd need to resolve these to actual devices
|
||||
source_devices = [d for d in network.devices if d.ip_address == rule.source]
|
||||
dest_devices = [d for d in network.devices if d.ip_address == rule.destination]
|
||||
|
||||
for src in source_devices:
|
||||
for dst in dest_devices:
|
||||
G.add_edge(f"device-{src.id}", f"device-{dst.id}",
|
||||
label=f"{rule.protocol}/{rule.port_range}\n{rule.action}",
|
||||
color="green" if rule.action == "allow" else "red")
|
||||
|
||||
# Set node colors based on type
|
||||
node_colors = []
|
||||
for node in G.nodes():
|
||||
node_type = G.nodes[node].get("type")
|
||||
if node_type == "subnet":
|
||||
node_colors.append("skyblue")
|
||||
elif node_type == "device":
|
||||
node_colors.append("lightgreen")
|
||||
else:
|
||||
node_colors.append("lightgray")
|
||||
|
||||
# Create the plot
|
||||
plt.figure(figsize=(12, 8))
|
||||
pos = nx.spring_layout(G)
|
||||
|
||||
# Draw nodes
|
||||
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=500)
|
||||
|
||||
# Draw edges
|
||||
edge_colors = [G.edges[edge].get("color", "black") for edge in G.edges()]
|
||||
nx.draw_networkx_edges(G, pos, edge_color=edge_colors, arrowstyle='->', arrowsize=15)
|
||||
|
||||
# Draw labels
|
||||
node_labels = {node: G.nodes[node].get("label", node) for node in G.nodes()}
|
||||
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=10)
|
||||
|
||||
# Save the plot to a bytes buffer
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format='png', dpi=100, bbox_inches='tight')
|
||||
plt.close()
|
||||
|
||||
# Encode the image as base64 for embedding in HTML
|
||||
buf.seek(0)
|
||||
image_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
|
||||
return f"data:image/png;base64,{image_base64}"
|
Loading…
Add table
Add a link
Reference in a new issue