diff --git a/docs/cloud_backup.md b/docs/cloud_backup.md index 2edd26b..4924477 100644 --- a/docs/cloud_backup.md +++ b/docs/cloud_backup.md @@ -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. @@ -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. diff --git a/docs/manual_install.md b/docs/manual_install.md index 0d5ed3c..2cd031f 100644 --- a/docs/manual_install.md +++ b/docs/manual_install.md @@ -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 diff --git a/scripts/backup_cloud.sh b/scripts/backup_cloud.sh index efda971..ef8a82f 100644 --- a/scripts/backup_cloud.sh +++ b/scripts/backup_cloud.sh @@ -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 @@ -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 { @@ -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 @@ -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 diff --git a/scripts/ping_healthchecks.py b/scripts/ping_healthchecks.py new file mode 100644 index 0000000..e13f2df --- /dev/null +++ b/scripts/ping_healthchecks.py @@ -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()) diff --git a/simple_safer_server/services/cloud_backup_service.py b/simple_safer_server/services/cloud_backup_service.py index 4397bf3..9830fea 100644 --- a/simple_safer_server/services/cloud_backup_service.py +++ b/simple_safer_server/services/cloud_backup_service.py @@ -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, @@ -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" @@ -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) @@ -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: diff --git a/simple_safer_server/services/config_manager.py b/simple_safer_server/services/config_manager.py index f6426eb..484b71e 100644 --- a/simple_safer_server/services/config_manager.py +++ b/simple_safer_server/services/config_manager.py @@ -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'} diff --git a/simple_safer_server/services/healthchecks.py b/simple_safer_server/services/healthchecks.py new file mode 100644 index 0000000..817dc0d --- /dev/null +++ b/simple_safer_server/services/healthchecks.py @@ -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 diff --git a/simple_safer_server/services/task_service.py b/simple_safer_server/services/task_service.py index c56ab60..24c61d6 100644 --- a/simple_safer_server/services/task_service.py +++ b/simple_safer_server/services/task_service.py @@ -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: @@ -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() diff --git a/static/js/cloud_backup.js b/static/js/cloud_backup.js index 5bc99f5..31d8ef0 100644 --- a/static/js/cloud_backup.js +++ b/static/js/cloud_backup.js @@ -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'); @@ -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() { @@ -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; } @@ -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(() => { diff --git a/templates/cloud_backup.html b/templates/cloud_backup.html index 995ca87..6a79207 100644 --- a/templates/cloud_backup.html +++ b/templates/cloud_backup.html @@ -56,6 +56,12 @@