Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/cloud_backup.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ The Cloud Backup page manages cloud backup settings, schedules, and status.
## Backup Schedule & Bandwidth
- **Backup Time**: Set the daily backup time in two-digit 24-hour `HH:MM` format.
- **Bandwidth Limit**: (Optional) Limit backup bandwidth (e.g., 4M for 4 MB/s).
- **Healthchecks Success Ping URL**: (Optional) Ping a Healthchecks.io check only after the
cloud backup finishes successfully. Failed backups, drive checks, DDNS updates, and other tasks do
not send this ping.
- **Save**: Button to save schedule settings by disabling during the request.
- **Error/Success Feedback**: Inline messages for save actions.

Expand All @@ -37,6 +40,9 @@ Fake mode avoids local system changes, but cloud-backup provider calls can still
credentials and destinations are configured. Use a test destination when developing against a real
provider from fake mode.

If a Healthchecks success ping URL is configured in fake mode, a successful fake Cloud Backup run can
send the same success ping. Use a test Healthchecks check when developing.

- **Save**: Button to save backup configuration by disabling during the request.
- **Error/Success Feedback**: Inline messages for save actions.

Expand Down
2 changes: 1 addition & 1 deletion docs/manual_install.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ The app environment is `/opt/SimpleSaferServer/.venv`. Do not use distro Python
```bash
sudo mkdir -p /usr/local/bin
sudo cp scripts/* /usr/local/bin/
sudo chmod +x /usr/local/bin/check_mount.sh /usr/local/bin/check_health.sh /usr/local/bin/check_health.py /usr/local/bin/backup_cloud.sh /usr/local/bin/app_update.sh /usr/local/bin/app_update.py /usr/local/bin/log_alert.py /usr/local/bin/ddns_update.sh /usr/local/bin/ddns_update.py /usr/local/bin/restore_disabled_timers.py
sudo chmod +x /usr/local/bin/check_mount.sh /usr/local/bin/check_health.sh /usr/local/bin/check_health.py /usr/local/bin/backup_cloud.sh /usr/local/bin/ping_healthchecks.py /usr/local/bin/app_update.sh /usr/local/bin/app_update.py /usr/local/bin/log_alert.py /usr/local/bin/ddns_update.sh /usr/local/bin/ddns_update.py /usr/local/bin/restore_disabled_timers.py
```

## 7. Prepare Samba Layout
Expand Down
20 changes: 18 additions & 2 deletions scripts/backup_cloud.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/bin/bash

CONFIG_FILE="/etc/SimpleSaferServer/config.conf"
PYTHON_BIN="/opt/SimpleSaferServer/.venv/bin/python"
CONFIG_FILE="${SSS_CONFIG_FILE:-/etc/SimpleSaferServer/config.conf}"
PYTHON_BIN="${SSS_PYTHON_BIN:-/opt/SimpleSaferServer/.venv/bin/python}"
SCRIPTS_DIR="${SSS_SCRIPTS_DIR:-/opt/SimpleSaferServer/scripts}"

if [ ! -x "$PYTHON_BIN" ]; then
echo "Missing SimpleSaferServer Python environment at $PYTHON_BIN" >&2
Expand All @@ -24,6 +25,7 @@ EMAIL_ADDRESS=$(get_config_value backup email_address)
SERVER_NAME=$(get_config_value system server_name)
RCLONE_DIR=$(get_config_value backup rclone_dir)
BANDWIDTH_LIMIT=$(get_config_value backup bandwidth_limit)
HEALTHCHECKS_PING_URL=$(get_config_value backup healthchecks_ping_url)

