This commit is contained in:
pika 2025-03-25 23:41:13 +01:00
commit 66f9ce3614
33 changed files with 2271 additions and 0 deletions

31
.env.example Normal file
View file

@ -0,0 +1,31 @@
# Flask Configuration
FLASK_APP=app
FLASK_ENV=development
SECRET_KEY=change_this_to_a_secure_random_string
SECURITY_PASSWORD_SALT=change_this_too
# Database Configuration
DATABASE_URL=sqlite:///netviz.db
# For PostgreSQL with Docker:
# DATABASE_URL=postgresql://netviz:netviz_password@db:5432/netviz
# Rate Limiting
RATELIMIT_STORAGE_URL=memory://
RATELIMIT_DEFAULT=200/day;50/hour;10/minute
# Mail Configuration (for password reset)
MAIL_SERVER=smtp.example.com
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_USERNAME=your_username
MAIL_PASSWORD=your_password
MAIL_DEFAULT_SENDER=noreply@netviz.example.com
# Session Configuration
SESSION_TYPE=filesystem
# For Redis with Docker:
# SESSION_TYPE=redis
# SESSION_REDIS=redis://redis:6379/0
# Security Headers
SECURE_HEADERS_ENABLED=True

33
Dockerfile Normal file
View file

@ -0,0 +1,33 @@
# Use Python 3.9 slim image
FROM python:3.9-slim
# Set working directory
WORKDIR /app
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV FLASK_APP app
ENV FLASK_ENV production
# Install 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 packages
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create a non-root user to run the application
RUN adduser --disabled-password --gecos "" netviz
RUN chown -R netviz:netviz /app
USER netviz
# Run gunicorn with 4 workers
CMD gunicorn --bind 0.0.0.0:5000 --workers 4 "app:create_app()"

173
Readme.md Normal file
View file

@ -0,0 +1,173 @@
# NetViz: Secure Network Documentation & Visualization Tool
A security-focused web application for documenting, visualizing, and managing network topologies with minimal JavaScript and a modern UI.
## Features
- **Security-First Architecture**
- OWASP Top 10 protections
- Secure session management
- CSRF protection
- Bcrypt password hashing
- Rate limiting for auth endpoints
- SQL injection protection
- **Modern, Minimal UI**
- HTMX for dynamic functionality
- Tailwind CSS with dark/light mode
- Responsive, mobile-first design
- Accessible components
- **Core Functionality**
- User authentication system
- Network topology management
- Interactive visualization
- Firewall rule documentation
- Data import/export (JSON, CSV)
- **Deployment Ready**
- Docker and docker-compose configuration
- PostgreSQL integration
- Comprehensive logging
- Health check endpoints
## Project Structure
```
/netviz/
├── app/
│ ├── auth/ # Authentication blueprints and views
│ ├── core/ # Core application functionality
│ ├── api/ # API endpoints
│ ├── models/ # SQLAlchemy models
│ ├── templates/ # Jinja2 templates
│ ├── static/ # Static assets
│ ├── utils/ # Utility functions
│ ├── __init__.py # Application factory
│ ├── extensions.py # Flask extensions
│ └── config.py # Configuration classes
├── tests/ # Pytest test suite
├── migrations/ # Alembic database migrations
├── docker/ # Docker-related files
├── .env.sample # Sample environment variables
├── requirements.txt # Python dependencies
├── requirements-dev.txt # Development dependencies
├── Dockerfile # Production Dockerfile
├── docker-compose.yml # Docker Compose configuration
└── README.md # Project documentation
```
## Getting Started
### Prerequisites
- Python 3.9+
- Docker and Docker Compose
- Git
### Local Development Setup
1. Clone the repository
```bash
git clone https://github.com/yourusername/netviz.git
cd netviz
```
2. Create and activate a virtual environment
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install dependencies
```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt
```
4. Set up environment variables
```bash
cp .env.sample .env
# Edit .env with your configuration
```
5. Initialize the database
```bash
flask db upgrade
```
6. Run the development server
```bash
flask run --debug
```
### Docker Deployment
1. Build and start the containers
```bash
docker-compose up -d
```
2. Access the application at http://localhost:5000
## Security Features
- **Authentication**
- Bcrypt password hashing
- Session-based authentication with secure cookies
- Password reset functionality
- Account lockout after failed attempts
- **Protection Mechanisms**
- CSRF tokens for all forms
- Content Security Policy (CSP)
- XSS protection
- SQL injection prevention
- Rate limiting
- Input validation and sanitization
## Development Guidelines
- Follow PEP 8 style guide
- Include type hints for all functions
- Write docstrings for all modules, classes, and functions
- Maintain test coverage above 90%
- Use atomic commits with descriptive messages
## Testing
Run the test suite with pytest:
```bash
pytest
```
Generate a coverage report:
```bash
pytest --cov=app --cov-report=html
```
## Color Scheme
The application uses a professional color palette that works well in both light and dark modes:
- **Primary**: #3B82F6 (blue-500)
- **Secondary**: #10B981 (emerald-500)
- **Accent**: #8B5CF6 (violet-500)
- **Dark Background**: #111827 (gray-900)
- **Light Background**: #F9FAFB (gray-50)
- **Dark Text**: #1F2937 (gray-800)
- **Light Text**: #F9FAFB (gray-50)
## License
[MIT License](LICENSE)
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Run tests
5. Submit a pull request

