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