# Function to send email and log alert
function send_email {
Expand All @@ -33,6 +35,19 @@ function send_email {
"$PYTHON_BIN" /opt/SimpleSaferServer/scripts/log_alert.py "$1" "$2" "error" "backup_cloud"
}

function ping_healthchecks_success {
if [ -z "$HEALTHCHECKS_PING_URL" ]; then
return 0
fi
# The ping URL contains the Healthchecks check UUID. Pass it on stdin so it
# does not appear in process arguments or journal lines.
if printf '%s' "$HEALTHCHECKS_PING_URL" | "$PYTHON_BIN" "$SCRIPTS_DIR/ping_healthchecks.py"; then
echo "Healthchecks.io success ping sent."
else
echo "Healthchecks.io success ping failed." >&2
fi
}

echo "Starting cloud backup process..."

# Check if drive is mounted
Expand Down Expand Up @@ -73,5 +88,6 @@ if ! rclone sync "$MOUNT_POINT" "$RCLONE_DIR" --create-empty-src-dirs -v "${extr
exit 1
fi

ping_healthchecks_success
echo "Cloud backup completed successfully"
exit 0
23 changes: 23 additions & 0 deletions scripts/ping_healthchecks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
import sys

from simple_safer_server.services.healthchecks import (
HealthchecksPingError,
ping_healthchecks_url,
)


def main() -> int:
url = sys.stdin.read().strip()
if not url:
return 0
try:
ping_healthchecks_url(url)
except (HealthchecksPingError, ValueError) as exc:
print(f"Healthchecks.io success ping failed: {exc}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
41 changes: 30 additions & 11 deletions simple_safer_server/services/cloud_backup_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Any

from simple_safer_server.adapters.command_runner import PIPE, CommandRunner
from simple_safer_server.services.healthchecks import normalize_healthchecks_ping_url
from simple_safer_server.services.schedule_time import (
ScheduleTimeError,
normalize_ui_schedule_time,
Expand Down Expand Up @@ -73,6 +74,7 @@ def get_config(self) -> dict[str, Any]:
"mega_folder": backup.get("mega_folder", ""),
"rclone_dir": backup.get("rclone_dir", ""),
"bandwidth_limit": backup.get("bandwidth_limit", ""),
"healthchecks_ping_url": backup.get("healthchecks_ping_url", ""),
"backup_cloud_time": schedule.get("backup_cloud_time", ""),
}
rclone_conf_path = self._runtime.rclone_config_dir / "rclone.conf"
Expand Down Expand Up @@ -199,12 +201,20 @@ def get_schedule(self) -> dict[str, Any]:
return {
"backup_cloud_time": schedule.get("backup_cloud_time", ""),
"bandwidth_limit": backup.get("bandwidth_limit", ""),
"healthchecks_ping_url": backup.get("healthchecks_ping_url", ""),
}

def save_schedule(self, data: dict[str, Any]) -> dict[str, Any]:
backup_time = data.get("backup_cloud_time")
has_bandwidth_limit = "bandwidth_limit" in data
bandwidth_limit = normalize_bandwidth_limit(data.get("bandwidth_limit"))
has_healthchecks_ping_url = "healthchecks_ping_url" in data
try:
healthchecks_ping_url = normalize_healthchecks_ping_url(
data.get("healthchecks_ping_url")
)
except ValueError as exc:
raise ValidationProblem(str(exc)) from exc
if backup_time:
try:
backup_time = normalize_ui_schedule_time(backup_time)
Expand All @@ -216,25 +226,34 @@ def save_schedule(self, data: dict[str, Any]) -> dict[str, Any]:
self._config_manager.set_value("schedule", "backup_cloud_time", backup_time)
if has_bandwidth_limit:
self._config_manager.set_value("backup", "bandwidth_limit", bandwidth_limit)
if has_healthchecks_ping_url:
self._config_manager.set_value(
"backup", "healthchecks_ping_url", healthchecks_ping_url
)
return {}

config = self._config_manager.get_all_config()
if backup_time:
config.setdefault("schedule", {})["backup_cloud_time"] = backup_time
if has_bandwidth_limit:
config.setdefault("backup", {})["bandwidth_limit"] = bandwidth_limit
ok, err = self._system_utils.create_systemd_config_file(config)
if not ok:
raise OperationProblem(f"Failed to update systemd config: {err}")
if backup_time or has_bandwidth_limit:
config = self._config_manager.get_all_config()
if backup_time:
config.setdefault("schedule", {})["backup_cloud_time"] = backup_time
if has_bandwidth_limit:
config.setdefault("backup", {})["bandwidth_limit"] = bandwidth_limit
if has_healthchecks_ping_url:
config.setdefault("backup", {})["healthchecks_ping_url"] = healthchecks_ping_url
ok, err = self._system_utils.create_systemd_config_file(config)
if not ok:
raise OperationProblem(f"Failed to update systemd config: {err}")

ok, err = self._system_utils.install_systemd_services_and_timers(config)
if not ok:
raise OperationProblem(f"Failed to update systemd timers: {err}")
ok, err = self._system_utils.install_systemd_services_and_timers(config)
if not ok:
raise OperationProblem(f"Failed to update systemd timers: {err}")

if backup_time:
self._config_manager.set_value("schedule", "backup_cloud_time", backup_time)
if has_bandwidth_limit:
self._config_manager.set_value("backup", "bandwidth_limit", bandwidth_limit)
if has_healthchecks_ping_url:
self._config_manager.set_value("backup", "healthchecks_ping_url", healthchecks_ping_url)
return {}

def validate_mega(self, data: dict[str, Any]) -> None:
Expand Down
1 change: 1 addition & 0 deletions simple_safer_server/services/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def _default_config_parser(self):
'mount_point': self.runtime.default_mount_point,
'rclone_dir': '',
'bandwidth_limit': '',
'healthchecks_ping_url': '',
}

config['schedule'] = {'backup_cloud_time': '03:00'}
Expand Down
51 changes: 51 additions & 0 deletions simple_safer_server/services/healthchecks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen

HEALTHCHECKS_TIMEOUT_SECONDS = 10


class HealthchecksPingError(RuntimeError):
"""Raised when a configured Healthchecks.io ping could not be sent."""


def normalize_healthchecks_ping_url(value) -> str:
"""Return a safe optional Healthchecks.io ping URL."""
if value is None:
return ""
url = str(value).strip()
if not url:
return ""
if any(character.isspace() for character in url):
raise ValueError("Healthchecks success ping URL must not contain spaces.")
if len(url) > 2048:
raise ValueError("Healthchecks success ping URL is too long.")

parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("Healthchecks success ping URL must start with http:// or https://.")
return url


def ping_healthchecks_url(url: str, *, timeout: int = HEALTHCHECKS_TIMEOUT_SECONDS) -> None:
"""Ping a Healthchecks.io URL without putting the URL in a command line."""
normalized_url = normalize_healthchecks_ping_url(url)
if not normalized_url:
return

request = Request(
normalized_url,
method="GET",
headers={"User-Agent": "SimpleSaferServer cloud-backup"},
)
try:
with urlopen(request, timeout=timeout) as response:
status = getattr(response, "status", response.getcode())
if status >= 400:
raise HealthchecksPingError(f"Healthchecks.io returned HTTP {status}.")
except HTTPError as exc:
raise HealthchecksPingError(f"Healthchecks.io returned HTTP {exc.code}.") from exc
except URLError as exc:
# Do not include the target URL here; the path usually contains the
# Healthchecks check UUID and can end up in service logs.
raise HealthchecksPingError(f"Healthchecks.io request failed: {exc.reason}") from exc
16 changes: 16 additions & 0 deletions simple_safer_server/services/task_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
hdsentinel_snapshot_has_health,
run_scheduled_drive_health_check,
)
from simple_safer_server.services.healthchecks import HealthchecksPingError, ping_healthchecks_url


