Initial commit

This commit is contained in:
tronosfera 2026-01-19 10:27:20 +03:00
commit 71a5fdb142
24 changed files with 3285 additions and 0 deletions

65
.env.example Normal file
View file

@ -0,0 +1,65 @@
# Example environment variables for the backup service
#
# Copy this file to `.env` and adjust the values as needed. Both the
# server and client read configuration from environment variables. When
# using Docker Compose you can reference this file via the `env_file`
# directive or set variables in the compose file directly.
# -----------------------------------------------------------------------------
# Server configuration
# -----------------------------------------------------------------------------
SECRET_KEY=mysecretkey
# Use Postgres instead of SQLite. This DSN is used by SQLAlchemy. If you
# leave it empty or omit it, the server will fall back to a local SQLite
# database stored in `/app/backup.db`.
DATABASE_URL=postgresql+psycopg2://backup:backup@db:5432/backup
# Configure S3 storage. When S3_BUCKET is defined the server will use
# Amazon S3 (or any S3-compatible service) to store backup files. If
# omitted, files are stored on the local filesystem under `/app/data`.
S3_BUCKET=backup
AWS_ACCESS_KEY_ID=minio
AWS_SECRET_ACCESS_KEY=minio123
AWS_REGION=us-east-1
S3_ENDPOINT=http://minio:9000
# -----------------------------------------------------------------------------
# Client configuration
# -----------------------------------------------------------------------------
# URL of the backup server. For local deployments this will typically be
# `http://server:8000` when using Docker Compose. When running the
# client independently, set this to the public address of your server.
SERVER_URL=http://server:8000
# Credentials of a user with permission to register new clients on the server.
USERNAME=admin
PASSWORD=adminpass
# Optional humanreadable name for this client. Defaults to the hostname.
CLIENT_NAME=example-client
# Commaseparated list of directories to back up. The client will walk
# these directories recursively and upload files that have changed since
# the last backup.
MONITORED_PATHS=/data
# Semicolonseparated list of shell commands to run before each backup cycle.
# This can be used, for example, to dump a PostgreSQL database. Leave empty
# if not needed.
PRE_COMMANDS=
# Seconds between ping requests to the server. Pings update the last
# heartbeat timestamp for the client in the server UI.
PING_INTERVAL=300
# Seconds between full backup scans. Lower this value to increase the
# frequency of backups.
BACKUP_INTERVAL=3600
# Enable or disable the client web interface. When set to `false`, the
# client does not launch the configuration UI and requires all mandatory
# variables (SERVER_URL, USERNAME, PASSWORD and MONITORED_PATHS) to be
# provided. When `true` (default), the client starts a small web server on
# port 8080 to collect configuration interactively.
CLIENT_UI_ENABLED=true

160
README.md Normal file
View file