62
app/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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))

View 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
View 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">
&copy; {{ 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>

View file

View 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">&#8203;</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 %}

View 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>

View 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.

View 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 %}

View 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 %}

View file

49
app/utils/commands.py Normal file
View 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
View 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)
)

View 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
View 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 = {
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
'\\': '&#x5C;',
'\n': '<br>',
}
for char, replacement in replacements.items():
input_string = input_string.replace(char, replacement)
return input_string

View 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}"

60
docker-compose.yml Normal file
View file

@ -0,0 +1,60 @@
version: '3.8'
services:
web:
build: .
restart: always
ports:
- "5000:5000"
depends_on:
- db
- redis
environment:
- FLASK_APP=app
- FLASK_ENV=production
- DATABASE_URL=postgresql://netviz:netviz_password@db:5432/netviz
- SECRET_KEY=${SECRET_KEY:-change_this_to_a_secure_random_string}
- SECURITY_PASSWORD_SALT=${SECURITY_PASSWORD_SALT:-change_this_too}
- SESSION_TYPE=redis
- SESSION_REDIS=redis://redis:6379/0
- RATELIMIT_STORAGE_URL=redis://redis:6379/1
- SECURE_HEADERS_ENABLED=True
volumes:
- ./app:/app/app
- ./migrations:/app/migrations
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:5000/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:14-alpine
restart: always
environment:
- POSTGRES_USER=netviz
- POSTGRES_PASSWORD=netviz_password
- POSTGRES_DB=netviz
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U netviz" ]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:6-alpine
restart: always
volumes:
- redis_data:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:

7
requirements-dev.txt Normal file
View file

@ -0,0 +1,7 @@
pytest==7.4.3
pytest-cov==4.1.0
pytest-flask==1.3.0
black==23.10.1
flake8==6.1.0
mypy==1.6.1
bandit==1.7.5

37
requirements.txt Normal file
View file

@ -0,0 +1,37 @@
# Flask and Extensions
Flask==2.3.3
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.5
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-Limiter==3.5.0
Flask-Mail==0.9.1
Flask-Session==0.5.0
Flask-Talisman==1.1.0
flask-seasurf==1.1.1
# Database
SQLAlchemy==2.0.23
psycopg2-binary==2.9.9
alembic==1.12.1
# Security
bcrypt==4.0.1
pyjwt==2.8.0
python-dotenv==1.0.0
secure==0.3.0
# UI
Jinja2==3.1.2
WTForms==3.1.1
email-validator==2.1.0
# Utils
gunicorn==21.2.0
python-dateutil==2.8.2
PyYAML==6.0.1
requests==2.31.0
# Visualization
networkx==3.2.1
matplotlib==3.8.0