class Status:
Expand Down Expand Up @@ -524,6 +525,21 @@ def _run_fake_cloud_backup(self, cancel_event: threading.Event) -> None:
raise RuntimeError("Cloud backup was cancelled.")
if proc.returncode != 0:
raise RuntimeError(output.strip() or "Cloud backup failed.")
self._ping_healthchecks_after_cloud_backup_success(fake_state)

def _ping_healthchecks_after_cloud_backup_success(self, fake_state: Any) -> None:
ping_url = self.config_manager.get_value("backup", "healthchecks_ping_url", "").strip()
if not ping_url:
return
try:
ping_healthchecks_url(ping_url)
fake_state.append_task_log("Cloud Backup", "Healthchecks.io success ping sent.")
except (HealthchecksPingError, ValueError) as exc:
# A Healthchecks network problem should not turn a completed cloud
# backup into a failed backup. Keep the URL out of fake task logs.
fake_state.append_task_log(
"Cloud Backup", f"Healthchecks.io success ping failed: {exc}"
)

def _start_fake_task(self, task_name: str) -> None:
fake_state = self._require_fake_state()
Expand Down
8 changes: 7 additions & 1 deletion static/js/cloud_backup.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ document.addEventListener('DOMContentLoaded', function () {
const remoteName = document.getElementById('remoteName');
const backupTime = document.getElementById('backupTime');
const bandwidthLimit = document.getElementById('bandwidthLimit');
const healthchecksPingUrl = document.getElementById('healthchecksPingUrl');

const scheduleForm = document.getElementById('cloud-backup-schedule-form');
const scheduleSaveBtn = document.getElementById('cloud-backup-schedule-save-btn');
Expand Down Expand Up @@ -106,6 +107,7 @@ document.addEventListener('DOMContentLoaded', function () {
function fillScheduleForm(cfg) {
backupTime.value = (cfg.backup_cloud_time || '').padStart(5, '0');
bandwidthLimit.value = cfg.bandwidth_limit || '';
healthchecksPingUrl.value = cfg.healthchecks_ping_url || '';
}

function loadSchedule() {
Expand All @@ -124,6 +126,9 @@ document.addEventListener('DOMContentLoaded', function () {
if (!backupTime.value) {
backupTime.classList.add('is-invalid'); valid = false;
} else { backupTime.classList.remove('is-invalid'); }
if (healthchecksPingUrl.value.trim() && !/^https?:\/\/\S+$/.test(healthchecksPingUrl.value.trim())) {
healthchecksPingUrl.classList.add('is-invalid'); valid = false;
} else { healthchecksPingUrl.classList.remove('is-invalid'); }
if (!valid) {
return;
}
Expand All @@ -133,7 +138,8 @@ document.addEventListener('DOMContentLoaded', function () {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
backup_cloud_time: backupTime.value,
bandwidth_limit: bandwidthLimit.value.trim()
bandwidth_limit: bandwidthLimit.value.trim(),
healthchecks_ping_url: healthchecksPingUrl.value.trim()
})
})
.then(() => {
Expand Down
6 changes: 6 additions & 0 deletions templates/cloud_backup.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@
<div class="invalid-feedback">Please enter a valid bandwidth limit.</div>
</div>
</div>
<div class="form-group mb-4">
<label class="form-label">Healthchecks Success Ping URL <span class="text-muted">(optional)</span></label>
<input type="url" class="form-control" id="healthchecksPingUrl" pattern="https?://.*" placeholder="https://hc-ping.com/your-check-id">
<div class="form-text text-muted" style="font-size: var(--text-xs);">Pinged only after a completed cloud backup.</div>
<div class="invalid-feedback">Please enter a valid http:// or https:// URL.</div>
</div>
<div class="d-flex gap-2 justify-end">
<button type="submit" id="cloud-backup-schedule-save-btn" class="btn btn-primary btn-sm">Save</button>
</div>
Expand Down
Loading
Loading