batman
This commit is contained in:
commit
66f9ce3614
33 changed files with 2271 additions and 0 deletions
31
.env.example
Normal file
31
.env.example
Normal 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
33
Dockerfile
Normal 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
173
Readme.md
Normal 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
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}"
|
60
docker-compose.yml
Normal file
60
docker-compose.yml
Normal 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
7
requirements-dev.txt
Normal 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
37
requirements.txt
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue