feat: ✨ initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
FROM mcr.microsoft.com/devcontainers/python:3.13
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
CMD ["sleep", "infinity"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.devcontainer
|
||||||
|
deployment.yml
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
".": "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
+20
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
---
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Flask
|
||||||
|
gunicorn
|
||||||
|
paramiko
|
||||||
|
mysql-connector-python
|
||||||
|
cryptography
|
||||||
|
werkzeug
|
||||||
|
python-dotenv
|
||||||
@@ -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
@@ -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
@@ -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())
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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 %}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
Reference in New Issue
Block a user