feat: initial commit

This commit is contained in:
2025-11-01 16:04:10 +00:00
commit 07fc78592b
27 changed files with 2577 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
FROM mcr.microsoft.com/devcontainers/python:3.13
WORKDIR /workspace
CMD ["sleep", "infinity"]
+15
View File
@@ -0,0 +1,15 @@
{
"name": "Flask Dev Container",
"build": {
"dockerfile": "Dockerfile"
},
"settings": {},
"customizations": {
"vscode": {
"extensions": ["ms-python.python"]
}
},
"postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss",
"forwardPorts": [5000],
"remoteUser": "vscode"
}
+2
View File
@@ -0,0 +1,2 @@
.devcontainer
deployment.yml
+72
View File
@@ -0,0 +1,72 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
steps:
- uses: google-github-actions/release-please-action@v4
id: release
with:
manifest-file: .release-please-manifest.json
config-file: .release-please-config.json
- name: Checkout
if: ${{ steps.release.outputs.release_created }}
uses: actions/checkout@v4
- name: Set up Docker Buildx
if: ${{ steps.release.outputs.release_created }}
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: ${{ steps.release.outputs.release_created }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Read version
if: ${{ steps.release.outputs.release_created }}
id: version
run: |
VERSION=$(cat VERSION)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Build and push Docker image
if: ${{ steps.release.outputs.release_created }}
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/jdb-net/opnsense-sftp:${{ env.VERSION }}
ghcr.io/jdb-net/opnsense-sftp:latest
build-args: |
VERSION=${{ env.VERSION }}
deploy:
name: Deploy to Kubernetes
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: [ k3s-lan-01 ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Apply manifests
run: |
sudo kubectl replace -f deployment.yml --grace-period=60 --force
+44
View File
@@ -0,0 +1,44 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
# Environment variables
.env
# Keys and sensitive data
keys/
*.key
*.pem
# Backups
backups/
# Tailwind output
static/css/output.css
tailwindcss
# Database
*.db
*.sqlite
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
+14
View File
@@ -0,0 +1,14 @@
{
"packages": {
".": {
"release-type": "python",
"version-file": "VERSION",
"extra-files": [
"Dockerfile",
"deployment.yml"
]
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
+4
View File
@@ -0,0 +1,4 @@
{
".": "1.0.0"
}
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.13-slim
WORKDIR /app
# Build argument for version
ARG VERSION=dev
ENV APP_VERSION=${VERSION}
COPY . /app
RUN pip install -r requirements.txt \
&& apt-get update \
&& apt-get install curl -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
&& chmod +x tailwindcss-linux-x64 \
&& mv tailwindcss-linux-x64 tailwindcss \
&& ./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.js" --minify \
&& rm tailwindcss
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
+167
View File
@@ -0,0 +1,167 @@
<div align="center">
<img src="https://assets.s3.jdbnet.co.uk/opnsense.png" alt="OPNsense" width="200" />
# OPNsense SFTP Backup Manager
</div>
A Flask-based web application for managing OPNsense configuration backups via SFTP. This application provides an SFTP server with SSH key authentication, automatically generates SSH keys for each OPNsense instance, and offers a web-based interface for viewing and downloading backups.
## Features
- **SFTP Server**: Built-in SFTP server supporting SSH key authentication
- **SSH Key Generation**: Automatically generates SSH key pairs for each OPNsense instance
- **Multi-Instance Support**: Manage backups from multiple OPNsense instances
- **Web Interface**: Modern web GUI built with Tailwind CSS
- **MariaDB Integration**: Stores instance information, SSH keys, and backup metadata
- **Secure Authentication**: Web interface with session-based authentication
## Quick Start with Docker
### Docker Run
```bash
docker run -d \
--name opnsense-sftp \
-p 5000:5000 \
-p 2222:2222 \
-e DB_HOST=10.10.2.27 \
-e DB_PORT=3306 \
-e DB_NAME=opnsense-sftp \
-e DB_USER=jamie \
-e DB_PASSWORD=your_password \
-e SECRET_KEY=your_secret_key \
-e ADMIN_PASSWORD=your_admin_password \
-e SFTP_PORT=2222 \
-e SFTP_PUBLIC_HOST=opnsense-sftp.jdb143.uk \
-e SFTP_PUBLIC_PORT=30222 \
-v /path/to/keys:/app/keys \
-v /path/to/backups:/app/backups \
ghcr.io/jdb-net/opnsense-sftp:latest
```
### Docker Compose
```yaml
version: '3.8'
services:
opnsense-sftp:
image: ghcr.io/jdb-net/opnsense-sftp:latest
container_name: opnsense-sftp
restart: unless-stopped
ports:
- "5000:5000" # Web interface
- "2222:2222" # SFTP server
environment:
- DB_HOST=10.10.2.27
- DB_PORT=3306
- DB_NAME=opnsense-sftp
- DB_USER=jamie
- DB_PASSWORD=your_password
- SECRET_KEY=your_secret_key
- ADMIN_PASSWORD=your_admin_password
- SFTP_PORT=2222
- SFTP_PUBLIC_HOST=opnsense-sftp.jdb143.uk
- SFTP_PUBLIC_PORT=30222
volumes:
- ./keys:/app/keys # SSH private keys
- ./backups:/app/backups # Backup files
```
## Configuration
### Environment Variables
- `DB_HOST`: MariaDB host (default: localhost)
- `DB_PORT`: MariaDB port (default: 3306)
- `DB_NAME`: Database name (default: opnsense_backup)
- `DB_USER`: Database user
- `DB_PASSWORD`: Database password
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
- `ADMIN_PASSWORD`: Default admin password (default: admin)
- `SFTP_HOST`: SFTP server bind address (default: 0.0.0.0)
- `SFTP_PORT`: SFTP server port (default: 2222)
- `SFTP_PUBLIC_HOST`: Public hostname/IP for OPNsense configuration (e.g., `opnsense-sftp.jdb143.uk` or `10.10.2.7`)
- `SFTP_PUBLIC_PORT`: Public port exposed (e.g., NodePort `30222` for Kubernetes)
### Volumes
- `/app/keys`: Directory containing SSH private keys (recommended to mount as volume)
- `/app/backups`: Directory containing backup files (recommended to mount as volume)
## Usage
### Setting up an OPNsense Instance
1. Access the web interface at `http://your-server:5000`
2. Log in with the default credentials (change immediately after first login!)
- Default username: `admin`
- Default password: Set via `ADMIN_PASSWORD` environment variable
3. Navigate to "Instances" and click "Add Instance"
4. Fill in:
- **Name**: Friendly name for the instance (e.g., "Main Router")
- **Identifier**: Unique identifier (e.g., "lan") - used as SFTP username
- **Description**: Optional description
5. Click "Create Instance"
### Configuring OPNsense
1. In OPNsense, navigate to **System → Configuration → Backups**
2. Add a new backup target:
- **Type**: SFTP
- **Target location (URI)**: Copy from the instance detail page (e.g., `sftp://lan@opnsense-sftp.jdb143.uk:30222//lan`)
- **SSH Private Key**: Copy or download the private key from the instance detail page
3. Configure your backup schedule and save
4. Test the backup connection to verify authentication works
### Accessing Backups
- View all backups in the "Backups" section
- Download backups by clicking the "Download" link
- View instance-specific backups from the instance detail page
## Kubernetes Deployment
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
**Note**: For Kubernetes deployments:
- Use NFS mounts for `keys` and `backups` volumes
- Configure `SFTP_PUBLIC_PORT` to match your NodePort (e.g., `30222`)
- Use `SFTP_PUBLIC_HOST` for the public hostname or IP address
## Security Notes
- **CHANGE THE DEFAULT ADMIN PASSWORD** immediately after first login
- **CHANGE THE SECRET_KEY** in production - use a strong random string
- The SFTP server uses SSH key authentication only (no passwords)
- SSH private keys are stored in the `keys/` directory with restricted permissions (600)
- Backups are stored per-instance to prevent cross-access
- Keep your private keys secure - anyone with access can authenticate as that instance
## Troubleshooting
### Database Connection Issues
- Ensure MariaDB is running and accessible from the container
- Check database credentials in environment variables
- Verify database and user exist with proper permissions
- Check network connectivity between container and database
### SFTP Connection Issues
- Check that the SFTP server is running (should show in logs)
- Verify firewall allows connections on SFTP port (default: 2222)
- Ensure OPNsense can reach the server on the configured port
- Check SSH key format - OPNsense requires the **private key**, not the public key
- Verify the SFTP URI format matches what's displayed in the web interface
### Backup Not Appearing
- Check SFTP server logs for connection attempts
- Verify instance identifier matches SFTP username
- Ensure private key in OPNsense matches the generated key
- Check file permissions on the backups directory
## License
This project is provided as-is for managing OPNsense backups.
+2
View File
@@ -0,0 +1,2 @@
1.0.0
+347
View File
@@ -0,0 +1,347 @@
"""
Flask application for OPNsense backup management via SFTP.
"""
import os
from flask import Flask, render_template, request, redirect, url_for, session, send_file, jsonify, flash
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
from functools import wraps
from pathlib import Path
from datetime import datetime
from dotenv import load_dotenv
from database import Database
from ssh_keys import SSHKeyManager
from sftp_server import SFTPThreadedServer
from logger_config import setup_logging, get_logger
# Load environment variables
load_dotenv()
# Setup logging
setup_logging()
logger = get_logger(__name__)
# Read version from VERSION file or environment
def get_version():
"""Get application version from VERSION file or environment variable."""
# Check environment variable first (set during Docker build)
env_version = os.getenv('APP_VERSION')
if env_version and env_version != 'dev':
return env_version
# Fall back to VERSION file
try:
version_path = Path(__file__).parent / 'VERSION'
if version_path.exists():
return version_path.read_text().strip()
except Exception:
pass
return 'dev'
app = Flask(__name__)
app.secret_key = os.getenv('SECRET_KEY', 'change-this-secret-key-in-production')
# Get version for templates
app_version = get_version()
# Make version available to all templates
@app.context_processor
def inject_version():
return dict(version=app_version)
# Initialize components
db = Database()
ssh_key_manager = SSHKeyManager()
sftp_server = SFTPThreadedServer(
host=os.getenv('SFTP_HOST', '0.0.0.0'),
port=int(os.getenv('SFTP_PORT', '2222')),
database=db,
ssh_key_manager=ssh_key_manager,
backups_dir=os.getenv('BACKUPS_DIR', 'backups')
)
# Initialize database on startup
try:
db.init_database()
# Create default admin user if it doesn't exist
default_user = db.get_user_by_username('admin')
if not default_user:
default_password = os.getenv('ADMIN_PASSWORD', 'admin')
db.create_user('admin', generate_password_hash(default_password))
logger.warning("Created default admin user (password: 'admin' - CHANGE THIS!)")
logger.info("Database initialized successfully")
except Exception as e:
logger.error(f"Database initialization failed: {e}")
# Start SFTP server
try:
sftp_server.start()
logger.info(f"SFTP server started on {sftp_server.host}:{sftp_server.port}")
except Exception as e:
logger.error(f"Failed to start SFTP server: {e}")
def login_required(f):
"""Decorator to require login."""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/')
def index():
"""Redirect to dashboard if logged in, otherwise to login."""
if 'user_id' in session:
return redirect(url_for('dashboard'))
return redirect(url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Login page."""
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if not username or not password:
flash('Username and password are required', 'error')
return render_template('login.html')
user = db.get_user_by_username(username)
if user and check_password_hash(user['password_hash'], password):
session['user_id'] = user['id']
session['username'] = user['username']
return redirect(url_for('dashboard'))
else:
flash('Invalid username or password', 'error')
return render_template('login.html')
@app.route('/logout')
def logout():
"""Logout and clear session."""
session.clear()
return redirect(url_for('login'))
@app.route('/dashboard')
@login_required
def dashboard():
"""Main dashboard."""
instances = db.get_all_instances()
all_backups = db.get_all_backups()
# Add instance info to each backup
for backup in all_backups:
instance = db.get_instance_by_id(backup['instance_id'])
if instance:
backup['instance_name'] = instance['name']
backup['instance_identifier'] = instance['identifier']
return render_template('dashboard.html', instances=instances, backups=all_backups, sftp_server=sftp_server)
@app.route('/instances')
@login_required
def instances():
"""List all OPNsense instances."""
instances = db.get_all_instances()
# Get SSH keys for each instance
for instance in instances:
ssh_key = db.get_ssh_key_by_key_id(instance['ssh_key_id'])
if ssh_key:
instance['public_key'] = ssh_key['public_key']
else:
instance['public_key'] = None
return render_template('instances.html', instances=instances)
@app.route('/instances/new', methods=['GET', 'POST'])
@login_required
def new_instance():
"""Create a new OPNsense instance."""
if request.method == 'POST':
name = request.form.get('name')
identifier = request.form.get('identifier')
description = request.form.get('description', '')
if not name or not identifier:
flash('Name and identifier are required', 'error')
return render_template('new_instance.html')
# Check if identifier already exists
existing = db.get_instance_by_identifier(identifier)
if existing:
flash('An instance with this identifier already exists', 'error')
return render_template('new_instance.html')
# Generate SSH key pair
key_id = ssh_key_manager.generate_key_id()
private_key_path, public_key = ssh_key_manager.generate_key_pair(key_id)
# Create instance
instance_id = db.create_instance(name, identifier, key_id, description)
if not instance_id:
flash('Failed to create instance', 'error')
return render_template('new_instance.html')
# Save SSH key to database
db.save_ssh_key(key_id, instance_id, public_key, private_key_path)
flash('Instance created successfully!', 'success')
return redirect(url_for('instance_detail', instance_id=instance_id))
return render_template('new_instance.html')
@app.route('/instances/<int:instance_id>')
@login_required
def instance_detail(instance_id):
"""View instance details and backups."""
instance = db.get_instance_by_id(instance_id)
if not instance:
flash('Instance not found', 'error')
return redirect(url_for('instances'))
ssh_key = db.get_ssh_key_by_key_id(instance['ssh_key_id'])
if not ssh_key:
flash('SSH key not found for this instance', 'error')
return redirect(url_for('instances'))
backups = db.get_backups_for_instance(instance_id)
# Get SFTP connection info
sftp_host = os.getenv('SFTP_PUBLIC_HOST', request.host.split(':')[0])
# Use public port if configured (e.g., NodePort), otherwise use internal port
sftp_port = int(os.getenv('SFTP_PUBLIC_PORT', sftp_server.port))
# Build SFTP URI for OPNsense with instance identifier in path
# Format: sftp://lan@host:port//lan
if sftp_port == 22:
sftp_uri = f"sftp://{instance['identifier']}@{sftp_host}//{instance['identifier']}"
else:
sftp_uri = f"sftp://{instance['identifier']}@{sftp_host}:{sftp_port}//{instance['identifier']}"
# Load private key for download
private_key_content = ssh_key_manager.load_private_key(instance['ssh_key_id'])
return render_template('instance_detail.html',
instance=instance,
ssh_key=ssh_key,
backups=backups,
sftp_host=sftp_host,
sftp_port=sftp_port,
sftp_uri=sftp_uri,
private_key_content=private_key_content.decode('utf-8') if private_key_content else None)
@app.route('/backups')
@login_required
def backups():
"""List all backups."""
all_backups = db.get_all_backups()
# Add instance info to each backup
for backup in all_backups:
instance = db.get_instance_by_id(backup['instance_id'])
if instance:
backup['instance_name'] = instance['name']
backup['instance_identifier'] = instance['identifier']
return render_template('backups.html', backups=all_backups)
@app.route('/backups/<int:backup_id>/download')
@login_required
def download_backup(backup_id):
"""Download a backup file."""
all_backups = db.get_all_backups()
backup = next((b for b in all_backups if b['id'] == backup_id), None)
if not backup:
flash('Backup not found', 'error')
return redirect(url_for('backups'))
file_path = Path(backup['file_path'])
if not file_path.exists():
flash('Backup file not found on disk', 'error')
return redirect(url_for('backups'))
return send_file(
str(file_path),
as_attachment=True,
download_name=backup['filename']
)
@app.route('/instances/<int:instance_id>/download-key')
@login_required
def download_private_key(instance_id):
"""Download the private key for an instance."""
instance = db.get_instance_by_id(instance_id)
if not instance:
flash('Instance not found', 'error')
return redirect(url_for('instances'))
private_key_content = ssh_key_manager.load_private_key(instance['ssh_key_id'])
if not private_key_content:
flash('Private key not found', 'error')
return redirect(url_for('instance_detail', instance_id=instance_id))
# Create a response with the private key
from flask import Response
response = Response(
private_key_content,
mimetype='text/plain',
headers={
'Content-Disposition': f'attachment; filename="{instance["identifier"]}_private_key"'
}
)
return response
@app.route('/backups/<int:backup_id>/delete', methods=['POST'])
@login_required
def delete_backup(backup_id):
"""Delete a backup."""
all_backups = db.get_all_backups()
backup = next((b for b in all_backups if b['id'] == backup_id), None)
if not backup:
flash('Backup not found', 'error')
return redirect(url_for('backups'))
file_path = Path(backup['file_path'])
if file_path.exists():
try:
file_path.unlink()
except Exception as e:
flash(f'Error deleting file: {e}', 'error')
return redirect(url_for('backups'))
# Delete from database
try:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM backups WHERE id = %s", (backup_id,))
conn.commit()
cursor.close()
except Exception as e:
flash(f'Error deleting backup record: {e}', 'error')
return redirect(url_for('backups'))
flash('Backup deleted successfully', 'success')
return redirect(url_for('backups'))
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
+282
View File
@@ -0,0 +1,282 @@
"""
Database connection and schema management for OPNsense backup system.
"""
import mysql.connector
from mysql.connector import Error
import os
from contextlib import contextmanager
from typing import Optional, List, Dict, Any
from logger_config import get_logger
logger = get_logger(__name__)
class Database:
"""Handle MariaDB database operations."""
def __init__(self):
self.host = os.getenv('DB_HOST', 'localhost')
self.port = int(os.getenv('DB_PORT', '3306'))
self.database = os.getenv('DB_NAME', 'opnsense_backup')
self.user = os.getenv('DB_USER', 'opnsense_backup')
self.password = os.getenv('DB_PASSWORD', 'changeme')
@contextmanager
def get_connection(self):
"""Get database connection with context manager."""
conn = None
try:
conn = mysql.connector.connect(
host=self.host,
port=self.port,
database=self.database,
user=self.user,
password=self.password
)
yield conn
except Error as e:
logger.error(f"Database connection error: {e}")
raise
finally:
if conn and conn.is_connected():
conn.close()
def init_database(self):
"""Initialize database schema."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
# Create users table
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create opnsense_instances table
cursor.execute("""
CREATE TABLE IF NOT EXISTS opnsense_instances (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
identifier VARCHAR(100) UNIQUE NOT NULL,
ssh_key_id VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_backup TIMESTAMP NULL,
description TEXT
)
""")
# Create ssh_keys table
cursor.execute("""
CREATE TABLE IF NOT EXISTS ssh_keys (
id INT AUTO_INCREMENT PRIMARY KEY,
key_id VARCHAR(50) UNIQUE NOT NULL,
instance_id INT NOT NULL,
public_key TEXT NOT NULL,
private_key_path VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES opnsense_instances(id) ON DELETE CASCADE
)
""")
# Create backups table
cursor.execute("""
CREATE TABLE IF NOT EXISTS backups (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES opnsense_instances(id) ON DELETE CASCADE
)
""")
conn.commit()
cursor.close()
logger.info("Database schema initialized successfully")
except Error as e:
logger.error(f"Error initializing database: {e}")
raise
def create_user(self, username: str, password_hash: str) -> Optional[int]:
"""Create a new user."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO users (username, password_hash) VALUES (%s, %s)",
(username, password_hash)
)
conn.commit()
user_id = cursor.lastrowid
cursor.close()
return user_id
except Error as e:
logger.error(f"Error creating user: {e}")
return None
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
"""Get user by username."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM users WHERE username = %s", (username,))
user = cursor.fetchone()
cursor.close()
return user
except Error as e:
logger.error(f"Error getting user: {e}")
return None
def create_instance(self, name: str, identifier: str, ssh_key_id: str, description: str = "") -> Optional[int]:
"""Create a new OPNsense instance."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""INSERT INTO opnsense_instances (name, identifier, ssh_key_id, description)
VALUES (%s, %s, %s, %s)""",
(name, identifier, ssh_key_id, description)
)
conn.commit()
instance_id = cursor.lastrowid
cursor.close()
return instance_id
except Error as e:
logger.error(f"Error creating instance: {e}")
return None
def get_instance_by_identifier(self, identifier: str) -> Optional[Dict[str, Any]]:
"""Get instance by identifier."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT * FROM opnsense_instances WHERE identifier = %s",
(identifier,)
)
instance = cursor.fetchone()
cursor.close()
return instance
except Error as e:
logger.error(f"Error getting instance by identifier: {e}")
return None
def get_all_instances(self) -> List[Dict[str, Any]]:
"""Get all instances."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM opnsense_instances ORDER BY created_at DESC")
instances = cursor.fetchall()
cursor.close()
return instances
except Error as e:
logger.error(f"Error getting instances: {e}")
return []
def save_ssh_key(self, key_id: str, instance_id: int, public_key: str, private_key_path: str) -> bool:
"""Save SSH key to database."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""INSERT INTO ssh_keys (key_id, instance_id, public_key, private_key_path)
VALUES (%s, %s, %s, %s)""",
(key_id, instance_id, public_key, private_key_path)
)
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error saving SSH key: {e}")
return False
def get_ssh_key_by_key_id(self, key_id: str) -> Optional[Dict[str, Any]]:
"""Get SSH key by key_id."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM ssh_keys WHERE key_id = %s", (key_id,))
key = cursor.fetchone()
cursor.close()
return key
except Error as e:
logger.error(f"Error getting SSH key: {e}")
return None
def record_backup(self, instance_id: int, filename: str, file_path: str, file_size: int) -> bool:
"""Record a backup in the database."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""INSERT INTO backups (instance_id, filename, file_path, file_size)
VALUES (%s, %s, %s, %s)""",
(instance_id, filename, file_path, file_size)
)
cursor.execute(
"UPDATE opnsense_instances SET last_backup = CURRENT_TIMESTAMP WHERE id = %s",
(instance_id,)
)
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error recording backup: {e}")
return False
def get_backups_for_instance(self, instance_id: int) -> List[Dict[str, Any]]:
"""Get all backups for an instance."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""SELECT * FROM backups WHERE instance_id = %s
ORDER BY uploaded_at DESC""",
(instance_id,)
)
backups = cursor.fetchall()
cursor.close()
return backups
except Error as e:
logger.error(f"Error getting backups: {e}")
return []
def get_all_backups(self) -> List[Dict[str, Any]]:
"""Get all backups with instance information."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT b.*, o.name as instance_name, o.identifier as instance_identifier
FROM backups b
JOIN opnsense_instances o ON b.instance_id = o.id
ORDER BY b.uploaded_at DESC
""")
backups = cursor.fetchall()
cursor.close()
return backups
except Error as e:
logger.error(f"Error getting all backups: {e}")
return []
def get_instance_by_id(self, instance_id: int) -> Optional[Dict[str, Any]]:
"""Get instance by ID."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM opnsense_instances WHERE id = %s", (instance_id,))
instance = cursor.fetchone()
cursor.close()
return instance
except Error as e:
logger.error(f"Error getting instance by ID: {e}")
return None
+106
View File
@@ -0,0 +1,106 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: opnsense-sftp
namespace: opnsense-sftp
spec:
replicas: 1
selector:
matchLabels:
app: opnsense-sftp
template:
metadata:
labels:
app: opnsense-sftp
spec:
containers:
- name: opnsense-sftp
image: ghcr.io/jdb-net/opnsense-sftp:latest
imagePullPolicy: Always
ports:
- containerPort: 5000
name: "opnsense-sftp"
- containerPort: 2222
name: "opnsense-sftp-sftp"
env:
- name: DB_HOST
value: "10.10.2.27"
- name: DB_PORT
value: "3306"
- name: DB_NAME
value: "opnsense-sftp"
- name: DB_USER
value: "opnsense-sftp"
- name: DB_PASSWORD
value: "n1RNwIJ9RvMiWsIE2T4H"
- name: SECRET_KEY
value: "hSnbupjp3T0UACP5xmLtO6TadizEBc"
- name: ADMIN_PASSWORD
value: "CVk7QKIB3MjZ8mt6MxES"
- name: SFTP_PUBLIC_HOST
value: "opnsense-sftp.jdb143.uk"
- name: SFTP_PUBLIC_PORT
value: "30222"
volumeMounts:
- name: keys-volume
mountPath: /app/keys
- name: backups-volume
mountPath: /app/backups
volumes:
- name: keys-volume
nfs:
server: 10.10.2.5
path: /srv/Backups/OPNsense/keys
- name: backups-volume
nfs:
server: 10.10.2.5
path: /srv/Backups/OPNsense/backups
---
apiVersion: v1
kind: Service
metadata:
name: opnsense-sftp-ingress-service
namespace: opnsense-sftp
spec:
selector:
app: opnsense-sftp
ports:
- protocol: TCP
port: 80
targetPort: 5000
name: http
---
apiVersion: v1
kind: Service
metadata:
name: opnsense-sftp-sftp-service
namespace: opnsense-sftp
spec:
type: NodePort
selector:
app: opnsense-sftp
ports:
- protocol: TCP
port: 2222
targetPort: 2222
nodePort: 30222
name: sftp
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: opnsense-sftp-ingress
namespace: opnsense-sftp
spec:
rules:
- host: opnsense-sftp.jdb143.uk
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: opnsense-sftp-ingress-service
port:
number: 80
---
+71
View File
@@ -0,0 +1,71 @@
"""
Custom logging configuration with emojis and clean formatting.
"""
import logging
import sys
from datetime import datetime
class EmojiFormatter(logging.Formatter):
"""Custom formatter with emojis for different log levels."""
# Emoji mapping for log levels
EMOJIS = {
'DEBUG': '🔍',
'INFO': '',
'WARNING': '⚠️',
'ERROR': '',
'CRITICAL': '🚨',
}
def format(self, record):
# Get emoji for log level
emoji = self.EMOJIS.get(record.levelname, '📝')
# Format timestamp
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# Format message with emoji and timestamp
message = f"{emoji} [{timestamp}] {record.getMessage()}"
return message
def setup_logging(level=logging.INFO):
"""Setup custom logging configuration.
Args:
level: Logging level (default: INFO)
"""
# Get root logger
logger = logging.getLogger()
logger.setLevel(level)
# Remove existing handlers
logger.handlers = []
# Create console handler
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(level)
# Set custom formatter
formatter = EmojiFormatter()
handler.setFormatter(formatter)
# Add handler to logger
logger.addHandler(handler)
return logger
def get_logger(name):
"""Get a logger with the custom configuration.
Args:
name: Logger name (usually __name__)
Returns:
Logger instance
"""
return logging.getLogger(name)
+7
View File
@@ -0,0 +1,7 @@
Flask
gunicorn
paramiko
mysql-connector-python
cryptography
werkzeug
python-dotenv
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
echo "Generating CSS..."
./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.js" --minify
echo "Starting app..."
python app.py
+605
View File
@@ -0,0 +1,605 @@
"""
SFTP server implementation for OPNsense backup system.
"""
import os
import threading
from pathlib import Path
from typing import Optional
import paramiko
from paramiko import ServerInterface, AUTH_FAILED, OPEN_SUCCEEDED
from paramiko.sftp_server import SFTPServer, SFTPServerInterface
from paramiko.sftp_handle import SFTPHandle
from database import Database
from ssh_keys import SSHKeyManager
from logger_config import get_logger
logger = get_logger(__name__)
class OPNsenseServerInterface(ServerInterface):
"""SSH server interface for authentication and SFTP operations."""
def __init__(self, database: Database, ssh_key_manager: SSHKeyManager, backups_dir: str = "backups"):
"""Initialize server interface.
Args:
database: Database instance
ssh_key_manager: SSH key manager instance
backups_dir: Directory to store backups
"""
self.database = database
self.ssh_key_manager = ssh_key_manager
self.backups_dir = Path(backups_dir)
self.backups_dir.mkdir(exist_ok=True, mode=0o755)
self.current_instance = None
def _canonicalize(self, path):
"""Canonicalize path - ensure it's within backups directory."""
if isinstance(path, bytes):
path = path.decode('utf-8')
# Remove leading slash
path = path.lstrip('/')
if not self.current_instance:
logger.error(f"No current instance for path: {path}")
return None
# If path is just the instance identifier (e.g., "lan" from "/lan"),
# treat it as the root directory for this instance
if path == self.current_instance['identifier']:
path = ""
# If path starts with instance identifier (e.g., "lan/backup.xml" from "/lan/backup.xml"),
# remove it to avoid duplicate instance directory in path
if path.startswith(self.current_instance['identifier'] + '/'):
path = path[len(self.current_instance['identifier']) + 1:]
instance_dir = self.backups_dir / self.current_instance['identifier']
instance_dir.mkdir(exist_ok=True, mode=0o755)
full_path = instance_dir / path if path else instance_dir
try:
full_path = full_path.resolve()
instance_dir_resolved = instance_dir.resolve()
if not str(full_path).startswith(str(instance_dir_resolved)):
logger.warning(f"Path traversal attempt detected: {full_path} not in {instance_dir_resolved}")
return None # Path traversal attempt
except Exception as e:
logger.error(f"Error resolving path {full_path}: {e}")
return None
return str(full_path)
def check_channel_request(self, kind, chanid):
"""Check channel request."""
if kind == "session":
return OPEN_SUCCEEDED
return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
def check_auth_publickey(self, username, key):
"""Authenticate using public key.
Args:
username: Username (should be instance identifier)
key: Public key object from paramiko
Returns:
AUTH_SUCCESSFUL or AUTH_FAILED
"""
try:
# Get instance by identifier (username)
instance = self.database.get_instance_by_identifier(username)
if not instance:
logger.warning(f"Instance not found: {username}")
return AUTH_FAILED
# Get SSH key for this instance
ssh_key = self.database.get_ssh_key_by_key_id(instance['ssh_key_id'])
if not ssh_key:
logger.warning(f"SSH key not found for instance: {username}")
return AUTH_FAILED
# Compare public keys - get base64 representation
stored_public_key = ssh_key['public_key'].strip()
key_fingerprint = key.get_base64()
# Extract key type and base64 from stored key
# Format: "ssh-rsa AAAAB3NzaC1yc2E..."
parts = stored_public_key.split()
if len(parts) >= 2:
stored_base64 = parts[1]
if stored_base64 == key_fingerprint or key_fingerprint in stored_public_key:
self.current_instance = instance
logger.info(f"Authentication successful for instance: {username}")
return paramiko.AUTH_SUCCESSFUL
logger.warning(f"Public key mismatch for instance: {username}")
return AUTH_FAILED
except Exception as e:
logger.error(f"Error during authentication: {e}")
return AUTH_FAILED
def get_allowed_auths(self, username):
"""Return allowed authentication methods."""
return "publickey"
def check_auth_password(self, username, password):
"""Password authentication not supported."""
return AUTH_FAILED
def check_auth_none(self, username):
"""None authentication not supported."""
return AUTH_FAILED
class OPNsenseSFTPHandle(SFTPHandle):
"""Custom SFTP handle that records backups when closed."""
def __init__(self, flags, sftp_interface):
"""Initialize SFTP handle.
Args:
flags: File open flags
sftp_interface: OPNsenseSFTPServerInterface instance
"""
super().__init__(flags)
self.sftp_interface = sftp_interface
self.filename = None
self.readfile = None
self.writefile = None
def close(self):
"""Close file handle and record backup if it was a write operation."""
try:
if self.writefile:
self.writefile.close()
instance = self.sftp_interface.current_instance
if instance and self.filename:
filename = Path(self.filename).name
file_size = Path(self.filename).stat().st_size if Path(self.filename).exists() else 0
try:
self.sftp_interface.database.record_backup(
instance_id=instance['id'],
filename=filename,
file_path=str(self.filename),
file_size=file_size
)
logger.info(f"Backup recorded: {filename} ({file_size} bytes) for instance {instance['identifier']}")
except Exception as e:
logger.error(f"Error recording backup in database: {e}", exc_info=True)
if self.readfile:
self.readfile.close()
# Call parent close
super().close()
except Exception as e:
logger.error(f"Error in OPNsenseSFTPHandle.close: {e}", exc_info=True)
super().close()
class OPNsenseSFTPServerInterface(SFTPServerInterface):
"""SFTP server interface for handling file operations."""
def __init__(self, server, *args, **kwargs):
"""Initialize SFTP server interface.
Args:
server: The OPNsenseServerInterface instance (from ServerInterface)
"""
super().__init__(server, *args, **kwargs)
self.server_interface = server
@property
def current_instance(self):
"""Get current instance from server interface."""
return getattr(self.server_interface, 'current_instance', None)
@property
def backups_dir(self):
"""Get backups directory from server interface."""
return getattr(self.server_interface, 'backups_dir', Path('backups'))
@property
def database(self):
"""Get database from server interface."""
return getattr(self.server_interface, 'database', None)
def _canonicalize(self, path):
"""Canonicalize path - ensure it's within backups directory."""
if isinstance(path, bytes):
path = path.decode('utf-8')
original_path = path
# Check if path is already an absolute path within our backups directory
try:
path_obj = Path(path)
if path_obj.is_absolute():
path_resolved = path_obj.resolve()
instance_dir = self.backups_dir / (self.current_instance['identifier'] if self.current_instance else '')
instance_dir_resolved = instance_dir.resolve()
# If the resolved path is within the instance directory, use it directly
if str(path_resolved).startswith(str(instance_dir_resolved)):
return str(path_resolved)
except Exception:
pass
# Remove leading slash
path = path.lstrip('/')
instance = self.current_instance
if not instance:
logger.error(f"No current instance for path: {path}")
return None
# If path is just the instance identifier (e.g., "lan" from "/lan"),
# treat it as the root directory for this instance
if path == instance['identifier']:
path = ""
# If path starts with instance identifier (e.g., "lan/backup.xml" from "/lan/backup.xml"),
# remove it to avoid duplicate instance directory in path
if path.startswith(instance['identifier'] + '/'):
path = path[len(instance['identifier']) + 1:]
instance_dir = self.backups_dir / instance['identifier']
instance_dir.mkdir(exist_ok=True, mode=0o755)
full_path = instance_dir / path if path else instance_dir
try:
full_path = full_path.resolve()
instance_dir_resolved = instance_dir.resolve()
if not str(full_path).startswith(str(instance_dir_resolved)):
logger.warning(f"Path traversal attempt detected: {full_path} not in {instance_dir_resolved}")
return None
except Exception as e:
logger.error(f"Error resolving path {full_path}: {e}", exc_info=True)
return None
return str(full_path)
def canonicalize(self, path):
"""Convert path to real path (canonicalized). This is called by paramiko for REALPATH requests."""
canonical_path = self._canonicalize(path)
if not canonical_path:
logger.warning(f"canonicalize: canonicalization failed for {path}, returning original")
return path
return canonical_path
def stat(self, path):
"""Get file/directory stats."""
canonical_path = self._canonicalize(path)
if not canonical_path:
logger.error(f"stat: canonicalization failed for path: {path}")
return paramiko.SFTP_NO_SUCH_FILE
# Ensure the directory exists
if not os.path.exists(canonical_path):
instance = self.current_instance
if instance:
instance_dir = self.backups_dir / instance['identifier']
canonical_path_obj = Path(canonical_path)
try:
if canonical_path_obj.resolve() == instance_dir.resolve():
os.makedirs(canonical_path, mode=0o755, exist_ok=True)
else:
return paramiko.SFTP_NO_SUCH_FILE
except Exception as e:
logger.error(f"Error creating instance directory: {e}")
return paramiko.SFTP_NO_SUCH_FILE
else:
logger.error(f"stat: no current instance")
return paramiko.SFTP_NO_SUCH_FILE
try:
stat_result = os.stat(canonical_path)
attr = paramiko.SFTPAttributes.from_stat(stat_result)
if os.path.isdir(canonical_path):
attr.st_mode = stat_result.st_mode
return attr
except OSError as e:
logger.error(f"Error getting stats for {canonical_path}: {e}")
if e.errno == 2: # No such file or directory
return paramiko.SFTP_NO_SUCH_FILE
return paramiko.SFTP_FAILURE
except Exception as e:
logger.error(f"Unexpected error getting stats: {e}", exc_info=True)
return paramiko.SFTP_FAILURE
def lstat(self, path):
"""Get file/directory stats (without following symlinks)."""
canonical_path = self._canonicalize(path)
if not canonical_path:
logger.error(f"lstat: canonicalization failed for path: {path}")
return paramiko.SFTP_NO_SUCH_FILE
# Ensure the directory exists
if not os.path.exists(canonical_path):
instance = self.current_instance
if instance:
instance_dir = self.backups_dir / instance['identifier']
canonical_path_obj = Path(canonical_path)
try:
if canonical_path_obj.resolve() == instance_dir.resolve():
os.makedirs(canonical_path, mode=0o755, exist_ok=True)
else:
return paramiko.SFTP_NO_SUCH_FILE
except Exception as e:
logger.error(f"Error creating instance directory: {e}")
return paramiko.SFTP_NO_SUCH_FILE
else:
logger.error(f"lstat: no current instance")
return paramiko.SFTP_NO_SUCH_FILE
try:
stat_result = os.lstat(canonical_path)
attr = paramiko.SFTPAttributes.from_stat(stat_result)
if os.path.isdir(canonical_path):
attr.st_mode = stat_result.st_mode
return attr
except OSError as e:
logger.error(f"Error getting lstat for {canonical_path}: {e}")
if e.errno == 2: # No such file or directory
return paramiko.SFTP_NO_SUCH_FILE
return paramiko.SFTP_FAILURE
except Exception as e:
logger.error(f"Unexpected error getting lstat: {e}", exc_info=True)
return paramiko.SFTP_FAILURE
def open(self, path, flags, attr):
"""Open a file for reading/writing."""
canonical_path = self._canonicalize(path)
if not canonical_path:
logger.error(f"open: canonicalization failed for {path}")
return paramiko.SFTP_NO_SUCH_FILE
if os.path.isdir(canonical_path):
logger.warning(f"open: attempted to open directory as file: {canonical_path}")
return paramiko.SFTP_FAILURE
try:
if flags & os.O_WRONLY or flags & os.O_RDWR or (flags & os.O_CREAT and flags & os.O_WRONLY):
Path(canonical_path).parent.mkdir(parents=True, exist_ok=True)
f = open(canonical_path, 'wb')
file_handle = OPNsenseSFTPHandle(flags, self)
file_handle.filename = canonical_path
file_handle.readfile = None
file_handle.writefile = f
return file_handle
else:
if not os.path.exists(canonical_path):
logger.error(f"open: file does not exist: {canonical_path}")
return paramiko.SFTP_NO_SUCH_FILE
if os.path.isdir(canonical_path):
logger.error(f"open: path is directory: {canonical_path}")
return paramiko.SFTP_FAILURE
f = open(canonical_path, 'rb')
file_handle = OPNsenseSFTPHandle(flags, self)
file_handle.filename = canonical_path
file_handle.readfile = f
file_handle.writefile = None
return file_handle
except OSError as e:
logger.error(f"Error opening file {canonical_path}: {e}")
if e.errno == 2: # No such file or directory
return paramiko.SFTP_NO_SUCH_FILE
return paramiko.SFTP_FAILURE
except Exception as e:
logger.error(f"Unexpected error opening file {canonical_path}: {e}", exc_info=True)
return paramiko.SFTP_FAILURE
def close(self, handle):
"""Close file handle.
Note: Paramiko calls handle.close() directly, so the backup recording
is handled in OPNsenseSFTPHandle.close(). This method is kept for
compatibility but shouldn't be called for file handles.
"""
# The actual close logic is in OPNsenseSFTPHandle.close()
return paramiko.SFTP_OK
def list_folder(self, path):
"""List folder contents."""
canonical_path = self._canonicalize(path)
if not canonical_path:
logger.warning(f"list_folder: canonicalization failed")
return []
if not os.path.exists(canonical_path):
try:
os.makedirs(canonical_path, mode=0o755, exist_ok=True)
except Exception as e:
logger.error(f"Error creating directory: {e}")
return []
if not os.path.isdir(canonical_path):
logger.warning(f"list_folder: path is not a directory: {canonical_path}")
return []
try:
files = []
for item in os.listdir(canonical_path):
item_path = os.path.join(canonical_path, item)
stat = os.stat(item_path)
attr = paramiko.SFTPAttributes.from_stat(stat)
attr.filename = item
files.append(attr)
return files
except Exception as e:
logger.error(f"Error listing folder: {e}", exc_info=True)
return []
def remove(self, path):
"""Remove a file."""
canonical_path = self._canonicalize(path)
if not canonical_path:
return paramiko.SFTP_NO_SUCH_FILE
try:
os.remove(canonical_path)
logger.info(f"Deleted file: {Path(canonical_path).name}")
return paramiko.SFTP_OK
except OSError as e:
logger.error(f"Error removing file: {e}")
if e.errno == 2: # No such file or directory
return paramiko.SFTP_NO_SUCH_FILE
return paramiko.SFTP_FAILURE
except Exception as e:
logger.error(f"Unexpected error removing file: {e}")
return paramiko.SFTP_FAILURE
class OPNsenseSFTPServer(SFTPServer):
"""Custom SFTP server that uses OPNsenseSFTPServerInterface."""
def __init__(self, channel, name, server, *args, **kwargs):
"""Initialize SFTP server.
Args:
channel: SSH channel
name: Subsystem name
server: OPNsenseServerInterface instance (ServerInterface)
*args, **kwargs: Additional arguments passed through
"""
# Pass OPNsenseSFTPServerInterface as sftp_si parameter
# This tells SFTPServer to use our SFTP interface for handling operations
super().__init__(channel, name, server, sftp_si=OPNsenseSFTPServerInterface, *args, **kwargs)
class SFTPThreadedServer:
"""Threaded SFTP server that runs in background."""
def __init__(self, host: str = "0.0.0.0", port: int = 2222,
database: Optional[Database] = None,
ssh_key_manager: Optional[SSHKeyManager] = None,
backups_dir: str = "backups"):
"""Initialize threaded SFTP server.
Args:
host: Host to bind to
port: Port to listen on
database: Database instance
ssh_key_manager: SSH key manager instance
backups_dir: Directory for backups
"""
self.host = host
self.port = port
self.database = database or Database()
self.ssh_key_manager = ssh_key_manager or SSHKeyManager()
self.backups_dir = backups_dir
self.server_socket = None
self.thread = None
self.running = False
def _handle_client(self, client, addr):
"""Handle individual client connection."""
try:
transport = paramiko.Transport(client)
# Create server instance
server_interface = OPNsenseServerInterface(
self.database,
self.ssh_key_manager,
self.backups_dir
)
# Load or generate host key
keys_dir = Path("keys")
keys_dir.mkdir(exist_ok=True, mode=0o700)
host_key_path = keys_dir / "host_key"
if not host_key_path.exists():
key = paramiko.RSAKey.generate(2048)
key.write_private_key_file(str(host_key_path))
os.chmod(host_key_path, 0o600)
logger.info("Generated new SSH host key")
host_key = paramiko.RSAKey.from_private_key_file(str(host_key_path))
transport.add_server_key(host_key)
# Set SFTP subsystem - use a factory function to create OPNsenseSFTPServer
def create_sftp_server(channel, name, server):
return OPNsenseSFTPServer(channel, name, server)
transport.set_subsystem_handler('sftp', create_sftp_server)
# Start server
transport.start_server(server=server_interface)
# Accept connection
channel = transport.accept(20)
if channel is None:
logger.warning(f"Client {addr} connection timeout")
transport.close()
return
instance_id = server_interface.current_instance['identifier'] if server_interface.current_instance else 'unknown'
logger.info(f"SFTP client connected from {addr[0]}:{addr[1]} as {instance_id}")
# Keep connection alive
while transport.is_active():
import time
time.sleep(1)
transport.close()
logger.info(f"SFTP client {addr[0]}:{addr[1]} disconnected")
except Exception as e:
logger.error(f"Error handling SFTP client {addr}: {e}")
def start(self):
"""Start the SFTP server in a background thread."""
if self.running:
logger.warning("SFTP server already running")
return
try:
import socket
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server_socket.bind((self.host, self.port))
self.server_socket.listen(10)
self.running = True
def server_loop():
logger.info(f"SFTP server started on {self.host}:{self.port}")
while self.running:
try:
client, addr = self.server_socket.accept()
client_thread = threading.Thread(
target=self._handle_client,
args=(client, addr),
daemon=True
)
client_thread.start()
except Exception as e:
if self.running:
logger.error(f"Error accepting SFTP connection: {e}")
self.thread = threading.Thread(target=server_loop, daemon=True)
self.thread.start()
except Exception as e:
logger.error(f"Error starting SFTP server: {e}")
self.running = False
raise
def stop(self):
"""Stop the SFTP server."""
self.running = False
if self.server_socket:
try:
self.server_socket.close()
except:
pass
logger.info("SFTP server stopped")
+124
View File
@@ -0,0 +1,124 @@
"""
SSH key generation and management for OPNsense backup system.
"""
import os
import uuid
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
from pathlib import Path
from typing import Tuple, Optional
from logger_config import get_logger
logger = get_logger(__name__)
class SSHKeyManager:
"""Manage SSH key generation and storage."""
def __init__(self, keys_dir: str = "keys"):
"""Initialize SSH key manager.
Args:
keys_dir: Directory to store SSH private keys
"""
self.keys_dir = Path(keys_dir)
self.keys_dir.mkdir(exist_ok=True, mode=0o700) # Ensure directory exists with proper permissions
def generate_key_pair(self, key_id: str) -> Tuple[str, str]:
"""Generate a new SSH key pair.
Args:
key_id: Unique identifier for the key
Returns:
Tuple of (private_key_path, public_key_string)
"""
# Generate RSA key pair (4096 bits)
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
backend=default_backend()
)
# Serialize private key in OpenSSH format
private_key_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.OpenSSH,
encryption_algorithm=serialization.NoEncryption()
)
# Get public key in OpenSSH format
public_key = private_key.public_key()
public_key_ssh = public_key.public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH
)
# Save private key to file
private_key_path = self.keys_dir / f"{key_id}"
with open(private_key_path, 'wb') as f:
f.write(private_key_pem)
os.chmod(private_key_path, 0o600) # Restrict permissions
# Return public key as string and private key path
public_key_str = public_key_ssh.decode('utf-8')
return str(private_key_path), public_key_str
def get_public_key_for_display(self, public_key: str, comment: str = "") -> str:
"""Format public key for display (add comment if needed).
Args:
public_key: Public key string
comment: Optional comment to append
Returns:
Formatted public key string
"""
if comment:
return f"{public_key} {comment}"
return public_key
def load_private_key(self, key_id: str) -> Optional[bytes]:
"""Load private key from file.
Args:
key_id: Key identifier
Returns:
Private key bytes or None if not found
"""
private_key_path = self.keys_dir / key_id
if not private_key_path.exists():
return None
try:
with open(private_key_path, 'rb') as f:
return f.read()
except Exception as e:
logger.error(f"Error loading private key {key_id}: {e}")
return None
def delete_key(self, key_id: str) -> bool:
"""Delete SSH key file.
Args:
key_id: Key identifier
Returns:
True if deleted successfully
"""
private_key_path = self.keys_dir / key_id
try:
if private_key_path.exists():
os.remove(private_key_path)
return True
except Exception as e:
logger.error(f"Error deleting key {key_id}: {e}")
return False
def generate_key_id(self) -> str:
"""Generate a unique key identifier."""
return str(uuid.uuid4())
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Backups - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">All Backups</h1>
<p class="text-neutral-400">View and download your backup files</p>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-700">
<h2 class="text-xl font-bold text-neutral-100">Backup Files</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-neutral-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Instance</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Filename</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Upload Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{% if backups %}
{% for backup in backups %}
<tr class="hover:bg-neutral-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-neutral-100">{{ backup.instance_name or backup.instance_identifier }}</div>
<code class="text-xs text-neutral-400">{{ backup.instance_identifier }}</code>
</td>
<td class="px-6 py-4">
<code class="text-sm text-neutral-300 break-all">{{ backup.filename }}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-neutral-400">{{ "%.2f"|format(backup.file_size / 1024 / 1024) }} MB</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-neutral-400">{{ backup.uploaded_at.strftime('%Y-%m-%d %H:%M') if backup.uploaded_at else 'N/A' }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<a href="{{ url_for('download_backup', backup_id=backup.id) }}" class="text-orange-500 hover:text-orange-400">
Download
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="px-6 py-8 text-center text-neutral-400">
No backups found. Configure your OPNsense instances to start receiving backups.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+70
View File
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}OPNsense Backup Manager{% endblock %}</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='opnsense.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
</head>
<body class="bg-neutral-900 text-neutral-100 min-h-screen">
<nav class="bg-neutral-800 border-b border-neutral-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="{{ url_for('dashboard') }}" class="flex items-center space-x-4">
<img src="{{ url_for('static', filename='opnsense.png') }}" alt="OPNsense" class="h-8 w-8">
<span class="text-neutral-400">Backup Manager</span>
</a>
</div>
{% if session.user_id %}
<div class="flex items-center space-x-4">
<a href="{{ url_for('dashboard') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Dashboard
</a>
<a href="{{ url_for('instances') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Instances
</a>
<a href="{{ url_for('backups') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Backups
</a>
<span class="text-neutral-400">|</span>
<span class="text-neutral-300">{{ session.username }}</span>
<a href="{{ url_for('logout') }}" class="bg-neutral-700 hover:bg-neutral-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
Logout
</a>
</div>
{% endif %}
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-2">
{% for category, message in messages %}
<div class="{% if category == 'error' %}bg-red-900 border-red-700{% elif category == 'success' %}bg-green-900 border-green-700{% else %}bg-blue-900 border-blue-700{% endif %} border rounded-md p-4">
<p class="text-sm">{{ message }}</p>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="bg-neutral-800 border-t border-neutral-700 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<p class="text-center text-neutral-400 text-sm">
OPNsense Backup Manager
{% if version %}
<span class="text-neutral-500">v{{ version }}</span>
{% endif %}
</p>
</div>
</footer>
</body>
</html>
+170
View File
@@ -0,0 +1,170 @@
{% extends "base.html" %}
{% block title %}Dashboard - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Dashboard</h1>
<p class="text-neutral-400">Overview of your OPNsense backup system</p>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
<div class="flex items-center justify-between">
<div>
<p class="text-neutral-400 text-sm font-medium">Total Instances</p>
<p class="text-3xl font-bold text-orange-500 mt-2">{{ instances|length }}</p>
</div>
<div class="p-3 bg-orange-500/20 rounded-lg">
<svg class="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
</div>
</div>
</div>
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
<div class="flex items-center justify-between">
<div>
<p class="text-neutral-400 text-sm font-medium">Total Backups</p>
<p class="text-3xl font-bold text-orange-500 mt-2">{{ backups|length }}</p>
</div>
<div class="p-3 bg-orange-500/20 rounded-lg">
<svg class="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
</svg>
</div>
</div>
</div>
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
<div class="flex items-center justify-between">
<div>
<p class="text-neutral-400 text-sm font-medium">SFTP Port</p>
<p class="text-3xl font-bold text-orange-500 mt-2">{{ sftp_server.port if sftp_server else 'N/A' }}</p>
</div>
<div class="p-3 bg-orange-500/20 rounded-lg">
<svg class="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold text-neutral-100">Quick Actions</h2>
<a href="{{ url_for('new_instance') }}" class="bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200">
+ Add Instance
</a>
</div>
<!-- Instances List -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-700">
<h2 class="text-xl font-bold text-neutral-100">OPNsense Instances</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-neutral-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Identifier</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Last Backup</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{% if instances %}
{% for instance in instances %}
<tr class="hover:bg-neutral-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-neutral-100">{{ instance.name }}</div>
{% if instance.description %}
<div class="text-sm text-neutral-400">{{ instance.description }}</div>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm text-orange-400">{{ instance.identifier }}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-neutral-400">
{% if instance.last_backup %}
{{ instance.last_backup.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-neutral-500">Never</span>
{% endif %}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<a href="{{ url_for('instance_detail', instance_id=instance.id) }}" class="text-orange-500 hover:text-orange-400 mr-4">View</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="px-6 py-8 text-center text-neutral-400">
No instances configured. <a href="{{ url_for('new_instance') }}" class="text-orange-500 hover:text-orange-400">Create one</a> to get started.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Recent Backups -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-700 flex justify-between items-center">
<h2 class="text-xl font-bold text-neutral-100">Recent Backups</h2>
<a href="{{ url_for('backups') }}" class="text-sm text-orange-500 hover:text-orange-400">View All</a>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-neutral-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Instance</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Filename</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{% if backups %}
{% for backup in backups[:10] %}
<tr class="hover:bg-neutral-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-neutral-100">{{ backup.instance_name or backup.instance_identifier }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm text-neutral-300">{{ backup.filename }}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-neutral-400">{{ "%.2f"|format(backup.file_size / 1024 / 1024) }} MB</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-neutral-400">{{ backup.uploaded_at.strftime('%Y-%m-%d %H:%M') if backup.uploaded_at else 'N/A' }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<a href="{{ url_for('download_backup', backup_id=backup.id) }}" class="text-orange-500 hover:text-orange-400">Download</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="px-6 py-8 text-center text-neutral-400">
No backups yet. Configure an instance and start backing up!
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+163
View File
@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}{{ instance.name }} - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">{{ instance.name }}</h1>
<p class="text-neutral-400">{{ instance.description or 'OPNsense backup instance' }}</p>
</div>
<a href="{{ url_for('instances') }}" class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200">
← Back to Instances
</a>
</div>
<!-- SFTP URI for OPNsense -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-xl font-bold text-neutral-100 mb-4">SFTP Target Location (URI)</h2>
<p class="text-sm text-neutral-400 mb-4">Use this URI format in OPNsense backup configuration:</p>
<div class="bg-neutral-900 rounded-md p-4 border border-neutral-600 mb-4">
<code class="text-sm text-orange-400 break-all block">{{ sftp_uri }}</code>
</div>
<div class="flex items-center space-x-2">
<button
onclick="copyToClipboard('{{ sftp_uri }}')"
class="bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 text-sm"
>
Copy URI
</button>
</div>
<div class="mt-4 bg-neutral-700 rounded-md p-3">
<p class="text-xs text-neutral-400">
<strong>Note:</strong> The double slash (<code>//</code>) or single slash after the host indicates the root path.
Backups will be stored automatically in the instance-specific directory.
</p>
</div>
</div>
<!-- SSH Private Key -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-xl font-bold text-neutral-100 mb-4">SSH Private Key</h2>
<p class="text-sm text-neutral-400 mb-4">
<strong class="text-orange-400">IMPORTANT:</strong> OPNsense requires the <strong>private key</strong> for authentication.
Download and paste this into OPNsense's SSH key field.
</p>
{% if private_key_content %}
<div class="bg-neutral-900 rounded-md p-4 border border-neutral-600 mb-4">
<code class="text-sm text-neutral-300 whitespace-pre-wrap break-all block">{{ private_key_content }}</code>
</div>
<div class="flex items-center space-x-2">
<button
onclick="copyToClipboard('{{ private_key_content }}')"
class="bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 text-sm"
>
Copy Private Key
</button>
<a
href="{{ url_for('download_private_key', instance_id=instance.id) }}"
class="bg-neutral-700 hover:bg-neutral-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 text-sm"
>
Download Private Key
</a>
</div>
{% else %}
<div class="bg-red-900/30 border border-red-700 rounded-md p-4">
<p class="text-sm text-red-400">Private key file not found on disk.</p>
</div>
{% endif %}
<div class="mt-4 bg-yellow-900/30 border border-yellow-700 rounded-md p-3">
<p class="text-xs text-yellow-400">
<strong>Security Warning:</strong> Keep this private key secure. Anyone with access to it can authenticate as this instance.
</p>
</div>
</div>
<!-- OPNsense Configuration Steps -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-xl font-bold text-neutral-100 mb-4">OPNsense Configuration Steps</h2>
<ol class="list-decimal list-inside space-y-3 text-neutral-300">
<li>Navigate to <strong class="text-neutral-100">System → Configuration → Backups</strong> in OPNsense</li>
<li>Click on <strong class="text-neutral-100">Add Backup Target</strong> or edit existing</li>
<li>Select <strong class="text-neutral-100">SFTP</strong> as the backup type</li>
<li>Enter the following:
<ul class="list-disc list-inside ml-6 mt-2 space-y-2 text-neutral-400">
<li><strong class="text-neutral-300">Target location (URI):</strong>
<code class="text-orange-400 bg-neutral-900 px-2 py-1 rounded">{{ sftp_uri }}</code>
<br><span class="text-xs">Copy the URI from above</span>
</li>
<li><strong class="text-neutral-300">SSH Private Key:</strong>
Copy or download the private key from above
<br><span class="text-xs text-yellow-400">⚠️ OPNsense requires the private key, not the public key!</span>
</li>
</ul>
</li>
<li>Configure your backup schedule and encryption options as desired</li>
<li>Save and test the backup configuration</li>
</ol>
<div class="mt-4 bg-blue-900/30 border border-blue-700 rounded-md p-3">
<p class="text-xs text-blue-400">
<strong>Tip:</strong> After saving, test the connection to verify authentication works correctly.
</p>
</div>
</div>
<!-- Backups List -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-700">
<h2 class="text-xl font-bold text-neutral-100">Backups</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-neutral-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Filename</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Upload Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{% if backups %}
{% for backup in backups %}
<tr class="hover:bg-neutral-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm text-neutral-300">{{ backup.filename }}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-neutral-400">{{ "%.2f"|format(backup.file_size / 1024 / 1024) }} MB</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-neutral-400">{{ backup.uploaded_at.strftime('%Y-%m-%d %H:%M') if backup.uploaded_at else 'N/A' }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<a href="{{ url_for('download_backup', backup_id=backup.id) }}" class="text-orange-500 hover:text-orange-400">Download</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="px-6 py-8 text-center text-neutral-400">
No backups yet. Configure OPNsense to start backing up to this instance.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() {
alert('Copied to clipboard!');
}, function(err) {
console.error('Failed to copy: ', err);
alert('Failed to copy to clipboard');
});
}
</script>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Instances - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">OPNsense Instances</h1>
<p class="text-neutral-400">Manage your OPNsense backup instances</p>
</div>
<a href="{{ url_for('new_instance') }}" class="bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200">
+ Add Instance
</a>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-700">
<h2 class="text-xl font-bold text-neutral-100">Configured Instances</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-neutral-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Identifier</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Last Backup</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-700">
{% if instances %}
{% for instance in instances %}
<tr class="hover:bg-neutral-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-neutral-100">{{ instance.name }}</div>
{% if instance.description %}
<div class="text-sm text-neutral-400">{{ instance.description }}</div>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm text-orange-400">{{ instance.identifier }}</code>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-neutral-400">
{% if instance.last_backup %}
{{ instance.last_backup.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-neutral-500">Never</span>
{% endif %}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<a href="{{ url_for('instance_detail', instance_id=instance.id) }}" class="text-orange-500 hover:text-orange-400">View Details</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="px-6 py-8 text-center text-neutral-400">
No instances configured. <a href="{{ url_for('new_instance') }}" class="text-orange-500 hover:text-orange-400">Create one</a> to get started.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Login - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="min-h-[calc(100vh-12rem)] flex items-center justify-center">
<div class="w-full max-w-md">
<div class="bg-neutral-800 rounded-lg shadow-lg p-8 border border-neutral-700">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-orange-500 mb-2">OPNsense Backup Manager</h1>
<p class="text-neutral-400">Sign in to manage your backups</p>
</div>
<form method="POST" action="{{ url_for('login') }}" class="space-y-6">
<div>
<label for="username" class="block text-sm font-medium text-neutral-300 mb-2">
Username
</label>
<input
type="text"
id="username"
name="username"
required
class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="Enter your username"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-neutral-300 mb-2">
Password
</label>
<input
type="password"
id="password"
name="password"
required
class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="Enter your password"
>
</div>
<button
type="submit"
class="w-full bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
Sign In
</button>
</form>
<div class="mt-6 p-4 bg-neutral-700 rounded-md">
<p class="text-xs text-neutral-400">
Default credentials: <code class="text-orange-400">admin / admin</code>
<br>Please change these after first login!
</p>
</div>
</div>
</div>
</div>
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}New Instance - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Add New OPNsense Instance</h1>
<p class="text-neutral-400">Create a new instance to start receiving backups</p>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<form method="POST" action="{{ url_for('new_instance') }}" class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-neutral-300 mb-2">
Instance Name <span class="text-red-400">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="e.g., Main Router, Office Firewall"
>
<p class="mt-1 text-sm text-neutral-400">A friendly name for this instance</p>
</div>
<div>
<label for="identifier" class="block text-sm font-medium text-neutral-300 mb-2">
Identifier <span class="text-red-400">*</span>
</label>
<input
type="text"
id="identifier"
name="identifier"
required
pattern="[a-z0-9-_]+"
class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="e.g., main-router, office-fw"
>
<p class="mt-1 text-sm text-neutral-400">Unique identifier (lowercase, alphanumeric, dashes, underscores only). This will be used as the SFTP username.</p>
</div>
<div>
<label for="description" class="block text-sm font-medium text-neutral-300 mb-2">
Description
</label>
<textarea
id="description"
name="description"
rows="3"
class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="Optional description for this instance"
></textarea>
</div>
<div class="flex justify-end space-x-4 pt-4">
<a href="{{ url_for('instances') }}" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-white font-medium rounded-md transition-colors duration-200">
Cancel
</a>
<button
type="submit"
class="px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white font-medium rounded-md transition-colors duration-200"
>
Create Instance
</button>
</div>
</form>
</div>
<div class="mt-6 bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h3 class="text-lg font-semibold text-neutral-100 mb-4">What happens next?</h3>
<ol class="list-decimal list-inside space-y-2 text-neutral-300">
<li>An SSH key pair will be automatically generated for this instance</li>
<li>You'll be provided with the public key to add to your OPNsense configuration</li>
<li>Use the instance identifier as the SFTP username when configuring OPNsense</li>
<li>Backups will be automatically stored and tracked</li>
</ol>
</div>
</div>
{% endblock %}