17 Commits

Author SHA1 Message Date
jamie 61e08d7f1a Merge pull request 'v1.4.0' (#14) from v1.4.0 into main
Reviewed-on: #14
2026-03-23 11:29:35 +00:00
jamie 68a8bdfe9e feat: add ability to change username, password and setup totp
Release / release (pull_request) Successful in 20s
2026-03-23 11:28:56 +00:00
jamie 8d12327752 feat: backup pruning 2026-03-23 11:16:36 +00:00
jamie dd3170464f ci: 🚀 remove deploy stage 2026-03-23 08:16:48 +00:00
jamie 4d3240a392 feat: display total backup size 2026-03-23 08:15:19 +00:00
jamie 6cbec23c49 Merge pull request 'feat: enhance backups page with filtering and pagination features' (#10) from v1.3.0 into main
Reviewed-on: #10
2026-01-08 18:10:58 +00:00
jamie c315066264 feat: enhance backups page with filtering and pagination features
Release / release (pull_request) Successful in 25s
Release / Deploy to Kubernetes (pull_request) Successful in 1s
2026-01-08 18:08:37 +00:00
jamie 945574906b Merge pull request 'chore: remove release-please configuration and related files' (#9) from v1.2.1 into main
Reviewed-on: #9
2026-01-08 17:43:52 +00:00
jamie ee10a0f35a chore: remove release-please configuration and related files
Release / release (pull_request) Successful in 26s
Release / Deploy to Kubernetes (pull_request) Successful in 1s
2026-01-08 17:43:33 +00:00
jamie 1ae54a46e7 build: 🚀 redeploy 2025-12-20 13:21:06 +00:00
Jamie 918fe80566 Merge pull request #4 from JDB-NET/release-please--branches--main
chore(main): release 1.2.0
2025-11-01 17:05:01 +00:00
github-actions[bot] 073a392f19 chore(main): release 1.2.0 2025-11-01 17:04:09 +00:00
jamie 703ab3b07d feat: latest backup api endpoint - /api/backups/latest 2025-11-01 16:53:32 +00:00
jamie b2cc478c85 refactor: 🎨 tidy login page 2025-11-01 16:48:28 +00:00
Jamie ac90952499 Merge pull request #3 from JDB-NET/release-please--branches--main
chore(main): release 1.1.2
2025-11-01 16:19:12 +00:00
github-actions[bot] dcea388b6f chore(main): release 1.1.2 2025-11-01 16:17:47 +00:00
jamie 2b91c19afb fix: 🐛 k3s character limit 2025-11-01 16:17:29 +00:00
21 changed files with 1589 additions and 176 deletions
+6 -2
View File
@@ -3,10 +3,14 @@
"build": { "build": {
"dockerfile": "Dockerfile" "dockerfile": "Dockerfile"
}, },
"settings": {},
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": ["ms-python.python"] "settings": {},
"extensions": [
"ms-python.python",
"vivaxy.vscode-conventional-commits",
"esbenp.prettier-vscode"
]
} }
}, },
"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", "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",
+46
View File
@@ -0,0 +1,46 @@
name: Release
on:
pull_request:
branches:
- main
types: [closed]
jobs:
release:
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Version
id: get_version
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
- name: Generate Changelog
id: changelog
uses: https://github.com/metcalfc/changelog-generator@v4.6.2
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
run: |
VERSION=${{ steps.get_version.outputs.VERSION }}
docker build -t cr.jdbnet.co.uk/public/opnsense-sftp:$VERSION \
-t cr.jdbnet.co.uk/public/opnsense-sftp:latest \
--build-arg VERSION=$VERSION \
.
docker push cr.jdbnet.co.uk/public/opnsense-sftp:$VERSION
docker push cr.jdbnet.co.uk/public/opnsense-sftp:latest
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.get_version.outputs.VERSION }}
name: ${{ steps.get_version.outputs.VERSION }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
-73
View File
@@ -1,73 +0,0 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
packages: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
steps:
- uses: googleapis/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
-10
View File
@@ -1,10 +0,0 @@
{
"packages": {
".": {
"release-type": "simple",
"version-file": "VERSION"
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
-4
View File
@@ -1,4 +0,0 @@
{
".": "1.1.1"
}
-21
View File
@@ -1,21 +0,0 @@
# Changelog
## [1.1.1](https://github.com/JDB-NET/opnsense-sftp/compare/v1.1.0...v1.1.1) (2025-11-01)
### Bug Fixes
* :bug: allow writing packages ([dbdeacd](https://github.com/JDB-NET/opnsense-sftp/commit/dbdeacdf7de133a0db4c44c13828458ff08a028a))
## [1.1.0](https://github.com/JDB-NET/opnsense-sftp/compare/v1.0.0...v1.1.0) (2025-11-01)
### Features
* :sparkles: initial commit ([07fc785](https://github.com/JDB-NET/opnsense-sftp/commit/07fc78592bc83e500ae0f3312d8a7bae9b0bf1f9))
### Bug Fixes
* :bug: public host ip ([6dd09cf](https://github.com/JDB-NET/opnsense-sftp/commit/6dd09cf147f7a20856af1cfa68390586f6acc2e5))
* :bug: release please config ([89e1231](https://github.com/JDB-NET/opnsense-sftp/commit/89e12315fe69a64849068645048b01c871165532))
+3 -5
View File
@@ -1,11 +1,9 @@
FROM python:3.13-slim FROM python:3.13-slim
LABEL org.opencontainers.image.vendor="JDB-NET"
WORKDIR /app WORKDIR /app
# Build argument for version
ARG VERSION=dev
ENV APP_VERSION=${VERSION}
COPY . /app COPY . /app
ARG VERSION=unknown
ENV APP_VERSION=${VERSION}
RUN pip install -r requirements.txt \ RUN pip install -r requirements.txt \
&& apt-get update \ && apt-get update \
&& apt-get install curl -y \ && apt-get install curl -y \
+2 -4
View File
@@ -36,17 +36,15 @@ docker run -d \
-e SFTP_PUBLIC_PORT=30222 \ -e SFTP_PUBLIC_PORT=30222 \
-v /path/to/keys:/app/keys \ -v /path/to/keys:/app/keys \
-v /path/to/backups:/app/backups \ -v /path/to/backups:/app/backups \
ghcr.io/jdb-net/opnsense-sftp:latest cr.jdbnet.co.uk/public/opnsense-sftp:latest
``` ```
### Docker Compose ### Docker Compose
```yaml ```yaml
version: '3.8'
services: services:
opnsense-sftp: opnsense-sftp:
image: ghcr.io/jdb-net/opnsense-sftp:latest image: cr.jdbnet.co.uk/public/opnsense-sftp:latest
container_name: opnsense-sftp container_name: opnsense-sftp
restart: unless-stopped restart: unless-stopped
ports: ports:
-1
View File
@@ -1 +0,0 @@
1.1.1
+629 -23
View File
@@ -7,8 +7,11 @@ from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timedelta
import threading
import time
from dotenv import load_dotenv from dotenv import load_dotenv
import pyotp
from database import Database from database import Database
from ssh_keys import SSHKeyManager from ssh_keys import SSHKeyManager
@@ -22,22 +25,8 @@ load_dotenv()
setup_logging() setup_logging()
logger = get_logger(__name__) logger = get_logger(__name__)
# Read version from VERSION file or environment
def get_version(): def get_version():
"""Get application version from VERSION file or environment variable.""" return os.getenv('APP_VERSION', 'dev')
# 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 = Flask(__name__)
app.secret_key = os.getenv('SECRET_KEY', 'change-this-secret-key-in-production') app.secret_key = os.getenv('SECRET_KEY', 'change-this-secret-key-in-production')
@@ -68,8 +57,11 @@ try:
default_user = db.get_user_by_username('admin') default_user = db.get_user_by_username('admin')
if not default_user: if not default_user:
default_password = os.getenv('ADMIN_PASSWORD', 'admin') default_password = os.getenv('ADMIN_PASSWORD', 'admin')
db.create_user('admin', generate_password_hash(default_password)) db.create_user('admin', generate_password_hash(default_password), is_admin=True)
logger.warning("Created default admin user (password: 'admin' - CHANGE THIS!)") logger.warning("Created default admin user (password: 'admin' - CHANGE THIS!)")
elif not default_user.get('is_admin'):
db.update_user_admin(default_user['id'], True)
logger.info("Updated default admin user with admin privileges")
logger.info("Database initialized successfully") logger.info("Database initialized successfully")
except Exception as e: except Exception as e:
logger.error(f"Database initialization failed: {e}") logger.error(f"Database initialization failed: {e}")
@@ -92,6 +84,34 @@ def login_required(f):
return decorated_function return decorated_function
def admin_required(f):
"""Decorator to require admin privileges."""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('login'))
if not session.get('is_admin'):
flash('Admin access required', 'error')
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function
def get_current_user():
"""Get current logged-in user from database."""
user_id = session.get('user_id')
if not user_id:
return None
return db.get_user_by_id(user_id)
def _sign_in_user(user):
"""Set session keys for authenticated user."""
session['user_id'] = user['id']
session['username'] = user['username']
session['is_admin'] = bool(user.get('is_admin'))
@app.route('/') @app.route('/')
def index(): def index():
"""Redirect to dashboard if logged in, otherwise to login.""" """Redirect to dashboard if logged in, otherwise to login."""
@@ -103,6 +123,9 @@ def index():
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
def login(): def login():
"""Login page.""" """Login page."""
if session.get('totp_pending_user_id'):
return redirect(url_for('login_totp'))
if request.method == 'POST': if request.method == 'POST':
username = request.form.get('username') username = request.form.get('username')
password = request.form.get('password') password = request.form.get('password')
@@ -113,8 +136,14 @@ def login():
user = db.get_user_by_username(username) user = db.get_user_by_username(username)
if user and check_password_hash(user['password_hash'], password): if user and check_password_hash(user['password_hash'], password):
session['user_id'] = user['id'] if user.get('totp_enabled'):
session['username'] = user['username'] # Step 1 complete: require second factor on separate page.
session.clear()
session['totp_pending_user_id'] = user['id']
return redirect(url_for('login_totp'))
session.clear()
_sign_in_user(user)
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
else: else:
flash('Invalid username or password', 'error') flash('Invalid username or password', 'error')
@@ -122,6 +151,43 @@ def login():
return render_template('login.html') return render_template('login.html')
@app.route('/login/totp', methods=['GET', 'POST'])
def login_totp():
"""Second-factor login page for users with TOTP enabled."""
pending_user_id = session.get('totp_pending_user_id')
if not pending_user_id:
return redirect(url_for('login'))
user = db.get_user_by_id(pending_user_id)
if not user or not user.get('totp_enabled'):
session.pop('totp_pending_user_id', None)
flash('TOTP session expired. Please log in again.', 'error')
return redirect(url_for('login'))
if request.method == 'POST':
otp_code = (request.form.get('otp_code') or '').strip().replace(' ', '')
if not otp_code:
flash('TOTP code is required', 'error')
return render_template('login_totp.html', username=user['username'])
totp_secret = user.get('totp_secret')
if not totp_secret:
session.pop('totp_pending_user_id', None)
flash('TOTP is enabled but no secret is configured. Contact an admin.', 'error')
return redirect(url_for('login'))
is_valid = pyotp.TOTP(totp_secret).verify(otp_code, valid_window=1)
if not is_valid:
flash('Invalid TOTP code', 'error')
return render_template('login_totp.html', username=user['username'])
session.clear()
_sign_in_user(user)
return redirect(url_for('dashboard'))
return render_template('login_totp.html', username=user['username'])
@app.route('/logout') @app.route('/logout')
def logout(): def logout():
"""Logout and clear session.""" """Logout and clear session."""
@@ -129,12 +195,204 @@ def logout():
return redirect(url_for('login')) return redirect(url_for('login'))
@app.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
"""Manage current user's profile: username, password, and TOTP."""
user = get_current_user()
if not user:
session.clear()
return redirect(url_for('login'))
if request.method == 'POST':
action = request.form.get('action')
if action == 'update_username':
new_username = (request.form.get('new_username') or '').strip()
if not new_username:
flash('Username is required', 'error')
return redirect(url_for('profile'))
existing = db.get_user_by_username(new_username)
if existing and existing['id'] != user['id']:
flash('That username is already in use', 'error')
return redirect(url_for('profile'))
if db.update_user_username(user['id'], new_username):
session['username'] = new_username
flash('Username updated successfully', 'success')
else:
flash('Failed to update username', 'error')
return redirect(url_for('profile'))
if action == 'update_password':
current_password = request.form.get('current_password') or ''
new_password = request.form.get('new_password') or ''
confirm_password = request.form.get('confirm_password') or ''
if not check_password_hash(user['password_hash'], current_password):
flash('Current password is incorrect', 'error')
return redirect(url_for('profile'))
if len(new_password) < 8:
flash('New password must be at least 8 characters', 'error')
return redirect(url_for('profile'))
if new_password != confirm_password:
flash('New password and confirmation do not match', 'error')
return redirect(url_for('profile'))
if db.update_user_password(user['id'], generate_password_hash(new_password)):
flash('Password updated successfully', 'success')
else:
flash('Failed to update password', 'error')
return redirect(url_for('profile'))
if action == 'generate_totp_secret':
secret = pyotp.random_base32()
if db.update_user_totp(user['id'], secret, False):
flash('Generated a new TOTP secret. Verify a code to enable TOTP.', 'success')
else:
flash('Failed to generate TOTP secret', 'error')
return redirect(url_for('profile'))
if action == 'enable_totp':
otp_code = (request.form.get('otp_code') or '').strip().replace(' ', '')
fresh_user = db.get_user_by_id(user['id'])
totp_secret = fresh_user.get('totp_secret') if fresh_user else None
if not totp_secret:
flash('No TOTP secret found. Generate one first.', 'error')
return redirect(url_for('profile'))
if not pyotp.TOTP(totp_secret).verify(otp_code, valid_window=1):
flash('Invalid TOTP code. Please try again.', 'error')
return redirect(url_for('profile'))
if db.update_user_totp(user['id'], totp_secret, True):
flash('TOTP enabled for your account.', 'success')
else:
flash('Failed to enable TOTP', 'error')
return redirect(url_for('profile'))
if action == 'disable_totp':
if db.update_user_totp(user['id'], None, False):
flash('TOTP disabled.', 'success')
else:
flash('Failed to disable TOTP', 'error')
return redirect(url_for('profile'))
flash('Unknown action', 'error')
return redirect(url_for('profile'))
user = get_current_user()
if user.get('totp_secret'):
totp_uri = pyotp.TOTP(user['totp_secret']).provisioning_uri(
name=user['username'],
issuer_name='OPNsense Backup Manager',
)
else:
totp_uri = None
return render_template('profile.html', user=user, totp_uri=totp_uri)
@app.route('/users')
@login_required
@admin_required
def users():
"""List and manage users."""
all_users = db.get_all_users()
return render_template('users.html', users=all_users)
@app.route('/users/create', methods=['POST'])
@login_required
@admin_required
def create_user():
"""Create a user account."""
username = (request.form.get('username') or '').strip()
password = request.form.get('password') or ''
is_admin = bool(request.form.get('is_admin'))
if not username or not password:
flash('Username and password are required', 'error')
return redirect(url_for('users'))
if len(password) < 8:
flash('Password must be at least 8 characters', 'error')
return redirect(url_for('users'))
if db.get_user_by_username(username):
flash('Username already exists', 'error')
return redirect(url_for('users'))
user_id = db.create_user(username, generate_password_hash(password), is_admin=is_admin)
if user_id:
flash('User created successfully', 'success')
else:
flash('Failed to create user', 'error')
return redirect(url_for('users'))
@app.route('/users/<int:user_id>/toggle-admin', methods=['POST'])
@login_required
@admin_required
def toggle_user_admin(user_id):
"""Toggle admin role for a user."""
target_user = db.get_user_by_id(user_id)
if not target_user:
flash('User not found', 'error')
return redirect(url_for('users'))
all_users = db.get_all_users()
admin_count = sum(1 for u in all_users if u.get('is_admin'))
new_is_admin = not bool(target_user.get('is_admin'))
if not new_is_admin and admin_count <= 1:
flash('Cannot remove admin role from the last admin user', 'error')
return redirect(url_for('users'))
if target_user['id'] == session.get('user_id') and not new_is_admin:
flash('You cannot remove your own admin role', 'error')
return redirect(url_for('users'))
if db.update_user_admin(user_id, new_is_admin):
flash('User admin role updated', 'success')
else:
flash('Failed to update user admin role', 'error')
return redirect(url_for('users'))
@app.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id):
"""Delete a user account."""
if user_id == session.get('user_id'):
flash('You cannot delete your own account', 'error')
return redirect(url_for('users'))
target_user = db.get_user_by_id(user_id)
if not target_user:
flash('User not found', 'error')
return redirect(url_for('users'))
all_users = db.get_all_users()
admin_count = sum(1 for u in all_users if u.get('is_admin'))
if target_user.get('is_admin') and admin_count <= 1:
flash('Cannot delete the last admin user', 'error')
return redirect(url_for('users'))
if db.delete_user(user_id):
flash('User deleted successfully', 'success')
else:
flash('Failed to delete user', 'error')
return redirect(url_for('users'))
@app.route('/dashboard') @app.route('/dashboard')
@login_required @login_required
def dashboard(): def dashboard():
"""Main dashboard.""" """Main dashboard."""
instances = db.get_all_instances() instances = db.get_all_instances()
all_backups = db.get_all_backups() all_backups = db.get_all_backups()
total_backup_size = sum((backup.get('file_size') or 0) for backup in all_backups)
# Add instance info to each backup # Add instance info to each backup
for backup in all_backups: for backup in all_backups:
@@ -143,7 +401,13 @@ def dashboard():
backup['instance_name'] = instance['name'] backup['instance_name'] = instance['name']
backup['instance_identifier'] = instance['identifier'] backup['instance_identifier'] = instance['identifier']
return render_template('dashboard.html', instances=instances, backups=all_backups, sftp_server=sftp_server) return render_template(
'dashboard.html',
instances=instances,
backups=all_backups,
sftp_server=sftp_server,
total_backup_size=total_backup_size
)
@app.route('/instances') @app.route('/instances')
@@ -246,17 +510,74 @@ def instance_detail(instance_id):
@app.route('/backups') @app.route('/backups')
@login_required @login_required
def backups(): def backups():
"""List all backups.""" """List all backups with pagination and filtering."""
all_backups = db.get_all_backups() all_backups = db.get_all_backups()
# Add instance info to each backup # Add instance info to each backup
instances_map = {}
for backup in all_backups: for backup in all_backups:
instance = db.get_instance_by_id(backup['instance_id']) if backup['instance_id'] not in instances_map:
instance = db.get_instance_by_id(backup['instance_id'])
instances_map[backup['instance_id']] = instance
instance = instances_map.get(backup['instance_id'])
if instance: if instance:
backup['instance_name'] = instance['name'] backup['instance_name'] = instance['name']
backup['instance_identifier'] = instance['identifier'] backup['instance_identifier'] = instance['identifier']
return render_template('backups.html', backups=all_backups) # Get unique instances for filter dropdown
all_instances = []
seen_ids = set()
for backup in all_backups:
instance_id = backup['instance_id']
if instance_id not in seen_ids and backup.get('instance_name'):
all_instances.append({
'id': instance_id,
'name': backup['instance_name'],
'identifier': backup['instance_identifier']
})
seen_ids.add(instance_id)
# Sort instances by name
all_instances.sort(key=lambda x: x['name'])
# Get filter parameter from query string
filter_instance_id = request.args.get('instance_id', type=int)
# Filter backups if instance filter is applied
if filter_instance_id:
filtered_backups = [b for b in all_backups if b['instance_id'] == filter_instance_id]
else:
filtered_backups = all_backups
# Sort by upload date descending (newest first)
filtered_backups.sort(key=lambda x: x['uploaded_at'] or datetime.min, reverse=True)
# Pagination
page = request.args.get('page', 1, type=int)
per_page = 10
total_backups = len(filtered_backups)
total_pages = (total_backups + per_page - 1) // per_page
# Ensure page is valid
if page < 1:
page = 1
elif page > total_pages and total_pages > 0:
page = total_pages
# Get backups for current page
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
paginated_backups = filtered_backups[start_idx:end_idx]
return render_template('backups.html',
backups=paginated_backups,
all_instances=all_instances,
current_page=page,
total_pages=total_pages,
total_backups=total_backups,
filter_instance_id=filter_instance_id,
per_page=per_page)
@app.route('/backups/<int:backup_id>/download') @app.route('/backups/<int:backup_id>/download')
@@ -342,6 +663,291 @@ def delete_backup(backup_id):
return redirect(url_for('backups')) return redirect(url_for('backups'))
def prune_backups(
*,
scope_type: str,
scope_instance_id: int | None,
keep_days: int | None,
keep_count: int | None,
) -> dict:
"""
Prune backups by retention rules.
- keep_days: delete backups older than (now - keep_days)
- keep_count: keep N newest backups per instance
"""
if (keep_days is None and keep_count is None) or (keep_days is not None and keep_count is not None):
raise ValueError("Exactly one of keep_days or keep_count must be provided.")
if scope_type not in {"all", "instance"}:
raise ValueError("scope_type must be 'all' or 'instance'.")
instances = []
if scope_type == "instance":
if not scope_instance_id:
raise ValueError("scope_instance_id must be provided when scope_type is 'instance'.")
instance = db.get_instance_by_id(scope_instance_id)
if not instance:
return {"deleted_backups": 0, "deleted_files": 0, "skipped_files": 0, "errors": 1}
instances = [instance]
else:
instances = db.get_all_instances()
now = datetime.now()
cutoff = (now - timedelta(days=keep_days)) if keep_days is not None else None
backups_marked_for_deletion: list[dict] = []
for instance in instances:
backups = db.get_backups_for_instance(instance["id"])
if keep_days is not None:
# db query orders by uploaded_at DESC, but for "older than" it's fine to just filter.
for backup in backups:
uploaded_at = backup.get("uploaded_at")
if uploaded_at is None or uploaded_at < cutoff:
backups_marked_for_deletion.append(backup)
else:
# backups are already sorted DESC; keep first N, prune the rest.
backups_marked_for_deletion.extend(backups[keep_count:])
if not backups_marked_for_deletion:
return {"deleted_backups": 0, "deleted_files": 0, "skipped_files": 0, "errors": 0}
deleted_files = 0
skipped_files = 0
db_ids_to_delete: list[int] = []
for backup in backups_marked_for_deletion:
file_path = Path(backup["file_path"])
try:
if file_path.exists():
file_path.unlink()
deleted_files += 1
else:
# Stale DB rows are still safe to remove.
skipped_files += 1
db_ids_to_delete.append(backup["id"])
except Exception as e:
skipped_files += 1
logger.error(f"Failed deleting backup file {file_path}: {e}", exc_info=True)
deleted_backups = db.delete_backups_by_ids(db_ids_to_delete)
return {
"deleted_backups": deleted_backups,
"deleted_files": deleted_files,
"skipped_files": skipped_files,
"errors": 0,
}
@app.route("/backups/prune", methods=["GET"])
@login_required
def prune_page():
"""Backup pruning manual runner + automated retention settings."""
instances = db.get_all_instances()
prune_settings = db.get_backup_prune_settings()
return render_template("prune.html", instances=instances, prune_settings=prune_settings)
@app.route("/backups/prune/run", methods=["POST"])
@login_required
def prune_run():
"""Run a manual prune based on form criteria."""
if not request.form.get("confirm_prune"):
flash("Confirmation required. Check the confirmation box to prune backups.", "error")
return redirect(url_for("prune_page"))
scope_type = request.form.get("scope_type", "all")
scope_instance_id = request.form.get("scope_instance_id", type=int)
keep_mode = request.form.get("keep_mode")
keep_days = None
keep_count = None
if keep_mode == "days":
keep_days = request.form.get("keep_days", type=int)
elif keep_mode == "count":
keep_count = request.form.get("keep_count", type=int)
else:
flash("Invalid keep mode. Choose either 'Days' or 'Count'.", "error")
return redirect(url_for("prune_page"))
if scope_type == "instance" and not scope_instance_id:
flash("Please select an instance when pruning a single instance.", "error")
return redirect(url_for("prune_page"))
if keep_days is not None and keep_days < 1:
flash("Keep days must be >= 1.", "error")
return redirect(url_for("prune_page"))
if keep_count is not None and keep_count < 1:
flash("Keep count must be >= 1.", "error")
return redirect(url_for("prune_page"))
try:
result = prune_backups(
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_days,
keep_count=keep_count,
)
except Exception as e:
logger.error(f"Manual prune failed: {e}", exc_info=True)
flash(f"Prune failed: {e}", "error")
return redirect(url_for("prune_page"))
flash(
f"Prune complete. Deleted {result['deleted_backups']} backup records. "
f"Deleted {result['deleted_files']} files.",
"success",
)
return redirect(url_for("prune_page"))
@app.route("/backups/prune/settings", methods=["POST"])
@login_required
def prune_settings():
"""Update automated pruning settings (and optionally run immediately)."""
enabled = bool(request.form.get("enabled"))
scope_type = request.form.get("scope_type", "all")
scope_instance_id = request.form.get("scope_instance_id", type=int)
if scope_type == "all":
scope_instance_id = None
keep_mode = request.form.get("keep_mode")
keep_days = None
keep_count = None
if keep_mode == "days":
keep_days = request.form.get("keep_days", type=int)
elif keep_mode == "count":
keep_count = request.form.get("keep_count", type=int)
else:
flash("Invalid keep mode. Choose either 'Days' or 'Count'.", "error")
return redirect(url_for("prune_page"))
interval_hours = request.form.get("interval_hours", type=int) or 24
if interval_hours < 1:
interval_hours = 24
interval_seconds = interval_hours * 3600
if enabled and scope_type == "instance" and not scope_instance_id:
flash("Please select an instance for automated pruning.", "error")
return redirect(url_for("prune_page"))
if keep_days is not None and keep_days < 1:
flash("Keep days must be >= 1.", "error")
return redirect(url_for("prune_page"))
if keep_count is not None and keep_count < 1:
flash("Keep count must be >= 1.", "error")
return redirect(url_for("prune_page"))
db.upsert_backup_prune_settings(
enabled=enabled,
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_days if enabled else None,
keep_count=keep_count if enabled else None,
interval_seconds=interval_seconds,
)
# Optional: run immediately.
if request.form.get("action") == "run_now" and enabled:
try:
prune_backups(
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_days,
keep_count=keep_count,
)
db.set_backup_prune_last_run_at(datetime.now())
except Exception as e:
logger.error(f"Run-now prune failed: {e}", exc_info=True)
flash(f"Saved settings, but run now failed: {e}", "error")
return redirect(url_for("prune_page"))
flash("Saved settings and ran prune now.", "success")
return redirect(url_for("prune_page"))
flash("Automated prune settings saved.", "success")
return redirect(url_for("prune_page"))
def _auto_prune_loop():
"""Background worker that periodically prunes backups based on stored settings."""
while True:
try:
settings = db.get_backup_prune_settings()
enabled = bool(settings.get("enabled"))
interval_seconds = int(settings.get("interval_seconds") or 86400)
if enabled:
keep_days = settings.get("keep_days")
keep_count = settings.get("keep_count")
scope_type = settings.get("scope_type") or "all"
scope_instance_id = settings.get("scope_instance_id")
# Determine keep mode (validated by settings form, but be defensive).
if keep_days is not None and keep_days >= 1 and keep_count is None:
keep_kwargs = {"keep_days": int(keep_days), "keep_count": None}
elif keep_count is not None and keep_count >= 1 and keep_days is None:
keep_kwargs = {"keep_days": None, "keep_count": int(keep_count)}
elif keep_days is not None and keep_days >= 1 and keep_count is not None and keep_count >= 1:
# Prefer days if both are set.
keep_kwargs = {"keep_days": int(keep_days), "keep_count": None}
else:
keep_kwargs = None
last_run_at = settings.get("last_run_at")
should_run = last_run_at is None
if last_run_at is not None:
try:
should_run = (datetime.now() - last_run_at).total_seconds() >= interval_seconds
except Exception:
should_run = True
if keep_kwargs and should_run:
prune_backups(
scope_type=scope_type,
scope_instance_id=scope_instance_id,
keep_days=keep_kwargs["keep_days"],
keep_count=keep_kwargs["keep_count"],
)
db.set_backup_prune_last_run_at(datetime.now())
logger.info(
f"Auto prune ran. scope_type={scope_type}, "
f"scope_instance_id={scope_instance_id}"
)
time.sleep(interval_seconds)
else:
time.sleep(3600)
except Exception as e:
logger.error(f"Auto prune loop error: {e}", exc_info=True)
time.sleep(300)
@app.route('/api/backups/latest')
def api_latest_backups():
"""API endpoint to get the latest backup date and time for each instance."""
latest_backups = db.get_latest_backup_per_instance()
# Format the response
result = []
for item in latest_backups:
result.append({
'instance_id': item['instance_id'],
'instance_name': item['instance_name'],
'latest_backup': item['latest_backup'].isoformat() if item['latest_backup'] else None
})
return jsonify(result)
# Start background auto-prune worker.
threading.Thread(target=_auto_prune_loop, daemon=True).start()
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)
+266 -3
View File
@@ -5,6 +5,7 @@ import mysql.connector
from mysql.connector import Error from mysql.connector import Error
import os import os
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from logger_config import get_logger from logger_config import get_logger
@@ -58,6 +59,19 @@ class Database:
) )
""") """)
# Backward-compatible user auth columns.
cursor.execute("SHOW COLUMNS FROM users LIKE 'is_admin'")
if not cursor.fetchone():
cursor.execute("ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE")
cursor.execute("SHOW COLUMNS FROM users LIKE 'totp_secret'")
if not cursor.fetchone():
cursor.execute("ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) NULL")
cursor.execute("SHOW COLUMNS FROM users LIKE 'totp_enabled'")
if not cursor.fetchone():
cursor.execute("ALTER TABLE users ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE")
# Create opnsense_instances table # Create opnsense_instances table
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS opnsense_instances ( CREATE TABLE IF NOT EXISTS opnsense_instances (
@@ -97,6 +111,32 @@ class Database:
) )
""") """)
# Create backup pruning settings table (single policy row).
cursor.execute("""
CREATE TABLE IF NOT EXISTS backup_prune_settings (
id INT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
scope_type VARCHAR(10) NOT NULL DEFAULT 'all', /* 'all' or 'instance' */
scope_instance_id INT NULL,
keep_days INT NULL,
keep_count INT NULL,
interval_seconds INT NOT NULL DEFAULT 86400,
last_run_at TIMESTAMP NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (scope_instance_id) REFERENCES opnsense_instances(id) ON DELETE SET NULL
)
""")
# Ensure we have exactly one settings row.
cursor.execute("""
INSERT INTO backup_prune_settings
(id, enabled, scope_type, scope_instance_id, keep_days, keep_count, interval_seconds, last_run_at)
VALUES
(1, FALSE, 'all', NULL, NULL, NULL, 86400, NULL)
ON DUPLICATE KEY UPDATE
id = id
""")
conn.commit() conn.commit()
cursor.close() cursor.close()
logger.info("Database schema initialized successfully") logger.info("Database schema initialized successfully")
@@ -104,14 +144,14 @@ class Database:
logger.error(f"Error initializing database: {e}") logger.error(f"Error initializing database: {e}")
raise raise
def create_user(self, username: str, password_hash: str) -> Optional[int]: def create_user(self, username: str, password_hash: str, is_admin: bool = False) -> Optional[int]:
"""Create a new user.""" """Create a new user."""
try: try:
with self.get_connection() as conn: with self.get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"INSERT INTO users (username, password_hash) VALUES (%s, %s)", "INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, %s)",
(username, password_hash) (username, password_hash, is_admin)
) )
conn.commit() conn.commit()
user_id = cursor.lastrowid user_id = cursor.lastrowid
@@ -134,6 +174,106 @@ class Database:
logger.error(f"Error getting user: {e}") logger.error(f"Error getting user: {e}")
return None return None
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Get user by ID."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
user = cursor.fetchone()
cursor.close()
return user
except Error as e:
logger.error(f"Error getting user by id: {e}")
return None
def get_all_users(self) -> List[Dict[str, Any]]:
"""Get all users."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT id, username, is_admin, totp_enabled, created_at
FROM users
ORDER BY created_at ASC
"""
)
users = cursor.fetchall()
cursor.close()
return users
except Error as e:
logger.error(f"Error getting users: {e}")
return []
def update_user_username(self, user_id: int, username: str) -> bool:
"""Update username for a user."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE users SET username = %s WHERE id = %s", (username, user_id))
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error updating username: {e}")
return False
def update_user_password(self, user_id: int, password_hash: str) -> bool:
"""Update password hash for a user."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, user_id))
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error updating password hash: {e}")
return False
def update_user_totp(self, user_id: int, totp_secret: Optional[str], totp_enabled: bool) -> bool:
"""Update TOTP settings for a user."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET totp_secret = %s, totp_enabled = %s WHERE id = %s",
(totp_secret, totp_enabled, user_id),
)
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error updating TOTP settings: {e}")
return False
def update_user_admin(self, user_id: int, is_admin: bool) -> bool:
"""Update admin flag for a user."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE users SET is_admin = %s WHERE id = %s", (is_admin, user_id))
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error updating user admin flag: {e}")
return False
def delete_user(self, user_id: int) -> bool:
"""Delete user by ID."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
conn.commit()
cursor.close()
return True
except Error as e:
logger.error(f"Error deleting user: {e}")
return False
def create_instance(self, name: str, identifier: str, ssh_key_id: str, description: str = "") -> Optional[int]: def create_instance(self, name: str, identifier: str, ssh_key_id: str, description: str = "") -> Optional[int]:
"""Create a new OPNsense instance.""" """Create a new OPNsense instance."""
try: try:
@@ -280,3 +420,126 @@ class Database:
logger.error(f"Error getting instance by ID: {e}") logger.error(f"Error getting instance by ID: {e}")
return None return None
def get_latest_backup_per_instance(self) -> List[Dict[str, Any]]:
"""Get the latest backup date and time for each instance."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT
o.id as instance_id,
o.name as instance_name,
o.identifier as instance_identifier,
MAX(b.uploaded_at) as latest_backup
FROM opnsense_instances o
LEFT JOIN backups b ON o.id = b.instance_id
GROUP BY o.id, o.name, o.identifier
ORDER BY o.name
""")
results = cursor.fetchall()
cursor.close()
return results
except Error as e:
logger.error(f"Error getting latest backup per instance: {e}")
return []
def get_backup_prune_settings(self) -> Dict[str, Any]:
"""Get automated backup prune settings (single row)."""
try:
with self.get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM backup_prune_settings WHERE id = %s", (1,))
row = cursor.fetchone()
cursor.close()
if row:
return row
except Error as e:
logger.error(f"Error getting backup prune settings: {e}")
# Safe defaults if table/row doesn't exist yet.
return {
"id": 1,
"enabled": False,
"scope_type": "all",
"scope_instance_id": None,
"keep_days": None,
"keep_count": None,
"interval_seconds": 86400,
"last_run_at": None,
"updated_at": None,
}
def upsert_backup_prune_settings(
self,
enabled: bool,
scope_type: str,
scope_instance_id: Optional[int],
keep_days: Optional[int],
keep_count: Optional[int],
interval_seconds: int,
) -> None:
"""Upsert automated backup prune settings (single row)."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO backup_prune_settings
(id, enabled, scope_type, scope_instance_id, keep_days, keep_count, interval_seconds)
VALUES
(1, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
scope_type = VALUES(scope_type),
scope_instance_id = VALUES(scope_instance_id),
keep_days = VALUES(keep_days),
keep_count = VALUES(keep_count),
interval_seconds = VALUES(interval_seconds)
""",
(
bool(enabled),
scope_type,
scope_instance_id,
keep_days,
keep_count,
interval_seconds,
),
)
conn.commit()
cursor.close()
except Error as e:
logger.error(f"Error updating backup prune settings: {e}")
def set_backup_prune_last_run_at(self, last_run_at: datetime) -> None:
"""Update last_run_at after a prune run."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE backup_prune_settings SET last_run_at = %s WHERE id = %s",
(last_run_at, 1),
)
conn.commit()
cursor.close()
except Error as e:
logger.error(f"Error updating backup prune last_run_at: {e}")
def delete_backups_by_ids(self, backup_ids: List[int]) -> int:
"""Delete backup records by IDs (returns number of deleted rows)."""
if not backup_ids:
return 0
try:
placeholders = ", ".join(["%s"] * len(backup_ids))
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
f"DELETE FROM backups WHERE id IN ({placeholders})",
tuple(backup_ids),
)
affected = cursor.rowcount or 0
conn.commit()
cursor.close()
return affected
except Error as e:
logger.error(f"Error deleting backups by ids: {e}")
return 0
+12 -12
View File
@@ -15,16 +15,16 @@ spec:
spec: spec:
containers: containers:
- name: opnsense-sftp - name: opnsense-sftp
image: ghcr.io/jdb-net/opnsense-sftp:latest image: cr.jdbnet.co.uk/public/opnsense-sftp:latest
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 5000 - containerPort: 5000
name: "opnsense-sftp" name: "opnsense-web"
- containerPort: 2222 - containerPort: 2222
name: "opnsense-sftp-sftp" name: "opnsense-sftp"
env: env:
- name: DB_HOST - name: DB_HOST
value: "10.10.2.27" value: "10.10.25.4"
- name: DB_PORT - name: DB_PORT
value: "3306" value: "3306"
- name: DB_NAME - name: DB_NAME
@@ -38,7 +38,7 @@ spec:
- name: ADMIN_PASSWORD - name: ADMIN_PASSWORD
value: "CVk7QKIB3MjZ8mt6MxES" value: "CVk7QKIB3MjZ8mt6MxES"
- name: SFTP_PUBLIC_HOST - name: SFTP_PUBLIC_HOST
value: "10.10.2.29" value: "10.10.25.8"
- name: SFTP_PUBLIC_PORT - name: SFTP_PUBLIC_PORT
value: "30222" value: "30222"
volumeMounts: volumeMounts:
@@ -49,17 +49,17 @@ spec:
volumes: volumes:
- name: keys-volume - name: keys-volume
nfs: nfs:
server: 10.10.2.5 server: 10.10.25.2
path: /srv/Backups/OPNsense/keys path: /srv/k3s/opnsense/keys
- name: backups-volume - name: backups-volume
nfs: nfs:
server: 10.10.2.5 server: 10.10.25.2
path: /srv/Backups/OPNsense/backups path: /srv/k3s/opnsense/backups
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: opnsense-sftp-ingress-service name: opnsense-web-ingress-service
namespace: opnsense-sftp namespace: opnsense-sftp
spec: spec:
selector: selector:
@@ -73,7 +73,7 @@ spec:
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: opnsense-sftp-sftp-service name: opnsense-sftp-service
namespace: opnsense-sftp namespace: opnsense-sftp
spec: spec:
type: NodePort type: NodePort
@@ -100,7 +100,7 @@ spec:
path: "/" path: "/"
backend: backend:
service: service:
name: opnsense-sftp-ingress-service name: opnsense-web-ingress-service
port: port:
number: 80 number: 80
--- ---
+1
View File
@@ -5,3 +5,4 @@ mysql-connector-python
cryptography cryptography
werkzeug werkzeug
python-dotenv python-dotenv
pyotp
+92 -2
View File
@@ -9,9 +9,40 @@
<p class="text-neutral-400">View and download your backup files</p> <p class="text-neutral-400">View and download your backup files</p>
</div> </div>
<!-- Filter Section -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-lg font-bold text-neutral-100 mb-4">Filters</h2>
<form method="get" class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<label for="instance_id" class="block text-sm font-medium text-neutral-300 mb-2">Instance</label>
<select id="instance_id" name="instance_id" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
<option value="">All Instances</option>
{% for instance in all_instances %}
<option value="{{ instance.id }}" {% if filter_instance_id == instance.id %}selected{% endif %}>
{{ instance.name }}
</option>
{% endfor %}
</select>
</div>
<div class="flex gap-2 sm:self-end">
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Apply Filter
</button>
{% if filter_instance_id %}
<a href="{{ url_for('backups') }}" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors">
Clear Filter
</a>
{% endif %}
</div>
</form>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden"> <div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-700"> <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">Backup Files</h2> <h2 class="text-xl font-bold text-neutral-100">Backup Files</h2>
{% if total_backups > 0 %}
<span class="text-sm text-neutral-400">{{ total_backups }} backup{% if total_backups != 1 %}s{% endif %}</span>
{% endif %}
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
@@ -51,7 +82,11 @@
{% else %} {% else %}
<tr> <tr>
<td colspan="5" class="px-6 py-8 text-center text-neutral-400"> <td colspan="5" class="px-6 py-8 text-center text-neutral-400">
No backups found. Configure your OPNsense instances to start receiving backups. {% if filter_instance_id %}
No backups found for the selected instance.
{% else %}
No backups found. Configure your OPNsense instances to start receiving backups.
{% endif %}
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
@@ -59,6 +94,61 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="text-sm text-neutral-400">
Page <span class="font-bold text-neutral-100">{{ current_page }}</span> of <span class="font-bold text-neutral-100">{{ total_pages }}</span>
({{ (current_page - 1) * per_page + 1 }}-{{ [current_page * per_page, total_backups]|min }} of {{ total_backups }} backups)
</div>
<div class="flex gap-2 flex-wrap justify-center">
{% if current_page > 1 %}
<a href="{{ url_for('backups', page=1, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
First
</a>
<a href="{{ url_for('backups', page=current_page - 1, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
Previous
</a>
{% endif %}
<!-- Page Numbers -->
{% set start_page = [current_page - 2, 1]|max %}
{% set end_page = [current_page + 2, total_pages]|min %}
{% if start_page > 1 %}
<span class="px-3 py-2 text-neutral-400">...</span>
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
{% if page_num == current_page %}
<button class="px-3 py-2 bg-orange-600 text-white rounded-md text-sm font-medium">
{{ page_num }}
</button>
{% else %}
<a href="{{ url_for('backups', page=page_num, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
{{ page_num }}
</a>
{% endif %}
{% endfor %}
{% if end_page < total_pages %}
<span class="px-3 py-2 text-neutral-400">...</span>
{% endif %}
{% if current_page < total_pages %}
<a href="{{ url_for('backups', page=current_page + 1, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
Next
</a>
<a href="{{ url_for('backups', page=total_pages, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
Last
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
+69 -6
View File
@@ -7,18 +7,19 @@
<link rel="icon" type="image/png" href="{{ url_for('static', filename='opnsense.png') }}"> <link rel="icon" type="image/png" href="{{ url_for('static', filename='opnsense.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
</head> </head>
<body class="bg-neutral-900 text-neutral-100 min-h-screen"> <body class="bg-neutral-900 text-neutral-100 min-h-screen flex flex-col">
<nav class="bg-neutral-800 border-b border-neutral-700"> <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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="flex justify-between h-16">
<div class="flex items-center"> <div class="flex items-center">
<a href="{{ url_for('dashboard') }}" class="flex items-center space-x-4"> <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"> <img src="{{ url_for('static', filename='opnsense.png') }}" alt="OPNsense" class="h-8 w-8">
<span class="text-neutral-400">Backup Manager</span> <span class="text-neutral-400 hidden sm:inline">Backup Manager</span>
</a> </a>
</div> </div>
{% if session.user_id %} {% if session.user_id %}
<div class="flex items-center space-x-4"> <!-- Desktop Navigation -->
<div class="hidden md: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"> <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 Dashboard
</a> </a>
@@ -28,18 +29,68 @@
<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"> <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 Backups
</a> </a>
<a href="{{ url_for('prune_page') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Prune
</a>
{% if session.is_admin %}
<a href="{{ url_for('users') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Users
</a>
{% endif %}
<span class="text-neutral-400">|</span> <span class="text-neutral-400">|</span>
<span class="text-neutral-300">{{ session.username }}</span> <a href="{{ url_for('profile') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
{{ session.username }}
</a>
<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"> <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 Logout
</a> </a>
</div> </div>
<!-- Mobile Menu Button -->
<div class="md:hidden flex items-center">
<button id="mobile-menu-btn" class="inline-flex items-center justify-center p-2 rounded-md text-neutral-300 hover:text-orange-500 hover:bg-neutral-700 focus:outline-none transition-colors">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
{% endif %} {% endif %}
</div> </div>
<!-- Mobile Navigation Menu -->
{% if session.user_id %}
<div id="mobile-menu" class="hidden md:hidden pb-4 space-y-1">
<a href="{{ url_for('dashboard') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Dashboard
</a>
<a href="{{ url_for('instances') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Instances
</a>
<a href="{{ url_for('backups') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Backups
</a>
<a href="{{ url_for('prune_page') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Prune
</a>
{% if session.is_admin %}
<a href="{{ url_for('users') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Users
</a>
{% endif %}
<div class="border-t border-neutral-700 pt-2 mt-2">
<a href="{{ url_for('profile') }}" class="block px-3 py-2 text-sm text-neutral-400 hover:text-orange-500 rounded-md transition-colors hover:bg-neutral-700">
{{ session.username }}
</a>
<a href="{{ url_for('logout') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
Logout
</a>
</div>
</div>
{% endif %}
</div> </div>
</nav> </nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main class="flex-grow max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<div class="mb-6 space-y-2"> <div class="mb-6 space-y-2">
@@ -60,11 +111,23 @@
<p class="text-center text-neutral-400 text-sm"> <p class="text-center text-neutral-400 text-sm">
OPNsense Backup Manager OPNsense Backup Manager
{% if version %} {% if version %}
<span class="text-neutral-500">v{{ version }}</span> <span class="text-neutral-500">{{ version }}</span>
{% endif %} {% endif %}
</p> </p>
</div> </div>
</footer> </footer>
<script>
// Mobile menu toggle
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
const mobileMenu = document.getElementById('mobile-menu');
if (mobileMenuBtn && mobileMenu) {
mobileMenuBtn.addEventListener('click', function() {
mobileMenu.classList.toggle('hidden');
});
}
</script>
</body> </body>
</html> </html>
+25 -1
View File
@@ -10,7 +10,7 @@
</div> </div>
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700"> <div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -39,6 +39,30 @@
</div> </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 Backup Size</p>
<p class="text-3xl font-bold text-orange-500 mt-2">
{% if total_backup_size >= 1024 * 1024 * 1024 %}
{{ "%.2f"|format(total_backup_size / 1024 / 1024 / 1024) }} GB
{% elif total_backup_size >= 1024 * 1024 %}
{{ "%.2f"|format(total_backup_size / 1024 / 1024) }} MB
{% elif total_backup_size >= 1024 %}
{{ "%.2f"|format(total_backup_size / 1024) }} KB
{% else %}
{{ total_backup_size }} B
{% endif %}
</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="M4 7h16M4 12h16m-7 5h7"></path>
</svg>
</div>
</div>
</div>
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700"> <div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
+1 -8
View File
@@ -42,18 +42,11 @@
<button <button
type="submit" 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" class="w-full bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 hover:cursor-pointer">
>
Sign In Sign In
</button> </button>
</form> </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> </div>
</div> </div>
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Two-Factor Verification - 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">Two-Factor Verification</h1>
<p class="text-neutral-400">Enter the code from your authenticator app.</p>
{% if username %}
<p class="text-sm text-neutral-500 mt-2">Signing in as <span class="text-neutral-300">{{ username }}</span></p>
{% endif %}
</div>
<form method="POST" action="{{ url_for('login_totp') }}" class="space-y-6">
<div>
<label for="otp_code" class="block text-sm font-medium text-neutral-300 mb-2">
TOTP Code
</label>
<input
type="text"
id="otp_code"
name="otp_code"
required
inputmode="numeric"
autocomplete="one-time-code"
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="6-digit code"
>
</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 hover:cursor-pointer">
Verify and Sign In
</button>
</form>
<div class="mt-6 text-center">
<a href="{{ url_for('logout') }}" class="text-sm text-neutral-400 hover:text-orange-500 transition-colors">
Cancel and return to login
</a>
</div>
</div>
</div>
</div>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Profile - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Profile</h1>
<p class="text-neutral-400">Manage your username, password, and multi-factor authentication.</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-xl font-bold text-neutral-100 mb-4">Change Username</h2>
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
<input type="hidden" name="action" value="update_username">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Current Username</label>
<input type="text" disabled value="{{ user.username }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-400">
</div>
<div>
<label for="new_username" class="block text-sm font-medium text-neutral-300 mb-2">New Username</label>
<input id="new_username" name="new_username" type="text" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">Update Username</button>
</form>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-xl font-bold text-neutral-100 mb-4">Change Password</h2>
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
<input type="hidden" name="action" value="update_password">
<div>
<label for="current_password" class="block text-sm font-medium text-neutral-300 mb-2">Current Password</label>
<input id="current_password" name="current_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div>
<label for="new_password" class="block text-sm font-medium text-neutral-300 mb-2">New Password</label>
<input id="new_password" name="new_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-neutral-300 mb-2">Confirm New Password</label>
<input id="confirm_password" name="confirm_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">Update Password</button>
</form>
</div>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6 space-y-5">
<h2 class="text-xl font-bold text-neutral-100">Two-Factor Authentication (TOTP)</h2>
<p class="text-neutral-400 text-sm">
Use an authenticator app (Aegis, Authy, 1Password, Google Authenticator, etc.) to secure your account.
</p>
{% if user.totp_enabled %}
<div class="bg-green-900/30 border border-green-700 rounded-md p-4 text-sm text-green-300">
TOTP is currently enabled for your account.
</div>
<form method="post" action="{{ url_for('profile') }}">
<input type="hidden" name="action" value="disable_totp">
<button type="submit" class="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Disable TOTP
</button>
</form>
{% else %}
{% if not user.totp_secret %}
<form method="post" action="{{ url_for('profile') }}">
<input type="hidden" name="action" value="generate_totp_secret">
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Generate TOTP Secret
</button>
</form>
{% else %}
<div class="bg-neutral-900 border border-neutral-700 rounded-md p-4 space-y-2">
<p class="text-sm text-neutral-300"><strong>Scan QR Code:</strong></p>
<div id="totp-qrcode" class="bg-white p-2 rounded-md inline-block"></div>
<p class="text-sm text-neutral-300"><strong>Secret:</strong> <code class="text-orange-400">{{ user.totp_secret }}</code></p>
<p class="text-sm text-neutral-300"><strong>OTPAuth URI:</strong></p>
<code id="totp-uri" class="text-xs text-orange-400 break-all block">{{ totp_uri }}</code>
</div>
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
<input type="hidden" name="action" value="enable_totp">
<div>
<label for="otp_code" class="block text-sm font-medium text-neutral-300 mb-2">Enter a code from your authenticator app</label>
<input id="otp_code" name="otp_code" type="text" inputmode="numeric" required class="w-full max-w-xs px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors" placeholder="123456">
</div>
<div class="flex gap-3">
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Verify and Enable
</button>
<button formaction="{{ url_for('profile') }}" name="action" value="generate_totp_secret" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Regenerate Secret
</button>
</div>
</form>
{% endif %}
{% endif %}
</div>
</div>
{% if totp_uri %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
(function() {
const qrContainer = document.getElementById('totp-qrcode');
const uriElement = document.getElementById('totp-uri');
if (!qrContainer || !uriElement) return;
const otpUri = uriElement.textContent.trim();
if (!otpUri) return;
new QRCode(qrContainer, {
text: otpUri,
width: 192,
height: 192,
correctLevel: QRCode.CorrectLevel.M
});
})();
</script>
{% endif %}
{% endblock %}
+184
View File
@@ -0,0 +1,184 @@
{% extends "base.html" %}
{% block title %}Prune Backups - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Prune Backups</h1>
<p class="text-neutral-400">Delete old backups manually or configure automated retention.</p>
</div>
<!-- Manual Pruning -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-neutral-100">Manual Pruning</h2>
<span class="text-sm text-neutral-400">Runs immediately</span>
</div>
<form method="post" action="{{ url_for('prune_run') }}" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Scope</label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="scope_type" value="all" checked>
All Instances
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="scope_type" value="instance">
Single Instance
</label>
</div>
<div class="mt-3">
<label for="scope_instance_id" class="block text-sm font-medium text-neutral-300 mb-2">Instance (used when Single Instance)</label>
<select id="scope_instance_id" name="scope_instance_id" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
<option value="">Select instance</option>
{% for instance in instances %}
<option value="{{ instance.id }}">{{ instance.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Retention Rule</label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="keep_mode" value="days" checked>
Keep by Days
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="keep_mode" value="count">
Keep by Count
</label>
</div>
<div class="mt-3 space-y-3">
<div>
<label for="keep_days" class="block text-sm font-medium text-neutral-300 mb-2">Days of backups to keep</label>
<input id="keep_days" name="keep_days" type="number" min="1" value="14" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div>
<label for="keep_count" class="block text-sm font-medium text-neutral-300 mb-2">Backups to keep per instance (newest first)</label>
<input id="keep_count" name="keep_count" type="number" min="1" value="10" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div class="text-xs text-neutral-400">
If you choose <strong>All Instances</strong>, count-based retention keeps the newest <em>N</em> backups <strong>per instance</strong>.
</div>
</div>
</div>
</div>
<div class="bg-neutral-900/40 border border-neutral-700 rounded-md p-4">
<label class="flex items-start gap-3 text-sm text-neutral-300">
<input type="checkbox" name="confirm_prune" class="mt-1">
<span>
I understand this will permanently delete backup files and their database records.
Only proceed if you're sure about the retention settings.
</span>
</label>
</div>
<div class="flex justify-end">
<button type="submit" class="px-5 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Prune Now
</button>
</div>
</form>
</div>
<!-- Automated Pruning -->
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-neutral-100">Automated Pruning</h2>
<span class="text-sm text-neutral-400">Runs in the background</span>
</div>
<form method="post" action="{{ url_for('prune_settings') }}" class="space-y-6">
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="checkbox" name="enabled" {% if prune_settings.enabled %}checked{% endif %}>
Enable automated pruning
</label>
<div class="text-xs text-neutral-400">
Interval is <strong>hours</strong>. Default: once per day.
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Scope</label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="scope_type" value="all" {% if prune_settings.scope_type != 'instance' %}checked{% endif %}>
All Instances
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="scope_type" value="instance" {% if prune_settings.scope_type == 'instance' %}checked{% endif %}>
Single Instance
</label>
</div>
<div class="mt-3">
<label for="auto_scope_instance_id" class="block text-sm font-medium text-neutral-300 mb-2">Instance (used when Single Instance)</label>
<select id="auto_scope_instance_id" name="scope_instance_id" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
<option value="">Select instance</option>
{% for instance in instances %}
<option value="{{ instance.id }}" {% if prune_settings.scope_instance_id == instance.id %}selected{% endif %}>
{{ instance.name }}
</option>
{% endfor %}
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Retention Rule</label>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="keep_mode" value="days" {% if prune_settings.keep_days is not none or (prune_settings.keep_days is none and prune_settings.keep_count is none) %}checked{% endif %}>
Keep by Days
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="radio" name="keep_mode" value="count" {% if prune_settings.keep_count is not none %}checked{% endif %}>
Keep by Count
</label>
</div>
<div class="mt-3 space-y-3">
<div>
<label for="auto_keep_days" class="block text-sm font-medium text-neutral-300 mb-2">Days of backups to keep</label>
<input id="auto_keep_days" name="keep_days" type="number" min="1" value="{{ prune_settings.keep_days if prune_settings.keep_days is not none else 14 }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div>
<label for="auto_keep_count" class="block text-sm font-medium text-neutral-300 mb-2">Backups to keep per instance (newest first)</label>
<input id="auto_keep_count" name="keep_count" type="number" min="1" value="{{ prune_settings.keep_count if prune_settings.keep_count is not none else 10 }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div class="mt-3">
<label for="interval_hours" class="block text-sm font-medium text-neutral-300 mb-2">Run interval (hours)</label>
<input id="interval_hours" name="interval_hours" type="number" min="1" value="{{ (prune_settings.interval_seconds // 3600) if prune_settings.interval_seconds is not none else 24 }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
</div>
</div>
</div>
{% if prune_settings.last_run_at %}
<div class="text-sm text-neutral-400">
Last automated prune run: <span class="text-neutral-300 font-medium">{{ prune_settings.last_run_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
{% endif %}
<div class="flex justify-end gap-3 flex-wrap">
<button type="submit" name="action" value="run_now" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Save & Run Now
</button>
<button type="submit" class="px-5 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Save Settings
</button>
</div>
</form>
</div>
</div>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}Users - OPNsense Backup Manager{% endblock %}
{% block content %}
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Users</h1>
<p class="text-neutral-400">Create and manage user accounts.</p>
</div>
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
<h2 class="text-xl font-bold text-neutral-100 mb-4">Create User</h2>
<form method="post" action="{{ url_for('create_user') }}" class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div class="md:col-span-1">
<label for="username" class="block text-sm font-medium text-neutral-300 mb-2">Username</label>
<input id="username" name="username" type="text" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div class="md:col-span-2">
<label for="password" class="block text-sm font-medium text-neutral-300 mb-2">Password</label>
<input id="password" name="password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
</div>
<div class="md:col-span-1 flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-neutral-300">
<input type="checkbox" name="is_admin">
Admin
</label>
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
Create
</button>
</div>
</form>
</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">Existing Users</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">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Role</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">TOTP</th>
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Created</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">
{% for user in users %}
<tr class="hover:bg-neutral-700/50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-100">{{ user.username }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-300">
{% if user.is_admin %}Admin{% else %}User{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-300">
{% if user.totp_enabled %}Enabled{% else %}Disabled{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400">
{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'N/A' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div class="flex gap-4">
<form method="post" action="{{ url_for('toggle_user_admin', user_id=user.id) }}">
<button type="submit" class="text-orange-500 hover:text-orange-400 hover:cursor-pointer">
{% if user.is_admin %}Remove Admin{% else %}Make Admin{% endif %}
</button>
</form>
<form method="post" action="{{ url_for('delete_user', user_id=user.id) }}" onsubmit="return confirm('Delete user {{ user.username }}?');">
<button type="submit" class="text-red-500 hover:text-red-400 hover:cursor-pointer">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}