@ -0,0 +1,160 @@
# Backup Service
This repository contains a simple backup system composed of a server and a
client. The service was designed to meet the requirements outlined in the
problem statement: it provides a REST API for storing and retrieving
deduplicated backups for multiple users, a web interface for administrators to
monitor clients, configurable retention policies (by age or version count),
support for both local filesystem storage and Amazon S3, and a Dockerised
client that periodically uploads files and runs prebackup commands.
## Architecture Overview
The system is split into two components:
1. **Server** (`./server`)
* Built with [FastAPI](https://fastapi.tiangolo.com/) and SQLAlchemy.
* Stores user accounts, clients, deduplicated file hashes, backup records
and client logs in a relational database.
* Uses SHA256 to detect duplicate uploads and stores each unique file only
once. A keyvalue style table (`file_hashes`) maps the content hash to
the storage path【744670406339295†L270-L339】.
* Supports local filesystem storage or S3. When the `S3_BUCKET`
environment variable is set, files are uploaded to S3; otherwise they are
saved under `./data`. S3 lifecycle rules can be used to automatically
expire old versions of objects【17949889377376†L188-L219】.
* Implements retention policies on a peruser basis. Administrators can
specify either a maximum number of versions or a maximum age (in days);
when a new backup is uploaded, older versions outside the policy are
pruned, preserving only the latest copy【709290716836410†L142-L159】.
* Provides a minimal HTML dashboard (`/`) displaying clients, their tokens,
and last ping/backup times. Forms for creating users and clients are
included as a starting point.
2. **Client** (`./client`)
* Written in Python and runs continuously inside a container.
* Authenticates to the server using credentials provided via environment
variables, registers itself to obtain a unique client token, then
periodically sends pings and backups.
* Recursively scans directories listed in `MONITORED_PATHS`, computes
SHA256 hashes of each file and uploads only those that have changed
since the previous run. This reduces network and storage overhead while
still allowing the server to deduplicate identical content.
* Supports optional `PRE_COMMANDS` that run before each backup cycle. This
feature can be used to generate database dumps (e.g. running
`pg_dump`) or any other preparatory work.
* Sends log messages to the server when errors occur to aid debugging.
### Web Interface and Configuration
The server now includes a simple but more complete web interface built with
Jinja2 templates:
* `/clients` lists all registered clients with last ping/backup times and
displays the prebackup commands configured for each client. It includes
forms to create new users and new clients.
* `/clients/{id}` shows details for a specific client. Administrators can
edit the **prebackup commands** for that client using a multiline text
area. The page also lists recent backups (with download links) and the
last 50 log messages.
Behind the scenes, prebackup commands are stored in the client record in
the database. Clients call `/api/clients/{token}/config` to retrieve their
commands before each backup cycle. This allows administrators to update
backup behaviour centrally without redeploying clients.
### Client Web Interface
In addition to the server dashboard, the backup **client** offers its own
minimal web interface. When the client container starts it opens a small
FastAPI application on port **8080** that presents a form where you can
enter the server URL, username and password for registration, an optional
client name, and the directories to monitor for backups. Once you submit
the form the client stores the configuration, registers itself with the
server, and begins running backup cycles automatically. A confirmation
page provides a direct link to the new client's page on the server.
This interface is enabled by default to make configuration easy. You can
disable it by setting the environment variable `CLIENT_UI_ENABLED=false`.
When the UI is disabled the client does not launch the HTTP server and will
exit if mandatory environment variables (`SERVER_URL`, `USERNAME`,
`PASSWORD` and `MONITORED_PATHS`) are missing. Use a `.env` file (see
`.env.example`) or set environment variables in your compose file to
configure the client noninteractively.
## Deployment with Docker Compose
Rather than a single allinone compose file, the repository now provides
three Compose configurations to support a variety of deployment scenarios:
| Compose file | Description |
|------------------------------|-----------------------------------------------------------------------------------|
| **`docker-compose.yml`** | Launches the server, PostgreSQL, MinIO and a client in one stack. Useful for
| | local testing or demonstration where all components run on the same host. |
| **`docker-compose.server.yml`** | Starts only the server stack (FastAPI app, database and MinIO). Use this when
| | deploying the server to a dedicated host or cloud. |
| **`docker-compose.client.yml`** | Runs just the client container. Use this to deploy the backup agent on a
| | separate machine and point it at your existing server. The client exposes
| | port 8080 for its configuration UI. |
To run the allinone configuration:
```bash
cd backup_service
docker compose up --build
```
To run just the server or just the client, specify the appropriate compose file:
```bash
docker compose -f docker-compose.server.yml up --build
```
and, on a different host or in a separate terminal:
```bash
docker compose -f docker-compose.client.yml up --build
```
The default server configuration uses SQLite for simplicity, storing files in
a volume mounted at `/app/data`. The provided compose files demonstrate how
to switch to PostgreSQL and MinIO by setting `DATABASE_URL`, `S3_BUCKET`,
`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` and `S3_ENDPOINT`.
Consult the comments in the compose files and the included `.env.example`
for guidance. The client container mounts a volume called `client_data` at
`/data`; any files placed in this directory will be backed up. You can
configure the client by editing environment variables in the compose file,
by supplying a `.env` file, or via the builtin web interface on port 8080.
## Usage Notes
* Before starting the client you must create a user on the server. One way
to do this is to run the server, visit `http://localhost:8000` in a
browser, authenticate using a token from `/api/login`, and use the “Create
User” form. Alternatively, you can call the `/api/register_user` endpoint
directly using a bearer token from an existing admin.
* Ensure that the retention policies set on each user reflect your backup
strategy. For example, specifying `retention_versions=5` keeps the five
most recent versions of each file; specifying `retention_days=30` retains
versions from the last 30 days【709290716836410†L142-L159】.
* When using S3, consider configuring lifecycle rules to automatically expire
old objects or transition them to cheaper storage classes. S3 lifecycle
rules can automate the deletion of objects after a specified period to meet
data retention requirements【17949889377376†L188-L219】.
## Limitations and Future Work
This sample implementation is intended as a starting point. Some features
that could be improved include:
* A richer web interface for managing users, clients and retention policies.
* More granular client configuration (e.g. inclusion/exclusion patterns,
incremental or differential backups) and scheduling via cron.
* Support for compressing and encrypting data before upload.
* Streaming large files to the server to avoid loading them entirely into
memory.
* Integration tests and better error handling.
Despite these limitations, the provided code demonstrates the core
functionality required for a secure, deduplicated backup service and
provides a foundation for further development.

21
client/Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
COPY . .
# Expose the port used by the client web interface. This allows
# external browsers to connect to the configuration UI when the
# client runs in its own container. See dockercompose files for
# port mappings.
EXPOSE 8080
# The client runs indefinitely, sending backups according to its schedule
CMD ["python", "main.py"]

688
client/main.py Normal file
View file

@ -0,0 +1,688 @@
"""Client agent for the backup service.
The client agent runs inside its own Docker container and periodically backs
up files to the backup server. It performs the following operations:
1. On first run, authenticate to the server using credentials provided via
environment variables and register itself as a new client. The returned
client token is stored locally.
2. Run optional prebackup commands specified in ``PRE_COMMANDS``. These
commands are executed in the container shell before each backup cycle; for
example, you can use this feature to dump a PostgreSQL database to a
file.
3. Walk through the directories specified in ``MONITORED_PATHS`` (comma
separated) and compute a SHA256 hash of each file. If the hash has not
changed since the previous backup, the file is skipped to conserve
bandwidth. Otherwise, the file is uploaded to the server via the
``/api/clients/{client_token}/backup`` endpoint.
4. Periodically send a ``ping`` to the server to update the client's last
heartbeat time.
5. Send log messages to the server when errors occur.
Configuration is provided entirely through environment variables:
* ``SERVER_URL`` (required): Base URL of the backup server, e.g. ``http://server:8000``.
* ``USERNAME`` and ``PASSWORD``: Credentials of a user with permission to
register new clients.
* ``CLIENT_NAME``: Humanreadable name for this client.
* ``MONITORED_PATHS``: Commaseparated list of directory paths to back up.
* ``PRE_COMMANDS``: Semicolonseparated list of shell commands to run before
each backup cycle (optional).
* ``PING_INTERVAL``: Seconds between ping requests (default: 300).
* ``BACKUP_INTERVAL``: Seconds between backup cycles (default: 3600).
State such as the client token and previously computed file hashes is saved in
``state.json`` within the working directory.
"""
from __future__ import annotations
import hashlib
import json
import os
import sys
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Dict, Optional, List
import requests
import yaml # type: ignore
import subprocess
from urllib.parse import urlparse
import datetime
# Imports for the web interface
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
import threading
import uvicorn
STATE_FILE = "state.json"
@dataclass
class ClientState:
token: Optional[str] = None # client token assigned by server
client_id: Optional[int] = None # numeric ID assigned by server
access_token: Optional[str] = None # bearer token for API authentication
file_hashes: Dict[str, str] = None # maps file paths to last known hash
def to_json(self) -> str:
return json.dumps(asdict(self))
@classmethod
def from_json(cls, data: str) -> "ClientState":
obj = json.loads(data)
return cls(
token=obj.get("token"),
client_id=obj.get("client_id"),
access_token=obj.get("access_token"),
file_hashes=obj.get("file_hashes", {}),
)
class BackupClient:
def __init__(self) -> None:
# Read initial configuration from environment variables. These may be empty
# when the client is first started; in that case the client will run
# exclusively as a web UI until the user supplies configuration.
self.server_url = os.environ.get("SERVER_URL") or ""
self.username = os.environ.get("USERNAME") or ""
self.password = os.environ.get("PASSWORD") or ""
self.client_name = os.environ.get("CLIENT_NAME", os.uname().nodename)
self.monitored_paths = [p.strip() for p in os.environ.get("MONITORED_PATHS", "").split(",") if p.strip()]
self.pre_commands: List[str] = [c.strip() for c in os.environ.get("PRE_COMMANDS", "").split(";") if c.strip()]
self.ping_interval = int(os.environ.get("PING_INTERVAL", "300"))
self.backup_interval = int(os.environ.get("BACKUP_INTERVAL", "3600"))
# A flag indicating whether the web UI should be enabled. By default
# the UI runs so that the client can be configured interactively when
# environment variables are not provided. Set the environment
# variable ``CLIENT_UI_ENABLED`` to ``false`` or ``0`` to disable the
# UI and run solely based on environment configuration. When the UI
# is disabled and mandatory settings are missing, the client will
# terminate with an error.
ui_env = os.environ.get("CLIENT_UI_ENABLED", "true").lower()
self.ui_enabled = ui_env not in ("false", "0", "no")
# Determine whether the client is configured enough to start backing up.
self.configured = bool(self.server_url and self.username and self.password and self.monitored_paths)
# Load saved state (token, access token, hashes).
self.state = self._load_state()
# Remote prebackup commands retrieved from the server; overrides local commands
self.remote_pre_commands: List[str] = []
# Track last run timestamps for each task ID so we don't run tasks too frequently
# This dict maps task IDs to the last time we attempted to run them.
self.task_last_run: Dict[int, float] = {}
def _load_state(self) -> ClientState:
if os.path.exists(STATE_FILE):
with open(STATE_FILE, "r") as f:
try:
return ClientState.from_json(f.read())
except Exception:
pass
return ClientState(token=None, client_id=None, access_token=None, file_hashes={})
def _save_state(self) -> None:
with open(STATE_FILE, "w") as f:
f.write(self.state.to_json())
def _login(self) -> None:
"""Authenticate using username/password and obtain an access token."""
url = f"{self.server_url}/api/login"
data = {"username": self.username, "password": self.password}
# Use form-encoded data as required by OAuth2PasswordRequestForm
response = requests.post(url, data=data)
if response.status_code != 200:
raise RuntimeError(f"Failed to login: {response.text}")
token = response.json()["access_token"]
self.state.access_token = token
self._save_state()
def _register_client(self) -> None:
"""Register this client and obtain a client token and ID."""
url = f"{self.server_url}/api/clients/register"
headers = {"Authorization": f"Bearer {self.state.access_token}"}
payload = {"name": self.client_name}
response = requests.post(url, json=payload, headers=headers)
if response.status_code != 200:
raise RuntimeError(f"Failed to register client: {response.text}")
data = response.json()
# The server returns the new client object, which includes id and token
self.state.token = data.get("token")
self.state.client_id = data.get("id")
self._save_state()
def ensure_authenticated(self) -> None:
"""Ensure we have valid tokens; login and register if necessary."""
if not self.state.access_token:
self._login()
if not self.state.token:
self._register_client()
def run_pre_commands(self) -> None:
# Use remote commands if available, otherwise fall back to local pre_commands
commands = self.remote_pre_commands if self.remote_pre_commands else self.pre_commands
for cmd in commands:
if not cmd:
continue
try:
subprocess.run(cmd, shell=True, check=True)
except subprocess.CalledProcessError as e:
self.send_log(level="ERROR", message=f"Precommand '{cmd}' failed: {e}")
def compute_file_hash(self, path: Path) -> str:
"""Compute SHA256 hash of a file."""
sha256 = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
def scan_and_backup(self) -> None:
"""Walk monitored directories and upload changed files."""
headers = {
"Authorization": f"Bearer {self.state.access_token}",
}
for dir_path in self.monitored_paths:
p = Path(dir_path)
if not p.exists():
self.send_log(level="ERROR", message=f"Monitored path does not exist: {dir_path}")
continue
for file_path in p.rglob("*"):
if file_path.is_file():
try:
current_hash = self.compute_file_hash(file_path)
except Exception as e:
self.send_log(level="ERROR", message=f"Failed to hash {file_path}: {e}")
continue
last_hash = self.state.file_hashes.get(str(file_path))
if last_hash == current_hash:
continue # unchanged
# Upload file
url = f"{self.server_url}/api/clients/{self.state.token}/backup"
try:
with file_path.open("rb") as f:
files = {"file": (file_path.name, f, "application/octet-stream")}
data = {"path": str(file_path)}
resp = requests.post(url, headers=headers, files=files, data=data)
if resp.status_code == 200:
self.state.file_hashes[str(file_path)] = current_hash
self._save_state()
else:
self.send_log(level="ERROR", message=f"Failed to upload {file_path}: {resp.text}")
except Exception as e:
self.send_log(level="ERROR", message=f"Exception uploading {file_path}: {e}")
# After completing the backup cycle, update the server with the current
# file structure. This allows administrators to browse the client's
# filesystem in the web UI. Only do this if we are authenticated.
if self.state.token:
try:
structure = self.get_file_structure()
url = f"{self.server_url}/api/clients/{self.state.token}/files"
# Send JSON body (list of {path, is_dir})
requests.post(url, json=structure)
except Exception as e:
self.send_log(level="ERROR", message=f"Failed to send file structure: {e}")
def send_ping(self) -> None:
url = f"{self.server_url}/api/clients/{self.state.token}/ping"
try:
requests.post(url)
except Exception as e:
self.send_log(level="ERROR", message=f"Ping failed: {e}")
def send_log(self, level: str, message: str) -> None:
if not self.state.token:
return
url = f"{self.server_url}/api/clients/{self.state.token}/log"
try:
data = {"level": level, "message": message}
requests.post(url, data=data)
except Exception:
pass
# ======== Extended functionality for tasks and file structure ========
def get_file_structure(self) -> List[dict]:
"""
Collect a list of files and directories under the monitored paths.
Returns a list of dictionaries with keys ``path`` and ``is_dir``. Paths
are absolute as seen by the client container. Duplicate entries are
removed.
"""
items: List[dict] = []
seen = set()
for root in self.monitored_paths:
root_path = Path(root)
if not root_path.exists():
continue
# Walk directory tree
for dirpath, dirnames, filenames in os.walk(root_path):
# Record directory itself
if dirpath not in seen:
seen.add(dirpath)
items.append({"path": str(dirpath), "is_dir": True})
for fname in filenames:
fpath = os.path.join(dirpath, fname)
if fpath not in seen:
seen.add(fpath)
items.append({"path": fpath, "is_dir": False})
return items
def fetch_tasks(self) -> List[dict]:
"""Retrieve the list of tasks for this client from the server."""
if not self.state.token:
return []
url = f"{self.server_url}/api/clients/{self.state.token}/tasks"
try:
resp = requests.get(url)
if resp.status_code == 200:
return resp.json()
except Exception as e:
self.send_log(level="ERROR", message=f"Failed to fetch tasks: {e}")
return []
def run_task(self, task: dict) -> None:
"""
Execute a single backup task according to its specification.
This method handles running any specified precommands, compressing
files if requested, uploading the file to the server with
retention overrides and reporting the result back to the server.
"""
task_id = task.get("id")
path = task.get("path")
frequency = task.get("frequency_minutes") or 0
pre_commands = task.get("pre_commands", []) or []
retention_days = task.get("retention_days")
retention_versions = task.get("retention_versions")
compress = task.get("compress", False)
next_run = task.get("next_run")
pending_run_id = task.get("pending_run_id")
# Parse next_run into a timestamp if provided
due = True
if next_run:
try:
dt = datetime.datetime.fromisoformat(next_run)
# Compare as naive UTC
due = datetime.datetime.utcnow() >= dt.replace(tzinfo=None)
except Exception:
pass
# Check local rate limiting: avoid running tasks too frequently in this loop
last = self.task_last_run.get(task_id)
if last and (time.time() - last) < (frequency * 60 if frequency else 0):
due = False
if not due:
return
# Record start time
self.task_last_run[task_id] = time.time()
# Combine commands: first client's remote pre_commands, then task commands
commands = self.remote_pre_commands + pre_commands
run_status = "SUCCESS"
run_message = ""
# Determine run_id: if there is a pending run id from server, use it
run_id = pending_run_id or 0
try:
# Execute precommands
for cmd in commands:
if not cmd:
continue
subprocess.run(cmd, shell=True, check=True)
# Determine file to upload
file_path = Path(path)
if not file_path.exists():
raise FileNotFoundError(f"Task path does not exist: {path}")
upload_path = file_path
temp_path: Optional[Path] = None
if compress and file_path.is_file():
# Create a gzipped archive of the file in memory
import tarfile
import tempfile
temp_fd, temp_name = tempfile.mkstemp(suffix=".tar.gz")
os.close(temp_fd)
temp_path = Path(temp_name)
with tarfile.open(temp_path, "w:gz") as tar:
tar.add(file_path, arcname=file_path.name)
upload_path = temp_path
# Upload the file; pass retention overrides if provided
url = f"{self.server_url}/api/clients/{self.state.token}/backup"
with open(upload_path, "rb") as f:
files = {"file": (upload_path.name, f, "application/octet-stream")}
data = {"path": str(path)}
if retention_days is not None:
data["retention_days"] = str(retention_days)
if retention_versions is not None:
data["retention_versions"] = str(retention_versions)
resp = requests.post(url, headers={"Authorization": f"Bearer {self.state.access_token}"}, files=files, data=data)
# Clean up temporary file
if compress and temp_path and temp_path.exists():
try:
temp_path.unlink()
except Exception:
pass
if resp.status_code != 200:
raise RuntimeError(f"Failed to upload task file: {resp.text}")
except Exception as e:
run_status = "FAILED"
run_message = str(e)
self.send_log(level="ERROR", message=f"Task {task_id} failed: {e}")
finally:
# Report status back to the server
status_url = f"{self.server_url}/api/clients/{self.state.token}/tasks/{task_id}/status"
try:
requests.post(
status_url,
data={
"run_id": str(run_id),
"status": run_status,
"message": run_message,
},
)
except Exception:
# Avoid raising exceptions if status reporting fails
pass
# ===== Configuration helpers for the web UI =====
def apply_config(
self,
server_url: str,
username: str,
password: str,
client_name: str,
monitored_paths: List[str],
) -> Optional[int]:
"""
Apply a new configuration provided by the user via the web UI.
This method updates the client's connection settings, resets any stored
authentication tokens and file hashes, and registers the client with
the server. It returns the newly assigned client ID on success.
Args:
server_url: Base URL of the backup server (e.g. ``http://localhost:8000``).
username: Username of a server user with permission to register clients.
password: Password for the user.
client_name: Humanreadable name for this client.
monitored_paths: List of directory paths to back up.
Returns:
The numeric client ID assigned by the server, or ``None`` if
registration fails.
"""
# Update attributes
self.server_url = server_url.strip()
self.username = username.strip()
self.password = password.strip()
self.client_name = client_name.strip() or os.uname().nodename
self.monitored_paths = monitored_paths or []
# Mark the client as configured only if essential fields are provided
self.configured = bool(self.server_url and self.username and self.password and self.monitored_paths)
# Reset state
self.state.access_token = None
self.state.token = None
self.state.client_id = None
self.state.file_hashes = {}
try:
# Authenticate and register
self._login()
self._register_client()
# Save state to disk
self._save_state()
return self.state.client_id
except Exception as e:
# Log any error; the backup loop will skip operations until configured
self.send_log(level="ERROR", message=str(e))
return None
def run(self) -> None:
"""
Main loop for the backup client.
If the client is not yet configured (no server URL, credentials or
monitored paths), this loop simply waits until configuration is
provided via the web interface. Once configured, it ensures the
client is authenticated and then periodically sends pings and
performs backups according to the configured intervals.
"""
last_backup = 0.0
last_ping = 0.0
last_task_check = 0.0
while True:
# If configuration is incomplete, skip any activity
if not self.configured:
time.sleep(1)
continue
# Ensure authentication; if tokens are missing the client will
# attempt to login/register. Errors are logged but do not
# terminate the loop.
try:
self.ensure_authenticated()
except Exception as e:
self.send_log(level="ERROR", message=f"Authentication failed: {e}")
time.sleep(5)
continue
now = time.time()
if now - last_ping >= self.ping_interval:
self.send_ping()
last_ping = now
if now - last_backup >= self.backup_interval:
# Retrieve remote commands before each backup cycle
try:
if self.state.token:
config_url = f"{self.server_url}/api/clients/{self.state.token}/config"
resp = requests.get(config_url)
if resp.status_code == 200:
data = resp.json()
self.remote_pre_commands = data.get("pre_commands", [])
except Exception as e:
self.send_log(level="ERROR", message=f"Failed to fetch config: {e}")
self.run_pre_commands()
self.scan_and_backup()
last_backup = now
# Periodically check for scheduled tasks. Tasks are fetched
# independently of the backup interval so that "run now" requests
# are handled promptly. Adjust the frequency as needed.
if now - last_task_check >= 30:
tasks = self.fetch_tasks()
for t in tasks:
try:
self.run_task(t)
except Exception as e:
# Catch any unexpected exception and log it
self.send_log(level="ERROR", message=f"Task execution error: {e}")
last_task_check = now
time.sleep(1)
def create_app(client: BackupClient) -> FastAPI:
"""Instantiate and return a FastAPI application for configuring the client.
The web interface exposes a simple form at the root URL which asks for
the server address, user credentials, client name and monitored paths. On
submission the client is configured and registered with the server, and
the user is redirected to the server's web UI for the newly created client.
Args:
client: The BackupClient instance to be configured via the UI.
Returns:
A FastAPI application ready to be served by Uvicorn or another ASGI server.
"""
app = FastAPI()
# Determine template directory relative to this file
templates_dir = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
# Render the configuration form. Prepopulate fields with current
# settings where available. If an error message exists in the query
# parameters it will be displayed on the page.
error = request.query_params.get("error")
return templates.TemplateResponse(
"login.html",
{
"request": request,
"server_url": client.server_url or "",
"username": client.username or "",
"client_name": client.client_name or "",
"monitored_paths": ",".join(client.monitored_paths) if client.monitored_paths else "",
"error": error,
},
)
@app.post("/configure")
async def configure(
request: Request,
server_url: str = Form(...),
username: str = Form(...),
password: str = Form(...),
client_name: str = Form(""),
monitored_paths: str = Form(""),
):
"""
Handle submission of the configuration form. Before registering the
client, validate the inputs to catch common mistakes such as
malformed URLs or missing credentials. If validation fails, the form
is re-rendered with an error message. On success the client is
registered and a success page with a link to the server UI is
displayed.
"""
# Trim whitespace
server_url = server_url.strip()
username = username.strip()
password = password.strip()
client_name = client_name.strip()
# Parse monitored paths from comma-separated input
paths = [p.strip() for p in monitored_paths.split(",") if p.strip()]
# Validate the server URL
parsed = urlparse(server_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"server_url": server_url,
"username": username,
"client_name": client_name,
"monitored_paths": monitored_paths,
"error": "Invalid server URL. Please enter a valid http or https URL.",
},
)
# Validate required fields
if not username or not password:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"server_url": server_url,
"username": username,
"client_name": client_name,
"monitored_paths": monitored_paths,
"error": "Username and password are required.",
},
)
if not paths:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"server_url": server_url,
"username": username,
"client_name": client_name,
"monitored_paths": monitored_paths,
"error": "Please specify at least one directory to back up.",
},
)
# Apply configuration and attempt to register the client. apply_config
# returns the client_id if registration succeeds.
client_id = client.apply_config(
server_url=server_url,
username=username,
password=password,
client_name=client_name,
monitored_paths=paths,
)
if client_id:
# On success show a confirmation page with a link to the server UI.
return templates.TemplateResponse(
"success.html",
{
"request": request,
"server_url": server_url.rstrip("/"),
"client_id": client_id,
},
)
# If registration failed, re-render the form with a generic error message
return templates.TemplateResponse(
"login.html",
{
"request": request,
"server_url": server_url,
"username": username,
"client_name": client_name,
"monitored_paths": monitored_paths,
"error": "Failed to authenticate or register. Please check your credentials and server address.",
},
)
return app
if __name__ == "__main__":
"""
Entry point for the client container. The behaviour depends on the
``CLIENT_UI_ENABLED`` environment variable:
* When ``CLIENT_UI_ENABLED`` is true (default), the client runs a small
web server on port 8080 to collect configuration from the user. The
backup loop will start automatically once the user submits the form.
* When ``CLIENT_UI_ENABLED`` is false (e.g. ``CLIENT_UI_ENABLED=0``), the
client skips launching the web UI. In this mode all required
configuration must be provided via environment variables. If any
required setting is missing the program will exit with an error.
"""
client = BackupClient()
if client.ui_enabled:
# Launch the web interface in a background thread. The UI allows
# interactive configuration when environment variables are absent.
def start_web() -> None:
app = create_app(client)
uvicorn.run(app, host="0.0.0.0", port=8080, log_level="info")
web_thread = threading.Thread(target=start_web, daemon=True)
web_thread.start()
# In UI mode always run the backup loop; it will noop until
# configuration is applied by the user.
client.run()
else:
# UI disabled. Ensure the client has enough configuration to run.
if not client.configured:
# Emit an error and terminate. Without configuration and without
# the UI there is no way for the user to provide settings.
sys.stderr.write(
"Error: CLIENT_UI_ENABLED is false but mandatory configuration\n"
"(SERVER_URL, USERNAME, PASSWORD and MONITORED_PATHS) was not provided.\n"
"Either set these environment variables or enable the UI.\n"
)
sys.exit(1)
# Configuration is provided via environment variables. Start the
# backup loop immediately.
client.run()

6
client/requirements.txt Normal file
View file

@ -0,0 +1,6 @@
requests
PyYAML
fastapi
uvicorn
jinja2
python-multipart

View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Configure Backup Client</title>
<!-- Load Tailwind via the Play CDN so we can use utility classes immediately【342154051484276†L285-L295】 -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50">
<div class="max-w-lg mx-auto my-12 bg-white p-8 rounded shadow">
<h1 class="text-2xl font-bold mb-4">Configure Backup Client</h1>
{% if error %}
<div class="mb-4 p-3 bg-red-100 text-red-700 rounded border border-red-300">
{{ error }}
</div>
{% endif %}
<form method="post" action="/configure" class="space-y-4">
<div>
<label for="server_url" class="block text-sm font-medium text-gray-700 mb-1">Server URL</label>
<input id="server_url" type="text" name="server_url" value="{{ server_url }}" required placeholder="http://server:8000" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input id="username" type="text" name="username" value="{{ username }}" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input id="password" type="password" name="password" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label for="client_name" class="block text-sm font-medium text-gray-700 mb-1">Client Name</label>
<input id="client_name" type="text" name="client_name" value="{{ client_name }}" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
<p class="text-xs text-gray-500 mt-1">If left blank, the container hostname will be used.</p>
</div>
<div>
<label for="monitored_paths" class="block text-sm font-medium text-gray-700 mb-1">Monitored Paths</label>
<input id="monitored_paths" type="text" name="monitored_paths" value="{{ monitored_paths }}" placeholder="/data" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
<p class="text-xs text-gray-500 mt-1">Commaseparated list of directories to back up.</p>
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Save &amp; Register</button>
</form>
</div>
</body>
</html>

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Registration Successful</title>
<!-- Load Tailwind via the Play CDN so we can use utility classes immediately -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body class="bg-gray-50">
<div class="max-w-lg mx-auto my-12 bg-white p-8 rounded shadow">
<h1 class="text-2xl font-bold mb-4">Registration Successful</h1>
<p class="mb-6">Your backup client has been registered successfully. You can now manage this client from the server interface.</p>
<a href="{{ server_url }}/clients/{{ client_id }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Go to Client Dashboard
</a>
</div>
</body>
</html>

39
docker-compose.client.yml Normal file
View file

@ -0,0 +1,39 @@
version: '3.8'
services:
client:
build: ./client
container_name: backup_client
# Expose the client's web interface on port 8080 so that it can be
# configured via a browser on the host.
ports:
- "8080:8080"
environment:
# Provide the address of the remote backup server. This should be
# updated to the actual server URL when deploying the client
# independently. Leaving it empty allows the user to set it via
# the web interface.
SERVER_URL: ""
# Credentials of a user with permission to register clients. These
# can also be provided via the web interface. If left blank the
# client will wait for user input.
USERNAME: ""
PASSWORD: ""
# Name of this client (will default to hostname if omitted). This
# value can be overridden in the web UI.
CLIENT_NAME: ""
# Directories inside the client container to back up. These can
# also be specified via the web UI as a commaseparated list.
MONITORED_PATHS: "/data"
# Prebackup commands (e.g. dumping a PostgreSQL database). These
# can be left empty and configured later on the server.
PRE_COMMANDS: ""
# Ping and backup intervals. Modify as needed.
PING_INTERVAL: "300"
BACKUP_INTERVAL: "3600"
volumes:
# Mount host data to back up; adjust to your needs
- client_data:/data
volumes:
client_data:

70
docker-compose.server.yml Normal file
View file

@ -0,0 +1,70 @@
version: '3.8'
services:
server:
build: ./server
container_name: backup_server
ports:
- "8000:8000"
environment:
# Change SECRET_KEY in production
SECRET_KEY: "mysecretkey"
# Use Postgres instead of SQLite. The DATABASE_URL uses the same
# credentials defined in the db service below.
DATABASE_URL: "postgresql+psycopg2://backup:backup@db:5432/backup"
# Configure S3 to point at the local MinIO service. These values
# correspond to the settings of the minio container defined below.
S3_BUCKET: "backup"
AWS_ACCESS_KEY_ID: "minio"
AWS_SECRET_ACCESS_KEY: "minio123"
AWS_REGION: "us-east-1"
# Endpoint for the local S3 service
S3_ENDPOINT: "http://minio:9000"
volumes:
# Persist local file storage
- server_data:/app/data
# Persist SQLite or Postgres database
- server_db:/app/backup.db
db:
image: postgres:15
container_name: backup_db
environment:
POSTGRES_USER: backup
POSTGRES_PASSWORD: backup
POSTGRES_DB: backup
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "backup"]
interval: 10s
timeout: 5s
retries: 5
# Lightweight S3-compatible storage using MinIO. This service
# provides an object store accessible at :9000 and a web console at
# :9001. Credentials and bucket configuration are set to match the
# server environment variables above.
minio:
image: quay.io/minio/minio
container_name: backup_minio
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
volumes:
server_data:
server_db:
postgres_data:
minio_data:

100
docker-compose.yml Normal file
View file

@ -0,0 +1,100 @@
version: '3.8'
services:
server:
build: ./server
container_name: backup_server
ports:
- "8000:8000"
environment:
# Change SECRET_KEY in production
SECRET_KEY: "mysecretkey"
# Use Postgres instead of SQLite. The DATABASE_URL uses the same
# credentials defined in the db service below.
DATABASE_URL: "postgresql+psycopg2://backup:backup@db:5432/backup"
# Configure S3 to point at the local MinIO service. These values
# correspond to the settings of the minio container defined below.
S3_BUCKET: "backup"
AWS_ACCESS_KEY_ID: "minio"
AWS_SECRET_ACCESS_KEY: "minio123"
AWS_REGION: "us-east-1"
# Endpoint for the local S3 service
S3_ENDPOINT: "http://minio:9000"
volumes:
# Persist local file storage
- server_data:/app/data
# Persist SQLite database
- server_db:/app/backup.db
# Example Postgres service (optional)
db:
image: postgres:15
container_name: backup_db
environment:
POSTGRES_USER: backup
POSTGRES_PASSWORD: backup
POSTGRES_DB: backup
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "backup"]
interval: 10s
timeout: 5s
retries: 5
# Lightweight S3-compatible storage using MinIO. This service
# provides an object store accessible at :9000 and a web console at
# :9001. Credentials and bucket configuration are set to match the
# server environment variables above.
minio:
image: quay.io/minio/minio
container_name: backup_minio
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
client:
build: ./client
container_name: backup_client
depends_on:
- server
environment:
# Address of the server container; uses Docker's internal DNS
SERVER_URL: "http://server:8000"
# Provide credentials of a user with permission to register clients
USERNAME: "admin"
PASSWORD: "adminpass"
# Name of this client (will default to hostname if omitted)
CLIENT_NAME: "example-client"
# Directories inside the client container to back up
MONITORED_PATHS: "/data"
# Prebackup commands (e.g. dumping a PostgreSQL database)
PRE_COMMANDS: ""
# Ping interval in seconds
PING_INTERVAL: "300"
# Backup interval in seconds
BACKUP_INTERVAL: "3600"
volumes:
# Mount host data to back up; adjust to your needs
- client_data:/data
ports:
# Expose the client's web interface on port 8080
- "8080:8080"
volumes:
server_data:
server_db:
client_data:
postgres_data:
minio_data:

18
server/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
# Use gunicorn or uvicorn for FastAPI
# Run the FastAPI application as a module so that relative imports work
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"]

1
server/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Server package initializer."""

96
server/auth.py Normal file
View file

@ -0,0 +1,96 @@
"""Authentication utilities for the backup service.
This module implements password hashing, JSON Web Token (JWT) generation and
verification, and FastAPI dependencies for authenticating API calls. Users
authenticate via the ``/api/login`` endpoint by providing their username and
password; upon successful authentication a signed JWT is returned. The JWT
must be included in the ``Authorization: Bearer <token>`` header for
subsequent requests.
The JWT contains the user's ID and role (admin flag). Token expiration is
configurable via the ``ACCESS_TOKEN_EXPIRE_MINUTES`` environment variable.
"""
from __future__ import annotations
import os
import datetime
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from . import models
from .database import get_db
# Configuration
SECRET_KEY = os.getenv("SECRET_KEY", "CHANGE_ME")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/login")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[datetime.timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.datetime.utcnow() + (
expires_delta or datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> models.User:
"""Retrieve the current user from a JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int | None = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError as e:
raise credentials_exception from e
user = db.query(models.User).filter(models.User.id == user_id).first()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
return current_user
async def get_current_admin(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough privileges",
)
return current_user

37
server/database.py Normal file
View file

@ -0,0 +1,37 @@
"""Database configuration for the backup service.
This module defines the SQLAlchemy engine and session factory used to access
the relational database. The database URL can be configured via the
``DATABASE_URL`` environment variable. When using SQLite the database will
be stored on disk in the working directory. For production deployments a
server like PostgreSQL should be used.
"""
from __future__ import annotations
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./backup.db")
# Determine whether to use check_same_thread for SQLite
if DATABASE_URL.startswith("sqlite"):
engine = create_engine(
DATABASE_URL, connect_args={"check_same_thread": False}
)
else:
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db() -> Session:
"""Yield a new database session and ensure it is closed afterwards."""
db = SessionLocal()
try:
yield db
finally:
db.close()

748
server/main.py Normal file
View file

@ -0,0 +1,748 @@
"""Main application entry point for the backup server.
This FastAPI application implements both a REST API for clients to register,
upload backups and send health checks, as well as a minimal web interface
for administrators to monitor clients and manage retention policies. The
design follows the requirements:
* Clients register themselves with the server and obtain a unique token used
for subsequent API calls.
* Files are uploaded as multipart form data; the server computes the file's
hash and uses a hashtostorage lookup to avoid reuploading duplicates
744670406339295L270-L339.
* Retention policies can be configured per user via maximum age (days) or
maximum number of versions, reflecting best practices for balancing version
depth against storage consumption709290716836410L142-L159.
* The server can be run under Docker and stores data in a relational database
and optionally S3 for the backing file store. A simple admin interface
displays client statuses and last backup times.
"""
from __future__ import annotations
import asyncio
import datetime
import hashlib
import os
from typing import List, Optional
from fastapi import (
FastAPI,
Depends,
HTTPException,
status,
File,
UploadFile,
Form,
Request,
Response,
)
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.templating import Jinja2Templates
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from . import models, schemas, auth, storage as storage_module, database
app = FastAPI(title="Backup Service")
templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates"))
storage = storage_module.get_storage()
@app.on_event("startup")
async def on_startup() -> None:
# Create database tables if they do not exist
models.Base.metadata.create_all(bind=database.engine)
# Ensure the `pre_commands` column exists on the clients table. SQLite will
# ignore the ALTER TABLE if the column already exists. For other
# databases this may fail gracefully if the column exists.
try:
with database.engine.connect() as conn:
conn.execute("""ALTER TABLE clients ADD COLUMN pre_commands TEXT""")
except Exception:
# Column already exists or migration failed; ignore
pass
@app.post("/api/register_user", response_model=schemas.UserOut)
async def register_user(
user: schemas.UserCreate,
db: Session = Depends(database.get_db),
current_admin: models.User = Depends(auth.get_current_admin),
) -> schemas.UserOut:
"""Create a new user.
Only administrators may create users. The user's password is hashed
before storage. Retention policies can be optionally provided.
"""
existing = db.query(models.User).filter(models.User.username == user.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already exists")
hashed_password = auth.hash_password(user.password)
db_user = models.User(
username=user.username,
hashed_password=hashed_password,
is_admin=user.is_admin,
retention_days=user.retention_days,
retention_versions=user.retention_versions,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@app.post("/api/login", response_model=schemas.Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(database.get_db),
) -> schemas.Token:
"""Authenticate a user and return a JWT token."""
user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not user or not auth.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = auth.create_access_token(data={"sub": user.id, "is_admin": user.is_admin})
return schemas.Token(access_token=access_token)
@app.post("/api/clients/register", response_model=schemas.ClientOut)
async def register_client(
req: schemas.ClientRegisterRequest,
db: Session = Depends(database.get_db),
current_admin: models.User = Depends(auth.get_current_admin),
) -> schemas.ClientOut:
"""Register a new client and return its unique token.
Only administrators may call this endpoint. A client belongs to a specific
user (owner). If ``owner_id`` is omitted the current admin becomes the
owner.
"""
owner_id = req.owner_id or current_admin.id
owner = db.query(models.User).filter(models.User.id == owner_id).first()
if owner is None:
raise HTTPException(status_code=404, detail="Owner not found")
# Generate a random token; collisions are extremely unlikely
token = hashlib.sha256(os.urandom(32)).hexdigest()
client = models.Client(name=req.name, token=token, owner_id=owner.id)
db.add(client)
db.commit()
db.refresh(client)
return client
def get_client_by_token(token: str, db: Session) -> models.Client:
client = db.query(models.Client).filter(models.Client.token == token).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
return client
@app.post("/api/clients/{client_token}/ping")
async def client_ping(
client_token: str,
db: Session = Depends(database.get_db),
) -> dict[str, str]:
"""Update the last ping time for a client.
Clients should call this endpoint periodically to indicate they are alive.
"""
client = get_client_by_token(client_token, db)
client.last_ping = datetime.datetime.utcnow()
db.commit()
return {"status": "pong"}
async def prune_old_versions(
db: Session,
client: models.Client,
original_path: str,
retention_days: Optional[int],
retention_versions: Optional[int],
) -> None:
"""Prune old backup versions according to retention policies.
This helper deletes the oldest entries that fall outside the specified
retention age or count. Both policies can be combined; whichever removes
more versions takes effect. The current (most recent) version is always
retained.
"""
query = (
db.query(models.BackupFile)
.join(models.FileHash)
.filter(models.BackupFile.client_id == client.id)
.filter(models.BackupFile.original_path == original_path)
.order_by(models.BackupFile.version_time.desc())
)
backups: List[models.BackupFile] = query.all()
if not backups:
return
# Keep the newest backup always
backups_to_consider = backups[1:]
to_delete: List[models.BackupFile] = []
now = datetime.datetime.utcnow()
# Agebased retention
if retention_days is not None:
cutoff = now - datetime.timedelta(days=retention_days)
for bf in backups_to_consider:
if bf.version_time < cutoff:
to_delete.append(bf)
# Countbased retention
if retention_versions is not None and len(backups) > retention_versions:
to_delete.extend(backups[retention_versions:])
# Remove duplicates in case both policies selected the same backup
unique_to_delete = {b.id: b for b in to_delete}.values()
for bf in unique_to_delete:
# Delete database record
db.delete(bf)
# Determine if file hash is still referenced by any backup
if len(bf.file_hash.backups) == 1:
# Last reference; remove physical file
asyncio.create_task(storage.delete_file(bf.file_hash.storage_path)) # schedule deletion
db.delete(bf.file_hash)
db.commit()
@app.post("/api/clients/{client_token}/backup", response_model=schemas.BackupEntryOut)
async def upload_backup(
client_token: str,
file: UploadFile = File(...),
path: str = Form(..., description="Original file path on the client"),
retention_days: Optional[int] = Form(None, description="Override retention days for this file"),
retention_versions: Optional[int] = Form(None, description="Override retention versions for this file"),
db: Session = Depends(database.get_db),
) -> schemas.BackupEntryOut:
"""Upload a file from a client and create a backup entry.
The client is identified by its token. The server computes a SHA256 hash of
the uploaded content; if a file with the same hash already exists in the
deduplicated store, the new backup entry simply references the existing
storage path744670406339295L270-L339. Retention policies defined on the
owning user (either age or version count) are applied.
"""
client = get_client_by_token(client_token, db)
data = await file.read()
hash_value = hashlib.sha256(data).hexdigest()
# Check if file already exists
file_hash = db.query(models.FileHash).filter(models.FileHash.hash_value == hash_value).first()
storage_path: str
if file_hash:
storage_path = file_hash.storage_path
else:
# Save file to storage backend
storage_path = await storage.save_file(data, filename=file.filename)
file_hash = models.FileHash(hash_value=hash_value, storage_path=storage_path)
db.add(file_hash)
# Create backup record
backup = models.BackupFile(
client_id=client.id,
file_hash=file_hash,
original_path=path,
size=len(data),
version_time=datetime.datetime.utcnow(),
)
db.add(backup)
# Update last backup time
client.last_backup = backup.version_time
db.commit()
db.refresh(backup)
# Apply retention policy. Use overrides from the request if provided;
# otherwise fall back to the owner's defaults. This allows perfile
# policies configured via backup tasks.
owner = client.owner
effective_retention_days = retention_days if retention_days is not None else owner.retention_days
effective_retention_versions = (
retention_versions if retention_versions is not None else owner.retention_versions
)
await prune_old_versions(
db=db,
client=client,
original_path=path,
retention_days=effective_retention_days,
retention_versions=effective_retention_versions,
)
return schemas.BackupEntryOut(
id=backup.id,
original_path=backup.original_path,
version_time=backup.version_time,
size=backup.size,
file_hash=hash_value,
)
@app.get("/api/clients/{client_token}/backups", response_model=List[schemas.BackupEntryOut])
async def list_backups(
client_token: str,
db: Session = Depends(database.get_db),
) -> List[schemas.BackupEntryOut]:
"""List backups for a client, ordered by newest first."""
client = get_client_by_token(client_token, db)
backups = (
db.query(models.BackupFile)
.filter(models.BackupFile.client_id == client.id)
.order_by(models.BackupFile.version_time.desc())
.all()
)
return [
schemas.BackupEntryOut(
id=b.id,
original_path=b.original_path,
version_time=b.version_time,
size=b.size,
file_hash=b.file_hash.hash_value,
)
for b in backups
]
@app.get("/api/clients/{client_token}/download/{backup_id}")
async def download_backup(
client_token: str,
backup_id: int,
db: Session = Depends(database.get_db),
) -> Response:
"""Download the contents of a backup file.
This endpoint streams the content of the stored file back to the client. For
S3 storage backends the content is downloaded from S3 on demand. For local
storage backends the file is read from disk.
"""
client = get_client_by_token(client_token, db)
backup = (
db.query(models.BackupFile)
.filter(models.BackupFile.id == backup_id)
.filter(models.BackupFile.client_id == client.id)
.first()
)
if not backup:
raise HTTPException(status_code=404, detail="Backup not found")
file_hash = backup.file_hash
# Determine if storage is local
if isinstance(storage, storage_module.LocalStorage):
file_path = storage.root_dir / file_hash.storage_path
return FileResponse(path=file_path, filename=os.path.basename(backup.original_path))
else:
# For S3 return the object contents
s3 = storage.s3
obj = s3.get_object(Bucket=storage.bucket_name, Key=file_hash.storage_path)
content = obj["Body"].read()
return Response(content=content, media_type="application/octet-stream")
@app.post("/api/clients/{client_token}/log")
async def log_from_client(
client_token: str,
level: str = Form(...),
message: str = Form(...),
db: Session = Depends(database.get_db),
) -> dict[str, str]:
"""Receive a log message from a client."""
client = get_client_by_token(client_token, db)
log_entry = models.ClientLog(
client_id=client.id, level=level.upper(), message=message
)
db.add(log_entry)
db.commit()
return {"status": "logged"}
# ======== File structure and task management endpoints ========
@app.post("/api/clients/{client_token}/files")
async def update_client_files(
client_token: str,
files: List[dict],
db: Session = Depends(database.get_db),
) -> dict[str, str]:
"""Receive a full file listing from a client and replace existing entries.
The payload should be a list of objects with keys ``path`` and ``is_dir``.
All existing ``ClientFile`` records for the client are removed and
recreated from the provided list. This endpoint allows the server to
present a file tree in the web interface.
"""
client = get_client_by_token(client_token, db)
# Delete existing file entries
db.query(models.ClientFile).filter(models.ClientFile.client_id == client.id).delete()
# Insert new entries
for item in files:
p = item.get("path")
is_dir = bool(item.get("is_dir"))
if not p:
continue
cf = models.ClientFile(client_id=client.id, path=p, is_dir=is_dir)
db.add(cf)
db.commit()
return {"status": "updated"}
@app.get("/api/clients/{client_id}/files", response_model=List[schemas.ClientFileOut])
async def list_client_files(
client_id: int,
db: Session = Depends(database.get_db),
current_admin: models.User = Depends(auth.get_current_admin),
) -> List[schemas.ClientFileOut]:
"""Return the file structure for a given client.
Administrators can query this endpoint to display a file tree in the web
interface. Files are returned unsorted; the UI may organise them into
a hierarchical view.
"""
client = db.query(models.Client).filter(models.Client.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
entries = (
db.query(models.ClientFile)
.filter(models.ClientFile.client_id == client.id)
.all()
)
return [schemas.ClientFileOut.from_orm(e) for e in entries]
@app.get("/api/clients/{client_token}/tasks", response_model=List[schemas.TaskOut])
async def get_tasks_for_client(
client_token: str,
db: Session = Depends(database.get_db),
) -> List[schemas.TaskOut]:
"""Return backup tasks for a client.
This endpoint is called by the client to retrieve its scheduled tasks. The
`pre_commands` field is returned as a list of strings for easier
consumption by the client.
"""
client = get_client_by_token(client_token, db)
tasks = (
db.query(models.BackupTask)
.filter(models.BackupTask.client_id == client.id)
.all()
)
result: List[schemas.TaskOut] = []
for t in tasks:
commands: List[str] = []
if t.pre_commands:
commands = [cmd for cmd in t.pre_commands.splitlines() if cmd.strip()]
# Determine pending run ID if there is a run with status PENDING
pending_run = (
db.query(models.TaskRun)
.filter(models.TaskRun.task_id == t.id, models.TaskRun.status == "PENDING")
.order_by(models.TaskRun.start_time.desc())
.first()
)
pending_id = pending_run.id if pending_run else None
result.append(
schemas.TaskOut(
id=t.id,
path=t.path,
frequency_minutes=t.frequency_minutes,
pre_commands=commands,
retention_days=t.retention_days,
retention_versions=t.retention_versions,
compress=t.compress,
last_run=t.last_run,
next_run=t.next_run,
pending_run_id=pending_id,
)
)
return result
@app.get("/api/clients/{client_id}/tasks", response_model=List[schemas.TaskOut])
async def list_tasks_for_admin(
client_id: int,
db: Session = Depends(database.get_db),
current_admin: models.User = Depends(auth.get_current_admin),
) -> List[schemas.TaskOut]:
"""List tasks associated with a client (admin view)."""
client = db.query(models.Client).filter(models.Client.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
tasks = (
db.query(models.BackupTask)
.filter(models.BackupTask.client_id == client.id)
.all()
)
out: List[schemas.TaskOut] = []
for t in tasks:
cmds = []
if t.pre_commands:
cmds = [c for c in t.pre_commands.splitlines() if c.strip()]
pending_run = (
db.query(models.TaskRun)
.filter(models.TaskRun.task_id == t.id, models.TaskRun.status == "PENDING")
.order_by(models.TaskRun.start_time.desc())
.first()
)
pending_id = pending_run.id if pending_run else None
out.append(
schemas.TaskOut(
id=t.id,
path=t.path,
frequency_minutes=t.frequency_minutes,
pre_commands=cmds,
retention_days=t.retention_days,
retention_versions=t.retention_versions,
compress=t.compress,
last_run=t.last_run,
next_run=t.next_run,
pending_run_id=pending_id,
)
)
return out
@app.post("/api/clients/{client_id}/tasks")
async def create_task(
client_id: int,
path: str = Form(...),
frequency_minutes: int = Form(..., gt=0, description="Run frequency in minutes"),
pre_commands: str = Form("", description="One command per line", max_length=4000),
retention_days: Optional[int] = Form(None),
retention_versions: Optional[int] = Form(None),
compress: bool = Form(False),
db: Session = Depends(database.get_db),
current_admin: models.User = Depends(auth.get_current_admin),
) -> Response:
"""Create a new backup task for the specified client.
Administrators use this endpoint (via the web UI) to schedule backups for
specific files or directories. The client will execute the task at the
configured frequency.
"""
client = db.query(models.Client).filter(models.Client.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
task = models.BackupTask(
client_id=client.id,
path=path,
frequency_minutes=frequency_minutes,
pre_commands=pre_commands.strip() if pre_commands else None,
retention_days=retention_days,
retention_versions=retention_versions,
compress=compress,
)
# Next run time initialised to now so that the client will pick up the task
task.next_run = datetime.datetime.utcnow()
db.add(task)
db.commit()
db.refresh(task)
# Redirect back to the client detail page
return Response(status_code=303, headers={"Location": f"/clients/{client_id}"})
@app.post("/api/clients/{client_id}/tasks/{task_id}/run")
async def run_task_now(
client_id: int,
task_id: int,
db: Session = Depends(database.get_db),
current_admin: models.User = Depends(auth.get_current_admin),
) -> dict[str, str]:
"""Request immediate execution of a task.
This sets the task's ``next_run`` to the current time so the client will
execute it on its next polling cycle. A new ``TaskRun`` entry is
created with status ``PENDING``.
"""
task = (
db.query(models.BackupTask)
.filter(models.BackupTask.id == task_id, models.BackupTask.client_id == client_id)
.first()
)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
now = datetime.datetime.utcnow()
task.next_run = now
# Create a TaskRun entry with pending status; it will be updated when
# the client reports completion
run = models.TaskRun(task_id=task.id, start_time=now, status="PENDING")
db.add(run)
db.commit()
return {"status": "scheduled", "run_id": run.id}
@app.post("/api/clients/{client_token}/tasks/{task_id}/status")
async def report_task_status(
client_token: str,
task_id: int,
run_id: int = Form(...),
status: str = Form(...),
message: str = Form(""),
db: Session = Depends(database.get_db),
) -> dict[str, str]:
"""Receive status updates for a task run from the client.
The client should call this endpoint after executing a task, providing
the run ID (created by ``run_task_now``) along with its status and any
message. The server records the end time and updates the task's
``last_run`` and ``next_run`` values.
"""
client = get_client_by_token(client_token, db)
task = (
db.query(models.BackupTask)
.filter(models.BackupTask.id == task_id, models.BackupTask.client_id == client.id)
.first()
)
if not task:
raise HTTPException(status_code=404, detail="Task not found for this client")
run = db.query(models.TaskRun).filter(models.TaskRun.id == run_id, models.TaskRun.task_id == task.id).first()
now = datetime.datetime.utcnow()
if not run:
# If the run does not exist (e.g. scheduled automatically), create it
run = models.TaskRun(task_id=task.id, start_time=now)
db.add(run)
run.end_time = now
run.status = status
run.message = message
# Update task last_run and next_run times
task.last_run = run.end_time
if task.frequency_minutes:
task.next_run = run.end_time + datetime.timedelta(minutes=task.frequency_minutes)
db.commit()
return {"status": "recorded", "run_id": run.id}
# ======== Client configuration endpoints =========
@app.get("/api/clients/{client_token}/config", response_model=schemas.ClientConfigOut)
async def get_client_config(
client_token: str,
db: Session = Depends(database.get_db),
) -> schemas.ClientConfigOut:
"""Return configuration information for a client.
Currently this includes the list of prebackup commands. The client
requests this endpoint to retrieve any commands set by administrators.
"""
client = get_client_by_token(client_token, db)
commands = []
if client.pre_commands:
commands = [cmd for cmd in client.pre_commands.splitlines() if cmd.strip()]
return schemas.ClientConfigOut(pre_commands=commands)
@app.post("/api/clients/{client_id}/config")
async def update_client_config(
client_id: int,
pre_commands: str = Form(..., description="One command per line"),
db: Session = Depends(database.get_db),
current_admin: models.User = Depends(auth.get_current_admin),
) -> Response:
"""Update configuration for a client.
Administrators can set shell commands to be executed by the client before
each backup. These commands are stored on the server and delivered to
clients via the `/api/clients/{client_token}/config` endpoint. Commands
should be separated by newlines.
"""
client = db.query(models.Client).filter(models.Client.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
client.pre_commands = pre_commands.strip() if pre_commands else None
db.commit()
# Redirect back to the client details page
return Response(status_code=303, headers={"Location": f"/clients/{client_id}"})
# ======== Web interface routes =========
@app.get("/clients", response_class=HTMLResponse)
async def list_clients_page(
request: Request,
current_user: models.User = Depends(auth.get_current_admin),
db: Session = Depends(database.get_db),
) -> Response:
"""Render a page that lists all clients with management actions."""
clients = db.query(models.Client).all()
return templates.TemplateResponse(
"clients.html",
{
"request": request,
"user": current_user,
"clients": clients,
},
)
@app.get("/clients/{client_id}", response_class=HTMLResponse)
async def client_detail_page(
client_id: int,
request: Request,
current_user: models.User = Depends(auth.get_current_admin),
db: Session = Depends(database.get_db),
) -> Response:
"""Render details for a specific client, including backups and configuration."""
client = db.query(models.Client).filter(models.Client.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="Client not found")
# fetch backups ordered by latest
backups = (
db.query(models.BackupFile)
.filter(models.BackupFile.client_id == client.id)
.order_by(models.BackupFile.version_time.desc())
.all()
)
files = (
db.query(models.ClientFile)
.filter(models.ClientFile.client_id == client.id)
.order_by(models.ClientFile.path)
.all()
)
tasks = (
db.query(models.BackupTask)
.filter(models.BackupTask.client_id == client.id)
.all()
)
logs = (
db.query(models.ClientLog)
.filter(models.ClientLog.client_id == client.id)
.order_by(models.ClientLog.timestamp.desc())
.limit(50)
.all()
)
# Prepare mappings for task history. For each task, collect its run history and
# the backup entries that correspond to the task's path. This allows the
# template to display run status and available versions per task.
runs_map: dict[int, list] = {}
backups_map: dict[str, list] = {}
# Precompute runs for each task
for t in tasks:
runs = (
db.query(models.TaskRun)
.filter(models.TaskRun.task_id == t.id)
.order_by(models.TaskRun.start_time.desc())
.all()
)
runs_map[t.id] = runs
# Group backups by original path
for b in backups:
backups_map.setdefault(b.original_path, []).append(b)
return templates.TemplateResponse(
"client_detail.html",
{
"request": request,
"user": current_user,
"client": client,
"backups": backups,
"files": files,
"tasks": tasks,
"logs": logs,
"runs_map": runs_map,
"backups_map": backups_map,
},
)
@app.get("/", response_class=HTMLResponse)
async def root_redirect(request: Request) -> Response:
"""Redirect the root URL to the clients list.
The main administration interface is available at ``/clients``. This
redirect keeps the root endpoint simple and ensures there is no
ambiguity with multiple handlers for ``/``.
"""
return Response(status_code=303, headers={"Location": "/clients"})

273
server/models.py Normal file
View file

@ -0,0 +1,273 @@
"""Database models for the backup service.
This module defines SQLAlchemy ORM models used by the backup service. The schema
supports user accounts with optional administrator privileges, client machines
that connect to the server, deduplicated file storage keyed by a content
hash, individual backup entries referencing those hashes, and logs from
clients. Users can also specify retention policies either by limiting the
number of versions retained or by specifying an age after which old versions
should expire.
References:
* Balancing versioning depth against storage consumption is an important
consideration when designing backup systems709290716836410L142-L159. The schema
includes fields for both a maximum number of versions and a maximum
retention age so administrators can adjust these policies according to
their needs.
* Using a key-value store to map file hashes to storage locations makes it
efficient to check whether a file already exists and avoid uploading
duplicates744670406339295L270-L284.
"""
from __future__ import annotations
import datetime
from typing import Optional, List
from sqlalchemy import (
Column,
Integer,
String,
Boolean,
DateTime,
ForeignKey,
UniqueConstraint,
Text,
func,
)
from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base()
class User(Base):
"""Represents an authenticated user.
Users may be administrators (``is_admin=True``) and are allowed to create
other users and clients. Nonadmin users are intended to authenticate
against the API to view and download their backups.
"""
__tablename__ = "users"
id: int = Column(Integer, primary_key=True)
username: str = Column(String(50), unique=True, nullable=False)
hashed_password: str = Column(String(128), nullable=False)
is_admin: bool = Column(Boolean, default=False)
# Optional retention policies set per user
retention_days: Optional[int] = Column(Integer, nullable=True)
retention_versions: Optional[int] = Column(Integer, nullable=True)
created_at: datetime.datetime = Column(
DateTime, nullable=False, server_default=func.now()
)
updated_at: datetime.datetime = Column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
clients: List[Client] = relationship("Client", back_populates="owner")
class Client(Base):
"""Represents a client machine that sends backups to the server.
Each client holds a unique token used for authenticating API requests
originating from that machine. The server tracks the last ping and
backup times to display client health in the web interface.
"""
__tablename__ = "clients"
id: int = Column(Integer, primary_key=True)
name: str = Column(String(128), nullable=False)
token: str = Column(String(64), unique=True, nullable=False)
owner_id: int = Column(Integer, ForeignKey("users.id"), nullable=False)
# Timestamp of the most recent ping request
last_ping: Optional[datetime.datetime] = Column(DateTime, nullable=True)
# Timestamp of the most recent backup
last_backup: Optional[datetime.datetime] = Column(DateTime, nullable=True)
created_at: datetime.datetime = Column(
DateTime, nullable=False, server_default=func.now()
)
updated_at: datetime.datetime = Column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
owner: User = relationship("User", back_populates="clients")
backups: List[BackupFile] = relationship(
"BackupFile", back_populates="client", cascade="all, delete-orphan"
)
logs: List[ClientLog] = relationship(
"ClientLog", back_populates="client", cascade="all, delete-orphan"
)
# Relationship to file structure entries. Each client maintains a list
# of files and directories that are present on the monitored machine.
# The list is updated whenever the client sends its file structure via
# the `/api/clients/{token}/files` endpoint. Entries are deleted
# automatically when the client is removed.
files: List[ClientFile] = relationship(
"ClientFile", back_populates="client", cascade="all, delete-orphan"
)
# Backup tasks configured for this client. Each task defines a path to
# backup, a frequency and optional precommands and retention settings.
tasks: List[BackupTask] = relationship(
"BackupTask", back_populates="client", cascade="all, delete-orphan"
)
# Optional list of shell commands to run on the client before each backup.
# The commands are stored as a newlineseparated string so they can be
# edited in the admin UI. When the client requests its configuration the
# server returns these commands as a list.
pre_commands: Optional[str] = Column(Text, nullable=True)
class FileHash(Base):
"""Represents a deduplicated file stored in the backend storage.
The `hash_value` uniquely identifies the file contents. The `storage_path`
field stores the path or key used by the storage backend (local filesystem
or S3). Multiple backup records may reference the same FileHash if clients
upload identical files744670406339295L270-L339.
"""
__tablename__ = "file_hashes"
id: int = Column(Integer, primary_key=True)
hash_value: str = Column(String(128), unique=True, nullable=False)
storage_path: str = Column(String(512), nullable=False)
created_at: datetime.datetime = Column(
DateTime, nullable=False, server_default=func.now()
)
backups: List[BackupFile] = relationship(
"BackupFile", back_populates="file_hash", cascade="all, delete-orphan"
)
class BackupFile(Base):
"""Represents a single backup entry for a file on a client machine.
Each backup entry references a deduplicated file via the `file_hash_id`
foreign key. The `original_path` records the path of the file on the
client. The `version_time` stores when the backup was taken.
"""
__tablename__ = "backup_files"
id: int = Column(Integer, primary_key=True)
client_id: int = Column(Integer, ForeignKey("clients.id"), nullable=False)
file_hash_id: int = Column(Integer, ForeignKey("file_hashes.id"), nullable=False)
original_path: str = Column(String(1024), nullable=False)
version_time: datetime.datetime = Column(
DateTime, nullable=False, server_default=func.now()
)
# Additional metadata such as file size could be stored here
size: Optional[int] = Column(Integer, nullable=True)
client: Client = relationship("Client", back_populates="backups")
file_hash: FileHash = relationship("FileHash", back_populates="backups")
__table_args__ = (
# Unique constraint ensures that the same client cannot record two
# backups for the same path at the exact same time; this prevents
# accidentally creating duplicate entries if a client retries a request.
UniqueConstraint("client_id", "original_path", "version_time"),
)
class ClientLog(Base):
"""Represents log entries sent by clients.
Log messages are stored with a timestamp and arbitrary text. This table is
useful for debugging and auditing client behaviour.
"""
__tablename__ = "client_logs"
id: int = Column(Integer, primary_key=True)
client_id: int = Column(Integer, ForeignKey("clients.id"), nullable=False)
timestamp: datetime.datetime = Column(
DateTime, nullable=False, server_default=func.now()
)
level: str = Column(String(20), nullable=False, default="INFO")
message: str = Column(Text, nullable=False)
client: Client = relationship("Client", back_populates="logs")
# ====================== Additional models for advanced features ======================
class ClientFile(Base):
"""Represents a file or directory present on a client machine.
The server stores the file structure sent by each client so that
administrators can browse the client's filesystem from the web UI. The
`is_dir` flag distinguishes directories from regular files. Entries are
updated wholesale when the client sends its file listing; old entries are
removed and replaced with the new listing.
"""
__tablename__ = "client_files"
id: int = Column(Integer, primary_key=True)
client_id: int = Column(Integer, ForeignKey("clients.id", ondelete="CASCADE"), nullable=False)
path: str = Column(String(1024), nullable=False)
is_dir: bool = Column(Boolean, default=False, nullable=False)
# Timestamp when this entry was last reported by the client
reported_at: datetime.datetime = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
client: Client = relationship("Client", back_populates="files")
class BackupTask(Base):
"""Represents a scheduled backup task for a specific file on a client.
A task defines a path on the client machine to back up at a regular
interval (specified in minutes). Optional shell commands may run before
backing up, and retention settings can override the user's defaults on a
pertask basis. The `compress` flag indicates whether the client should
archive the file before uploading it. The server tracks the last and
next run times to aid scheduling logic on the client side.
"""
__tablename__ = "backup_tasks"
id: int = Column(Integer, primary_key=True)
client_id: int = Column(Integer, ForeignKey("clients.id", ondelete="CASCADE"), nullable=False)
path: str = Column(String(1024), nullable=False)
# Frequency in minutes; the client should run this task at least this often
frequency_minutes: int = Column(Integer, nullable=False)
pre_commands: Optional[str] = Column(Text, nullable=True)
retention_days: Optional[int] = Column(Integer, nullable=True)
retention_versions: Optional[int] = Column(Integer, nullable=True)
compress: bool = Column(Boolean, default=False, nullable=False)
last_run: Optional[datetime.datetime] = Column(DateTime, nullable=True)
next_run: Optional[datetime.datetime] = Column(DateTime, nullable=True)
created_at: datetime.datetime = Column(DateTime, nullable=False, server_default=func.now())
updated_at: datetime.datetime = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
client: Client = relationship("Client", back_populates="tasks")
runs: List[TaskRun] = relationship(
"TaskRun", back_populates="task", cascade="all, delete-orphan"
)
class TaskRun(Base):
"""Represents a single execution of a backup task.
The client reports the outcome of each run back to the server. This
information allows administrators to see whether tasks are succeeding and
inspect any error messages. Timestamps record when the run started and
ended.
"""
__tablename__ = "task_runs"
id: int = Column(Integer, primary_key=True)
task_id: int = Column(Integer, ForeignKey("backup_tasks.id", ondelete="CASCADE"), nullable=False)
start_time: datetime.datetime = Column(DateTime, nullable=False, server_default=func.now())
end_time: Optional[datetime.datetime] = Column(DateTime, nullable=True)
status: str = Column(String(50), nullable=False)
message: Optional[str] = Column(Text, nullable=True)
task: BackupTask = relationship("BackupTask", back_populates="runs")

10
server/requirements.txt Normal file
View file

@ -0,0 +1,10 @@
fastapi
uvicorn
sqlalchemy
psycopg2-binary
pydantic
passlib[bcrypt]
python-jose[cryptography]
boto3
jinja2
python-multipart

137
server/schemas.py Normal file
View file

@ -0,0 +1,137 @@
"""Pydantic models for request and response bodies.
These schemas define the shape of data sent to and from the API. They are
used by FastAPI to validate input and generate OpenAPI documentation.
"""
from __future__ import annotations
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel, Field, constr
# User schemas
class UserCreate(BaseModel):
username: constr(strip_whitespace=True, min_length=3, max_length=50)
password: constr(min_length=6)
is_admin: bool = False
retention_days: Optional[int] = Field(
None, description="Maximum age (in days) to retain old versions."
)
retention_versions: Optional[int] = Field(
None, description="Maximum number of versions to retain per file."
)
class UserOut(BaseModel):
id: int
username: str
is_admin: bool
retention_days: Optional[int] = None
retention_versions: Optional[int] = None
class Config:
orm_mode = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
# Client schemas
class ClientRegisterRequest(BaseModel):
name: str
owner_id: Optional[int] = None # Only used when admin registers on behalf of user
class ClientOut(BaseModel):
id: int
name: str
token: str
owner_id: int
last_ping: Optional[datetime] = None
last_backup: Optional[datetime] = None
pre_commands: Optional[str] = None
class Config:
orm_mode = True
class ClientConfigOut(BaseModel):
"""Configuration returned to the client.
Currently it exposes the list of prebackup commands. Additional fields
could be added here in future (e.g. inclusion/exclusion patterns).
"""
pre_commands: List[str]
# Backup-related schemas
class BackupEntryOut(BaseModel):
id: int
original_path: str
version_time: datetime
size: Optional[int]
file_hash: str = Field(..., description="Content hash of the stored file")
class Config:
orm_mode = True
class ClientLogEntry(BaseModel):
timestamp: datetime
level: str
message: str
class Config:
orm_mode = True
# ==================== Additional schemas for advanced features ====================
class ClientFileOut(BaseModel):
id: int
path: str
is_dir: bool
class Config:
orm_mode = True
class TaskOut(BaseModel):
id: int
path: str
frequency_minutes: int
pre_commands: List[str] = []
retention_days: Optional[int] = None
retention_versions: Optional[int] = None
compress: bool
last_run: Optional[datetime] = None
next_run: Optional[datetime] = None
pending_run_id: Optional[int] = None
class Config:
orm_mode = True
class TaskCreate(BaseModel):
path: str
frequency_minutes: int
pre_commands: Optional[str] = None
retention_days: Optional[int] = None
retention_versions: Optional[int] = None
compress: bool = False
class TaskRunOut(BaseModel):
id: int
start_time: datetime
end_time: Optional[datetime]
status: str
message: Optional[str] = None
class Config:
orm_mode = True

140
server/storage.py Normal file
View file

@ -0,0 +1,140 @@
"""Abstraction over storage backends for the backup service.
The backup server supports storing files either on the local filesystem or in
Amazon S3. At runtime the storage backend is chosen based on environment
variables. Using S3 for storage enables durable, scalable backup storage and
facilitates lifecycle management (for example, using S3 expiration rules to
delete old versions of objects automatically17949889377376L188-L219). When no
S3 configuration is provided the service falls back to storing files on disk
under a configurable directory.
The Storage base class defines a common API for saving and deleting files. The
local storage implementation simply writes files to disk. The S3
implementation uses boto3 to upload objects to a bucket and generate unique
keys.
"""
from __future__ import annotations
import hashlib
import os
import uuid
from pathlib import Path
from typing import Optional
import boto3
class Storage:
"""Abstract base class for storage backends."""
async def save_file(self, data: bytes, filename: Optional[str] = None) -> str:
"""Save a binary blob and return a storage key/path.
Args:
data: The file content to store.
filename: Optional file name hint; ignored by some backends.
Returns:
A string representing the storage location (e.g. file path or S3 key).
"""
raise NotImplementedError
async def delete_file(self, key: str) -> None:
"""Delete a file from storage.
Args:
key: The storage key previously returned by ``save_file``.
"""
raise NotImplementedError
class LocalStorage(Storage):
"""Filesystem storage backend.
Files are stored inside a root directory defined by the ``BACKUP_STORAGE_PATH``
environment variable (default: ``./data``). Each saved file is placed
under its hash name to avoid collisions; this also allows the backend to
deduplicate by content easily.
"""
def __init__(self, root_dir: Optional[str] = None) -> None:
self.root_dir = Path(root_dir or os.getenv("BACKUP_STORAGE_PATH", "./data")).resolve()
self.root_dir.mkdir(parents=True, exist_ok=True)
async def save_file(self, data: bytes, filename: Optional[str] = None) -> str:
# Use SHA256 of the content as file name to ensure uniqueness
hash_value = hashlib.sha256(data).hexdigest()
# Place files in subdirectories to avoid too many files in one folder
subdir = self.root_dir / hash_value[:2]
subdir.mkdir(parents=True, exist_ok=True)
file_path = subdir / hash_value
if not file_path.exists():
with open(file_path, "wb") as f:
f.write(data)
# Return relative path from root to allow migration if root changes
return str(file_path.relative_to(self.root_dir))
async def delete_file(self, key: str) -> None:
file_path = self.root_dir / key
try:
file_path.unlink()
except FileNotFoundError:
pass
class S3Storage(Storage):
"""Amazon S3 storage backend.
Files are uploaded to an S3 bucket defined by the ``S3_BUCKET`` environment
variable. A UUID-based key is generated for each file. With S3 versioning
enabled, an object can have multiple versions, and lifecycle rules can be
applied to automatically expire old versions17949889377376L188-L219.
"""
def __init__(self, bucket_name: str, prefix: str = "backups/") -> None:
self.bucket_name = bucket_name
self.prefix = prefix
# boto3 will automatically use credentials from environment variables
# Allow overriding the S3 endpoint to support selfhosted services like MinIO.
# When using a custom endpoint, you should also specify a region (any string),
# otherwise boto3 will attempt to infer AWS regions. We pass through
# ``S3_ENDPOINT`` from the environment if present.
endpoint_url = os.getenv("S3_ENDPOINT")
self.s3 = boto3.client(
"s3",
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
region_name=os.getenv("AWS_REGION"),
endpoint_url=endpoint_url,
)
async def save_file(self, data: bytes, filename: Optional[str] = None) -> str:
# Generate a random key; include original filename for readability if provided
key = f"{self.prefix}{uuid.uuid4().hex}"
if filename:
# Sanitize filename to avoid path traversal
basename = os.path.basename(filename)
key = f"{self.prefix}{uuid.uuid4().hex}-{basename}"
self.s3.put_object(Bucket=self.bucket_name, Key=key, Body=data)
return key
async def delete_file(self, key: str) -> None:
self.s3.delete_object(Bucket=self.bucket_name, Key=key)
def get_storage() -> Storage:
"""Factory function returning the configured storage backend.
If the ``S3_BUCKET`` environment variable is set the service uses S3,
otherwise it falls back to local storage. Additional configuration options
such as ``S3_PREFIX`` and ``BACKUP_STORAGE_PATH`` can also be used to
customise the storage key prefix and local directory.
"""
bucket = os.getenv("S3_BUCKET")
if bucket:
prefix = os.getenv("S3_PREFIX", "backups/")
return S3Storage(bucket_name=bucket, prefix=prefix)
else:
root = os.getenv("BACKUP_STORAGE_PATH", "./data")
return LocalStorage(root_dir=root)

View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{{ title if title else 'Backup Service' }}</title>
<!-- Tailwind CSS via Play CDN -->
<!-- The Play CDN script adds Tailwind's utility classes to your HTML so you can start using them immediately【342154051484276†L285-L295】. -->
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
</head>
<body>
<header class="bg-gray-800 text-white">
<nav class="container mx-auto px-4 py-3 flex items-center space-x-4">
<a href="/" class="text-xl font-semibold">Backup Service</a>
<a href="/clients" class="hover:underline">Clients</a>
</nav>
</header>
<main class="container mx-auto p-4">
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,247 @@
{% extends "base.html" %}
{% block content %}
<h1 class="text-2xl font-bold mb-4">Client {{ client.id }} {{ client.name }}</h1>
<p class="mb-4 text-sm text-gray-700">Owner: <span class="font-medium">{{ client.owner.username }}</span> | Token: <code class="bg-gray-100 px-2 py-1 rounded text-sm">{{ client.token }}</code></p>
<p class="mb-6 text-sm text-gray-700">Last ping: {{ client.last_ping if client.last_ping else '-' }}<br>
Last backup: {{ client.last_backup if client.last_backup else '-' }}</p>
<!-- Pre-backup commands -->
<h2 class="text-xl font-semibold mb-2">PreBackup Commands</h2>
<form action="/api/clients/{{ client.id }}/config" method="post" class="mb-8 space-y-4">
<div>
<label for="pre_commands" class="block text-sm font-medium text-gray-700 mb-1">Enter commands (one per line):</label>
<textarea name="pre_commands" id="pre_commands" rows="4" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2">{{ client.pre_commands }}</textarea>
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Update Commands</button>
</form>
<!-- Backups table -->
<h2 class="text-xl font-semibold mb-2">Backups</h2>
{% if backups %}
<div class="overflow-x-auto mb-8">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Path</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version Time</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size (bytes)</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Hash</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Download</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for b in backups %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ b.id }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ b.original_path }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ b.version_time }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ b.size }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm font-mono text-gray-900">{{ b.file_hash.hash_value }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-blue-600">
<a href="/api/clients/{{ client.token }}/download/{{ b.id }}" class="hover:underline">Download</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mb-8 text-sm text-gray-700">No backups yet.</p>
{% endif %}
<h2 class="text-xl font-semibold mb-2">File Structure</h2>
{% if files %}
<div class="overflow-x-auto mb-8">
<ul class="list-disc pl-6 text-sm text-gray-800">
{% for f in files %}
<li>{{ f.path }}{% if f.is_dir %}/{% endif %}</li>
{% endfor %}
</ul>
</div>
{% else %}
<p class="mb-8 text-sm text-gray-700">No file structure available. The client will send its file list on the next backup cycle.</p>
{% endif %}
<!-- Tasks table -->
<h2 class="text-xl font-semibold mb-2">Backup Tasks</h2>
{% if tasks %}
<div class="overflow-x-auto mb-4">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Path</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Frequency (min)</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Retention</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Compress</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Run</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Next Run</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for t in tasks %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ t.path }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ t.frequency_minutes }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">
{% if t.retention_versions %}{{ t.retention_versions }} versions{% endif %}
{% if t.retention_days %}
{% if t.retention_versions %}<br>{% endif %}
{{ t.retention_days }} days
{% endif %}
{% if not t.retention_versions and not t.retention_days %}-{% endif %}
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{% if t.compress %}Yes{% else %}No{% endif %}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ t.last_run if t.last_run else '-' }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ t.next_run if t.next_run else '-' }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">
<form action="/api/clients/{{ client.id }}/tasks/{{ t.id }}/run" method="post">
<button type="submit" class="text-blue-600 hover:underline">Run Now</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mb-4 text-sm text-gray-700">No backup tasks configured.</p>
{% endif %}
<!-- Task history section -->
{% if tasks %}
<h3 class="text-lg font-semibold mb-2">Task History</h3>
<div class="space-y-6 mb-8">
{% for t in tasks %}
<div class="border border-gray-200 rounded-md p-4">
<h4 class="text-md font-medium mb-2">Path: {{ t.path }}</h4>
<!-- Show task run history -->
<h5 class="text-sm font-semibold mb-1">Runs</h5>
{% set runs = runs_map.get(t.id) %}
{% if runs and runs|length > 0 %}
<div class="overflow-x-auto mb-4">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Start</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">End</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Message</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for run in runs %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap">{{ run.start_time }}</td>
<td class="px-4 py-2 whitespace-nowrap">{{ run.end_time if run.end_time else '-' }}</td>
<td class="px-4 py-2 whitespace-nowrap">{{ run.status }}</td>
<td class="px-4 py-2 whitespace-pre-wrap break-all">{{ run.message or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-700 mb-4">No runs recorded for this task.</p>
{% endif %}
<!-- Show available backups for this task path -->
<h5 class="text-sm font-semibold mb-1">Backups</h5>
{% set bks = backups_map.get(t.path) %}
{% if bks and bks|length > 0 %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Version Time</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Size</th>
<th class="px-4 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Download</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for b in bks %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap">{{ b.id }}</td>
<td class="px-4 py-2 whitespace-nowrap">{{ b.version_time }}</td>
<td class="px-4 py-2 whitespace-nowrap">{{ b.size }}</td>
<td class="px-4 py-2 whitespace-nowrap text-blue-600"><a href="/api/clients/{{ client.token }}/download/{{ b.id }}" class="hover:underline">Download</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-700">No backups available for this path.</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Create new task -->
<h3 class="text-lg font-semibold mb-2">Create New Task</h3>
<form action="/api/clients/{{ client.id }}/tasks" method="post" class="space-y-4 mb-8">
<div>
<label for="path" class="block text-sm font-medium text-gray-700 mb-1">Path to backup</label>
<input type="text" name="path" id="path" list="file_paths" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" required>
<datalist id="file_paths">
{% for f in files %}
{% if not f.is_dir %}<option value="{{ f.path }}"></option>{% endif %}
{% endfor %}
</datalist>
</div>
<div>
<label for="frequency_minutes" class="block text-sm font-medium text-gray-700 mb-1">Frequency (minutes)</label>
<input type="number" name="frequency_minutes" id="frequency_minutes" min="1" value="60" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" required>
</div>
<div>
<label for="pre_commands_task" class="block text-sm font-medium text-gray-700 mb-1">Precommands (one per line)</label>
<textarea name="pre_commands" id="pre_commands_task" rows="3" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2"></textarea>
</div>
<div class="flex space-x-4">
<div class="w-1/2">
<label for="retention_versions" class="block text-sm font-medium text-gray-700 mb-1">Retention Versions (optional)</label>
<input type="number" name="retention_versions" id="retention_versions" min="1" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2">
</div>
<div class="w-1/2">
<label for="retention_days" class="block text-sm font-medium text-gray-700 mb-1">Retention Days (optional)</label>
<input type="number" name="retention_days" id="retention_days" min="1" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2">
</div>
</div>
<div class="flex items-center">
<input type="checkbox" name="compress" id="compress" value="true" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="compress" class="ml-2 block text-sm text-gray-700">Compress before uploading</label>
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">Add Task</button>
</form>
<!-- Logs table -->
<h2 class="text-xl font-semibold mb-2">Recent Logs</h2>
{% if logs %}
<div class="overflow-x-auto mb-8">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Message</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for log in logs %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ log.timestamp }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ log.level }}</td>
<td class="px-4 py-2 whitespace-pre-wrap break-all text-sm text-gray-900">{{ log.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mb-8 text-sm text-gray-700">No logs.</p>
{% endif %}
<p><a href="/clients" class="text-blue-600 hover:underline">&larr; Back to list</a></p>
{% endblock %}

View file

@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block content %}
<h1 class="text-2xl font-bold mb-4">Clients</h1>
<p class="mb-6">List of registered clients. Click on a client ID to view details.</p>
<!-- Clients table -->
<div class="overflow-x-auto mb-8">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Owner</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Ping</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Backup</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Commands</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for client in clients %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap text-sm font-medium text-blue-600">
<a href="/clients/{{ client.id }}" class="hover:underline">{{ client.id }}</a>
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ client.name }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ client.owner.username }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ client.last_ping if client.last_ping else '-' }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ client.last_backup if client.last_backup else '-' }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">
{% if client.pre_commands %}
{{ client.pre_commands|replace('\n', '<br>')|safe }}
{% else %}
-
{% endif %}
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-blue-600">
<a href="/clients/{{ client.id }}" class="hover:underline">Details</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Create new client form -->
<h2 class="text-xl font-semibold mb-2">Create New Client</h2>
<form action="/api/clients/register" method="post" class="mb-8 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="client_name">Client Name:</label>
<input id="client_name" type="text" name="name" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="owner_id">Owner ID (optional):</label>
<input id="owner_id" type="number" name="owner_id" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Register Client</button>
</form>
<!-- Create new user form -->
<h2 class="text-xl font-semibold mb-2">Create New User</h2>
<form action="/api/register_user" method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="username">Username:</label>
<input id="username" type="text" name="username" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="password">Password:</label>
<input id="password" type="password" name="password" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div class="flex items-center">
<input id="is_admin" type="checkbox" name="is_admin" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
<label for="is_admin" class="ml-2 block text-sm text-gray-700">Is Admin</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="retention_days">Retention Days:</label>
<input id="retention_days" type="number" name="retention_days" min="1" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="retention_versions">Retention Versions:</label>
<input id="retention_versions" type="number" name="retention_versions" min="1" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">Create User</button>
</form>
{% endblock %}

View file

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<h1 class="text-2xl font-bold mb-4">Backup Service Admin Dashboard</h1>
<p class="mb-6">Welcome, {{ user.username }}!</p>
<!-- Clients table -->
<h2 class="text-xl font-semibold mb-2">Clients</h2>
<div class="overflow-x-auto mb-8">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Client ID</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Owner</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Ping</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Backup</th>
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Token</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for client in clients %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ client.id }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ client.name }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ client.owner.username }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ client.last_ping if client.last_ping else '-' }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-900">{{ client.last_backup if client.last_backup else '-' }}</td>
<td class="px-4 py-2 whitespace-nowrap text-sm font-mono text-gray-900"><code>{{ client.token }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Create user form -->
<h2 class="text-xl font-semibold mb-2">Create User</h2>
<form action="/api/register_user" method="post" class="mb-8 space-y-4">
<div>
<label for="admin_username" class="block text-sm font-medium text-gray-700 mb-1">Username:</label>
<input id="admin_username" type="text" name="username" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label for="admin_password" class="block text-sm font-medium text-gray-700 mb-1">Password:</label>
<input id="admin_password" type="password" name="password" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div class="flex items-center">
<input id="admin_is_admin" type="checkbox" name="is_admin" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
<label for="admin_is_admin" class="ml-2 block text-sm text-gray-700">Is Admin</label>
</div>
<div>
<label for="admin_retention_days" class="block text-sm font-medium text-gray-700 mb-1">Retention Days:</label>
<input id="admin_retention_days" type="number" name="retention_days" min="1" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label for="admin_retention_versions" class="block text-sm font-medium text-gray-700 mb-1">Retention Versions:</label>
<input id="admin_retention_versions" type="number" name="retention_versions" min="1" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">Create User</button>
</form>
<!-- Create client form -->
<h2 class="text-xl font-semibold mb-2">Create Client</h2>
<form action="/api/clients/register" method="post" class="space-y-4">
<div>
<label for="new_client_name" class="block text-sm font-medium text-gray-700 mb-1">Client Name:</label>
<input id="new_client_name" type="text" name="name" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label for="new_client_owner" class="block text-sm font-medium text-gray-700 mb-1">Owner ID (optional):</label>
<input id="new_client_owner" type="number" name="owner_id" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Create Client</button>
</form>
{% endblock %}

187
tests/test_endpoints.py Normal file
View file

@ -0,0 +1,187 @@
"""Endtoend tests for the backup service API.
These tests exercise key functionality of the server to ensure that
registration, file structure reporting, task scheduling and status
reporting all work as expected. A temporary SQLite database is
used so that the tests do not affect production data. To execute
these tests, run ``pytest`` in the root of the repository.
Note: The tests import FastAPI and SQLAlchemy; ensure that these
dependencies are installed in your development environment. In
offline or minimal environments you may need to install them
manually before running the tests.
"""
import os
import tempfile
import pytest
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backup_service.server import models, auth, database, main as server_app
@pytest.fixture(scope="function")
def test_client(tmp_path):
"""Set up a FastAPI TestClient with an isolated SQLite database.
The fixture creates a temporary file for the SQLite database,
overrides the ``get_db`` dependency to use a session bound to
this database, and ensures that all tables are created before
returning the TestClient instance. After the test the overrides
are cleared.
"""
# Create a temporary SQLite database file
db_path = tmp_path / "test.db"
engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False})
# Bind a sessionmaker to the temporary engine
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create all tables on the temporary engine
models.Base.metadata.create_all(bind=engine)
# Dependency override to use the test session
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
server_app.app.dependency_overrides[database.get_db] = override_get_db
# Yield a TestClient instance
with TestClient(server_app.app) as client:
yield client
# Clear overrides after the test to avoid side effects
server_app.app.dependency_overrides.clear()
def _create_admin_user(db_session, username="admin", password="secret"):
"""Helper to insert an admin user into the database.
Returns the created User object. The password is hashed using
the server's authentication helper so that the login endpoint
functions correctly.
"""
user = models.User(
username=username,
hashed_password=auth.hash_password(password),
is_admin=True,
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
def test_register_and_backup_flow(test_client):
"""Endtoend test of client registration, file listing, task
creation and status reporting.
This test performs the following steps:
1. Insert an admin user into the test database.
2. Log in as the admin to obtain a JWT token.
3. Register a new client and capture its token and ID.
4. Post a file structure for the client and verify it can be retrieved.
5. Create a backup task via the admin API and verify it appears in the task list returned to the client.
6. Schedule the task to run immediately and record a success status.
7. Confirm that the task's ``last_run`` and ``next_run`` fields are updated accordingly.
"""
# Access the test DB via the overridden get_db dependency
db = next(server_app.app.dependency_overrides[database.get_db]())
# Create an admin user in the DB so that login works
admin = _create_admin_user(db)
# Step 1: login to get JWT
resp = test_client.post(
"/api/login",
data={"username": admin.username, "password": "secret"},
)
assert resp.status_code == 200
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# Step 2: register a new client
resp = test_client.post(
"/api/clients/register",
json={"name": "TestClient"},
headers=headers,
)
assert resp.status_code == 200
client_data = resp.json()
client_id = client_data["id"]
client_token = client_data["token"]
# Step 3: post file structure
file_list = [
{"path": "/data", "is_dir": True},
{"path": "/data/file.txt", "is_dir": False},
]
resp = test_client.post(f"/api/clients/{client_token}/files", json=file_list)
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
# Verify admin can retrieve the file structure
resp = test_client.get(f"/api/clients/{client_id}/files", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert len(data) == len(file_list)
paths = {entry["path"] for entry in data}
assert "/data" in paths and "/data/file.txt" in paths
# Step 4: create a task for backing up /data/file.txt every 5 minutes
resp = test_client.post(
f"/api/clients/{client_id}/tasks",
data={
"path": "/data/file.txt",
"frequency_minutes": "5",
"pre_commands": "echo pre",
"retention_versions": "3",
"retention_days": "30",
"compress": "true",
},
headers=headers,
)
# The endpoint returns a 303 redirect on success
assert resp.status_code == 303
# Fetch tasks as the client would
resp = test_client.get(f"/api/clients/{client_token}/tasks")
assert resp.status_code == 200
tasks = resp.json()
assert len(tasks) == 1
task = tasks[0]
assert task["path"] == "/data/file.txt"
assert task["frequency_minutes"] == 5
assert task["retention_versions"] == 3
assert task["retention_days"] == 30
assert task["compress"] is True
# Step 5: schedule the task to run immediately
resp = test_client.post(
f"/api/clients/{client_id}/tasks/{task['id']}/run",
headers=headers,
)
assert resp.status_code == 200
run_id = resp.json()["run_id"]
# Fetch tasks again; pending_run_id should be set
resp = test_client.get(f"/api/clients/{client_token}/tasks")
task_with_pending = resp.json()[0]
assert task_with_pending["pending_run_id"] == run_id
# Step 6: simulate client reporting success
resp = test_client.post(
f"/api/clients/{client_token}/tasks/{task['id']}/status",
data={"run_id": str(run_id), "status": "SUCCESS", "message": "ok"},
)
assert resp.status_code == 200
# After reporting, pending_run_id should be cleared
resp = test_client.get(f"/api/clients/{client_token}/tasks")
updated_task = resp.json()[0]
assert updated_task.get("pending_run_id") is None
# The last_run and next_run fields should be set
assert updated_task.get("last_run") is not None
assert updated_task.get("next_run") is not None