diff --git a/.env.example b/.env.example index 173a077..3391858 100644 --- a/.env.example +++ b/.env.example @@ -151,7 +151,10 @@ BACKUP_SFTP_HOST= BACKUP_SFTP_PORT=22 BACKUP_SFTP_USER= # Distinct from gecko-notes', so the two apps' snapshots do not share a path. -BACKUP_SFTP_REMOTE_PATH=/gam +BACKUP_SFTP_REMOTE_PATH=/GAM/restic +# Optional plain-file mirror beside the restic repository: SFTP chroot path, e.g. +# /GAM/mirror. Empty = off. See ops/backup/README.md step 13. +BACKUP_MIRROR_PATH= BACKUP_CRON_SCHEDULE=0 * * * * BACKUP_KEEP_HOURLY=48 BACKUP_KEEP_DAILY=14 diff --git a/backend/app/auth.py b/backend/app/auth.py index 9784ed1..4f424b1 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -35,7 +35,7 @@ from jose import JWTError, jwt from jose.exceptions import ExpiredSignatureError from pydantic import BaseModel -from sqlmodel import Session +from sqlmodel import Session, select from app.config import settings from app.database import get_session @@ -144,8 +144,26 @@ def _origin_allowed_for_cookie(request: Request) -> bool: return any(origin_host == _host_of(allowed) for allowed in settings.cors_origins) +def _read_only_user(session: Session) -> UserCtx: + """The one person a read-only archive acts as. + + Matched by id first, then by username, so `READ_ONLY_USER` can be whichever the + operator has to hand. Exact matches only, for the reason `is_admin` gives. An entry + matching no row is used as an id as given: the archive then shows an empty library, + which says plainly that the setting is wrong. + """ + wanted = settings.read_only_user.strip() + record = session.get(User, wanted) + if record is None: + record = session.exec(select(User).where(User.username == wanted)).first() + if record is None: + return UserCtx(id=wanted, username=wanted) + return UserCtx(id=record.id, username=record.username) + + def current_user( request: Request, + session: Session = Depends(get_session), authorization: Annotated[Optional[str], Header()] = None, gecko_session: Annotated[Optional[str], Cookie()] = None, ) -> UserCtx: @@ -155,6 +173,11 @@ def current_user( explicitly rather than read off the request so it shows up in the OpenAPI schema and can be overridden in tests. """ + # Before any token is looked at: an archive cannot verify one (it deliberately does + # not hold production's secret — see docs/deployment.md), and has nobody to ask. + if settings.read_only and settings.read_only_user: + return _read_only_user(session) + token: Optional[str] = None # Which transport carried it, because it changes what a failure *means*. A cookie # was minted by Notes moments ago and handed over by the browser; if it will not diff --git a/backend/app/config.py b/backend/app/config.py index d374653..d09a539 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import List -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict REPO_ROOT = Path(__file__).resolve().parents[2] @@ -128,6 +128,18 @@ class Settings(BaseSettings): generation_base_max_edge: int = 2048 generation_max_data_uri_mb: int = 8 + # ─── read-only archive ─────────────────────────────────────────────────── + # For a copy of the app run against the plain-file mirror the backup sidecar keeps + # on the NAS (ops/backup/README.md, "Plain-file mirror"). The database is opened + # read-only, every non-GET request is refused, and no worker starts — restart + # recovery and the stale sweep both write. + read_only: bool = False + # Who the archive acts as, by id or username. Instead of signing in: Notes only + # redirects back to *.geckopico.com, and an archive that needs Notes up to open is + # no use on the day production is down. So an archive must never be reachable from + # anywhere but a trusted network. + read_only_user: str = "" + # ─── development ───────────────────────────────────────────────────────── # Bypasses token verification and acts as this user id. Refused unless # `environment` is "development", so it cannot be switched on in production by @@ -146,6 +158,17 @@ def _reject_weak_secret(cls, value: str) -> str: ) return value + @model_validator(mode="after") + def _read_only_user_needs_read_only(self) -> "Settings": + # The same two-condition rule as dev auth, but refused outright rather than + # ignored: READ_ONLY_USER turns sign-in off, and a production .env that picked + # it up from an archive's should stop the app, not quietly keep it running. + if self.read_only_user and not self.read_only: + raise ValueError( + "READ_ONLY_USER disables sign-in and is only allowed with READ_ONLY=true." + ) + return self + @property def resolved_database_url(self) -> str: if self.database_url: diff --git a/backend/app/database.py b/backend/app/database.py index 9aed297..2fc8712 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -11,6 +11,7 @@ from typing import Iterator from sqlalchemy import event +from sqlalchemy.pool import NullPool from sqlmodel import Session, create_engine from app.config import settings @@ -19,7 +20,28 @@ DATABASE_URL = settings.resolved_database_url -if DATABASE_URL.startswith("sqlite:///"): + +def read_only_url(url: str) -> str: + """The same SQLite file, opened so that no write can reach it. + + `mode=ro` rather than relying on the `:ro` bind mount alone: on a read-only mount + SQLite still opens read-write and fails on the first write with "attempt to write a + readonly database" — after the request has already done half its work. `mode=ro` + fails it at the statement, and keeps a mirror mounted read-write safe as well. + + Needs a file in rollback-journal mode. A WAL file opened `mode=ro` still has to + create its `-shm`, which a read-only mount cannot hold; the mirror converts its + copy before uploading it (ops/backup/backup.sh). + """ + if not url.startswith("sqlite:///"): + return url + path = url.replace("sqlite:///", "", 1) + return f"sqlite:///file:{path}?mode=ro&uri=true" + + +if settings.read_only: + DATABASE_URL = read_only_url(DATABASE_URL) +elif DATABASE_URL.startswith("sqlite:///"): Path(DATABASE_URL.replace("sqlite:///", "", 1)).parent.mkdir(parents=True, exist_ok=True) engine = create_engine( @@ -28,6 +50,11 @@ # it. Background workers run on their own threads and open their own sessions, # so the check has to come off. connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}, + # A read-only archive's database is replaced under it on every mirror run, by a + # rename. A pooled connection holds the old file open and goes on reading it, so + # the archive would show the day it started until somebody restarted it. A fresh + # connection per session costs a file open, which SQLite does in microseconds. + **({"poolclass": NullPool} if settings.read_only else {}), echo=False, ) @@ -43,8 +70,10 @@ def _set_sqlite_pragmas(dbapi_connection, connection_record): which is the right trade for a single-host media library. """ cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA journal_mode=WAL") - cursor.execute("PRAGMA synchronous=NORMAL") + # Both are writes to the file header, so a read-only connection refuses them. + if not settings.read_only: + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") cursor.execute("PRAGMA foreign_keys=ON") cursor.close() diff --git a/backend/app/main.py b/backend/app/main.py index 5ce0071..6be159a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,7 @@ import shutil from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException @@ -47,7 +47,8 @@ @asynccontextmanager async def lifespan(_app: FastAPI): - os.makedirs(settings.resolved_media_dir, exist_ok=True) + if not settings.read_only: + os.makedirs(settings.resolved_media_dir, exist_ok=True) if ffmpeg_available(): logger.info("ffmpeg available: %s", ffmpeg_version()) else: @@ -70,10 +71,15 @@ async def lifespan(_app: FastAPI): log_if_behind(engine) - # Picks up anything a restart interrupted, and starts the worker threads. - from app.jobs import enrichment + # Picks up anything a restart interrupted, and starts the worker threads. Not in a + # read-only archive: recovery would requeue whatever production had running when + # the mirror was taken, and every step of a job is a write. + if settings.read_only: + logger.info("Read-only mode: the database is opened read-only and no workers start.") + else: + from app.jobs import enrichment - enrichment.start() + enrichment.start() yield @@ -84,6 +90,30 @@ async def lifespan(_app: FastAPI): # gecko-notes deployment, where the app container is never reachable directly. app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") +_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + + +@app.middleware("http") +async def _refuse_writes_when_read_only(request: Request, call_next): + """In a read-only archive, refuse every write before any router sees it. + + The read-only database would refuse them anyway, but only as a 500 from partway + through a handler, after an upload had already been streamed to a disk that + cannot hold it. Registered before CORS so the refusal still carries CORS headers. + """ + if settings.read_only and request.method.upper() not in _SAFE_METHODS: + return JSONResponse( + status_code=403, + content={ + "detail": { + "code": "read_only", + "message": "This is a read-only archive copy. Make changes in the live app.", + } + }, + ) + return await call_next(request) + + if not settings.cors_origins: logger.warning("CORS_ORIGIN is not set — cross-origin API requests will be blocked.") @@ -141,7 +171,9 @@ def client_config() -> DataResponse[ClientConfig]: `NOTES_BASE_URL` could be set in `.env`, plumbed through docker-compose, and still have no effect on where the login button went. """ - return DataResponse(data=ClientConfig(notes_base_url=settings.notes_base_url)) + return DataResponse( + data=ClientConfig(notes_base_url=settings.notes_base_url, read_only=settings.read_only) + ) @app.get("/api/me", response_model=DataResponse[dict]) @@ -155,9 +187,14 @@ def me( authenticated request would put a SELECT — and periodically a write — in front of every upload and every media byte, for a row nothing on those paths reads. """ + from app.models.user import User from app.services.users import ensure_user - record = ensure_user(session, user) + # Read, not upserted, in an archive: the write would only fail — harmlessly, but + # with a warning on every page load. + record = session.get(User, user.id) if settings.read_only else None + if record is None: + record = ensure_user(session, user) return DataResponse( data={ "id": record.id, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 80991f0..f5cb717 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -45,6 +45,9 @@ class ClientConfig(BaseModel): """ notes_base_url: str + # So the frontend can say it is an archive. Public by nature: anyone who can reach + # an archive can already see that writes are refused. + read_only: bool = False class HealthResponse(BaseModel): diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 5c2daad..8ca90f4 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -6,8 +6,18 @@ # which the orchestrator can see, and a half-migrated app cannot tell you. set -e -echo "Running database migrations..." -alembic upgrade head +# Never against a read-only archive. Its database is a mirror of production's: migrating +# it would fail on the read-only mount, and if it did not, the next mirror run would put +# production's schema back over it. +case "$(echo "${READ_ONLY:-}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) + echo "Read-only mode: skipping migrations." + ;; + *) + echo "Running database migrations..." + alembic upgrade head + ;; +esac echo "Starting API..." exec uvicorn app.main:app --host 0.0.0.0 --port 8000 "$@" diff --git a/backend/tests/test_client_config.py b/backend/tests/test_client_config.py index fc511cf..42d3dfb 100644 --- a/backend/tests/test_client_config.py +++ b/backend/tests/test_client_config.py @@ -38,5 +38,5 @@ def test_it_exposes_nothing_but_what_it_promises(client): """ data = client.get("/api/config").json()["data"] - assert set(data) == {"notes_base_url"} + assert set(data) == {"notes_base_url", "read_only"} assert settings.jwt_secret_key not in str(data) diff --git a/backend/tests/test_read_only.py b/backend/tests/test_read_only.py new file mode 100644 index 0000000..9994627 --- /dev/null +++ b/backend/tests/test_read_only.py @@ -0,0 +1,116 @@ +"""Read-only mode: the archive copy run against the NAS mirror. + +What these pin is that the archive cannot change anything and cannot be talked into +signing somebody else in — the two properties an operator relies on when they point it +at the only other copy of their library. +""" + +import sqlite3 + +import pytest +from pydantic import ValidationError +from sqlalchemy import text +from sqlalchemy.exc import OperationalError +from sqlmodel import create_engine + +from app.config import Settings, settings +from app.database import read_only_url +from app.models.user import User + + +@pytest.fixture +def read_only(monkeypatch): + monkeypatch.setattr(settings, "read_only", True) + + +@pytest.mark.parametrize( + "method, path", + [ + ("post", "/api/tags"), + ("patch", "/api/assets/some-id"), + ("delete", "/api/assets/some-id"), + ("put", "/api/settings/generation"), + ], +) +def test_every_write_is_refused(auth_client, read_only, method, path): + response = auth_client.request(method.upper(), path, json={}) + + assert response.status_code == 403 + assert response.json()["detail"]["code"] == "read_only" + + +def test_reads_still_work(auth_client, read_only): + assert auth_client.get("/api/assets").status_code == 200 + + +def test_writes_are_untouched_when_not_read_only(auth_client): + """The guard must not leak into a normal deployment.""" + # Refused for the body it sent, by the router — not by the guard. + assert auth_client.post("/api/tags", json={}).status_code == 422 + + +def test_config_says_it_is_read_only(client, read_only): + assert client.get("/api/config").json()["data"]["read_only"] is True + + +def test_the_archive_acts_as_its_user_by_id(client, session, read_only, monkeypatch): + session.add(User(id="owner-id", username="owner")) + session.commit() + monkeypatch.setattr(settings, "read_only_user", "owner-id") + + data = client.get("/api/me").json()["data"] + + assert (data["id"], data["username"]) == ("owner-id", "owner") + + +def test_the_archive_acts_as_its_user_by_username(client, session, read_only, monkeypatch): + session.add(User(id="owner-id", username="owner")) + session.commit() + monkeypatch.setattr(settings, "read_only_user", "owner") + + assert client.get("/api/me").json()["data"]["id"] == "owner-id" + + +def test_a_token_cannot_choose_someone_else(client, session, read_only, monkeypatch): + """Whatever arrives in the header, the archive is its one configured user.""" + monkeypatch.setattr(settings, "read_only_user", "owner-id") + + response = client.get("/api/me", headers={"Authorization": "Bearer anything"}) + + assert response.json()["data"]["id"] == "owner-id" + + +def test_read_only_user_alone_does_not_disable_sign_in(client, monkeypatch): + """Belt to the validator's braces: patched in directly, it still does nothing.""" + monkeypatch.setattr(settings, "read_only_user", "owner-id") + + assert client.get("/api/me").status_code == 401 + + +def test_settings_refuse_read_only_user_without_read_only(monkeypatch): + for name in ("READ_ONLY", "READ_ONLY_USER"): + monkeypatch.delenv(name, raising=False) + with pytest.raises(ValidationError): + Settings(_env_file=None, jwt_secret_key="a" * 64, read_only_user="owner") + + allowed = Settings( + _env_file=None, jwt_secret_key="a" * 64, read_only=True, read_only_user="owner" + ) + assert allowed.read_only_user == "owner" + + +def test_the_read_only_engine_reads_and_cannot_write(tmp_path): + path = tmp_path / "gam.db" + with sqlite3.connect(path) as db: + db.execute("CREATE TABLE t (x INTEGER)") + db.execute("INSERT INTO t VALUES (1)") + + engine = create_engine(read_only_url(f"sqlite:///{path}")) + with engine.connect() as connection: + assert connection.execute(text("SELECT x FROM t")).scalar() == 1 + with pytest.raises(OperationalError, match="readonly"): + connection.execute(text("INSERT INTO t VALUES (2)")) + + +def test_read_only_url_leaves_other_databases_alone(): + assert read_only_url("postgresql://x/y") == "postgresql://x/y" diff --git a/docker-compose.yml b/docker-compose.yml index 774fbc7..e303179 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,9 @@ services: - BACKUP_SFTP_HOST=${BACKUP_SFTP_HOST} - BACKUP_SFTP_PORT=${BACKUP_SFTP_PORT:-22} - BACKUP_SFTP_USER=${BACKUP_SFTP_USER} - - BACKUP_SFTP_REMOTE_PATH=${BACKUP_SFTP_REMOTE_PATH:-/gam} + - BACKUP_SFTP_REMOTE_PATH=${BACKUP_SFTP_REMOTE_PATH:-/GAM/restic} + # Empty leaves the plain-file mirror off; restic runs either way. + - BACKUP_MIRROR_PATH=${BACKUP_MIRROR_PATH:-} - BACKUP_CRON_SCHEDULE=${BACKUP_CRON_SCHEDULE:-0 * * * *} - BACKUP_KEEP_HOURLY=${BACKUP_KEEP_HOURLY:-48} - BACKUP_KEEP_DAILY=${BACKUP_KEEP_DAILY:-14} diff --git a/docs/deployment.md b/docs/deployment.md index 0080046..bece9dd 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -110,6 +110,13 @@ GAM and Notes can share one Synology and one restic repository — the distinct `--host` tag and `BACKUP_SFTP_REMOTE_PATH` keep their snapshots apart, so `restic forget` prunes each app's history separately. +Optionally the same run also keeps a **plain-file mirror** on the NAS (`BACKUP_MIRROR_PATH`, +step 13 of the backup README): the same `db/` + `media/` layout as `data/`, as ordinary +files. A read-only copy of the app can run from it in Synology's Container Manager, with +no dependency on this host or on Notes — see [`ops/archive/README.md`](../ops/archive/README.md). +That copy sets `READ_ONLY=true` and `READ_ONLY_USER`; the app refuses to start with the +second and not the first, so neither can turn sign-in off here by accident. + ## Disk space **Settings → Storage** shows what the library takes up and what the disk has left. Every diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index dd0e5fb..8de0819 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -15,6 +15,8 @@ import client from '@/api/client' export interface ClientConfig { notes_base_url: string + /** A copy run against the NAS mirror: the server refuses every write. */ + read_only: boolean } interface DataResponse { diff --git a/frontend/src/components/AppShell.test.tsx b/frontend/src/components/AppShell.test.tsx index d4134b5..9118494 100644 --- a/frontend/src/components/AppShell.test.tsx +++ b/frontend/src/components/AppShell.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AppShell from '@/components/AppShell' import { storageApi } from '@/api/storage' +import * as configApi from '@/api/config' import { useActivityStore } from '@/stores/activity' import { useAuthStore } from '@/stores/auth' @@ -23,6 +24,13 @@ afterEach(() => { vi.restoreAllMocks() }) +function mockConfig(readOnly: boolean) { + vi.spyOn(configApi, 'loadConfig').mockResolvedValue({ + notes_base_url: 'https://notes.example.test', + read_only: readOnly, + }) +} + function renderShell() { return render( @@ -54,4 +62,25 @@ describe('AppShell', () => { expect(signOut).toHaveBeenCalled() }) + + it('says when it is a read-only archive, and offers no sign-out there', async () => { + mockConfig(true) + useAuthStore.setState({ + user: { id: 'u1', username: 'tester', is_admin: false }, + status: 'authenticated', + }) + + renderShell() + + expect(await screen.findByText(/read-only archive copy/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /sign out/i })).not.toBeInTheDocument() + }) + + it('shows no archive banner in the live app', async () => { + mockConfig(false) + renderShell() + + await screen.findByText('Content') + expect(screen.queryByText(/read-only archive copy/i)).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index dc6a069..0b4879d 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,6 +1,7 @@ -import type { ReactNode } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { LogOut, Moon, Search, Settings, Sun } from 'lucide-react' import { Link } from 'react-router-dom' +import { currentConfig, loadConfig } from '@/api/config' import ActivityIndicator from '@/components/ActivityIndicator' import StorageWarning from '@/components/StorageWarning' import { useAuthStore } from '@/stores/auth' @@ -21,6 +22,20 @@ export default function AppShell({ children }: Props) { const signOut = useAuthStore((s) => s.signOut) const theme = useThemeStore((s) => s.theme) const toggleTheme = useThemeStore((s) => s.toggle) + const [readOnly, setReadOnly] = useState(() => currentConfig()?.read_only ?? false) + + useEffect(() => { + let stale = false + loadConfig() + .then((config) => { + if (!stale) setReadOnly(config.read_only) + }) + // App already reports an unreachable backend; a missing banner adds nothing. + .catch(() => {}) + return () => { + stale = true + } + }, []) return (
@@ -58,7 +73,9 @@ export default function AppShell({ children }: Props) { - {user && ( + {/* No sign-out on an archive: it signs nobody in, so the next request + would put the same user straight back. */} + {user && !readOnly && ( <> {user.username} @@ -81,6 +98,19 @@ export default function AppShell({ children }: Props) {
+ {readOnly && ( + // Said up front rather than left to the first refused save: an archive looks + // exactly like the live app, and someone who edits it should know before they + // start that nothing they do will be kept. +
+ Read-only archive copy. Browse and play anything; changes are made in the live + app. +
+ )} +
{children}
) diff --git a/ops/archive/.env.example b/ops/archive/.env.example new file mode 100644 index 0000000..008ace1 --- /dev/null +++ b/ops/archive/.env.example @@ -0,0 +1,15 @@ +# Copy to ops/archive/.env on the NAS and fill in. Setup: ops/archive/README.md. + +# The mirror on the NAS, as a DSM path (/volume1/...), not the SFTP chroot path +# (/GeckoNotes/...) that BACKUP_MIRROR_PATH uses on the production host. +GAM_MIRROR_DIR=/volume1/GeckoNotes/gam-mirror + +# Whose library the archive shows: your GAM user id, or your Notes username. +READ_ONLY_USER= + +# A NEW secret, not production's: openssl rand -hex 32 +JWT_SECRET_KEY= + +# The port on the NAS. LAN or Tailscale only — never port-forward it. +ARCHIVE_PORT=18083 +TZ=UTC diff --git a/ops/archive/README.md b/ops/archive/README.md new file mode 100644 index 0000000..053d8c4 --- /dev/null +++ b/ops/archive/README.md @@ -0,0 +1,168 @@ +# Read-only archive: GAM on the Synology + +A copy of GAM that runs in Container Manager on the NAS against the plain-file mirror +the backup sidecar keeps there. It shows your library exactly as of the last mirror +run, plays every video, and opens every image and document. It changes nothing: the +database is opened read-only from a read-only mount, every save is refused, and no +background job runs. + +It needs nothing from production. It doesn't use Notes, the gam host or the internet, +which is the point: it is how you get at the library on the day those are down. + +What it is not: + +- **Not signed in.** Notes only redirects back to `*.geckopico.com`, and an archive that + needed Notes would be useless on exactly the day you want it. So it shows one user's + library, `READ_ONLY_USER`'s, to **whoever can reach its port**. Keep it on your LAN or + Tailscale. Never port-forward it. +- **Not semantic search.** Provider API keys are encrypted with production's + `JWT_SECRET_KEY`, which deliberately does not go to the NAS (it would let anyone who + can read the NAS mint a Notes session). Search falls back to text matching, and + generation, transcription and the rest are off anyway. +- **Not live.** It is as fresh as the last mirror run — hourly by default. + +--- + +## Before you start + +- The mirror works: steps 1–14 of [`ops/backup/README.md`](../backup/README.md) are done + and step 14 shows `MIRROR_INFO`, `db/` and `media/` under `GAM/mirror`. +- The NAS runs **DSM 7.2 or later** and its model supports **Container Manager** + (Package Center shows it; most Plus-series models and a few ARM64 ones do). Building + the images needs about 3 GB free on the volume holding the `docker` share. + +## 1. Install Container Manager + +**Package Center** → search **Container Manager** → Install. It creates a `docker` +shared folder, which is where the app's code goes. + +## 2. Put the code on the NAS + +The archive must run the **same version** as production, or near it. Code older than +the mirrored database cannot read the newer schema. Find production's commit on the gam +host: + +```bash +cd /opt/gam && git rev-parse HEAD +``` + +On GitHub, signed in (the repository is private), download that commit as a ZIP: +`https://github.com/davior/gam/archive/.zip`. + +In **File Station**: + +1. Open the `docker` share, then **Upload** the ZIP. +2. Right-click it → **Extract** → **Extract here**. You get a folder like + `gam-`. Rename it to `gam`. +3. Check that `docker/gam/ops/archive/docker-compose.yml` exists. + +No Git, SSH or shell on the NAS is needed. + +## 3. Find whose library to show + +Your Notes **username** works as `READ_ONLY_USER`, and so does your GAM user id. The id +is the safer one, since a username can be renamed. To read it on the gam host: + +```bash +cd /opt/gam +docker compose exec backend python -c \ + "import sqlite3; print(sqlite3.connect('/app/data/db/gam.db').execute('select id, username from user').fetchall())" +``` + +## 4. Create `ops/archive/.env` + +On your computer, generate a **new** secret. Do not reuse production's: + +```bash +openssl rand -hex 32 +``` + +(No `openssl`? Any 64 random hex characters will do; a password manager can generate +them.) + +Make a text file named exactly `.env` with this content, filling in your values: + +``` +GAM_MIRROR_DIR=/volume1/GAM/mirror +READ_ONLY_USER= +JWT_SECRET_KEY= +ARCHIVE_PORT=18083 +TZ=Europe/London +``` + +`GAM_MIRROR_DIR` is the NAS's own path, starting `/volume1/` (File Station → +right-click the share → **Properties** shows where it lives). It is *not* the chroot +path the backup uses. If GAM's backup shares Gecko Notes' NAS account (the backup +README's "Already backing up Gecko Notes" section), the mirror is in Notes' share: +`GAM_MIRROR_DIR=/volume1/GeckoNotes/gam/mirror`. + +Upload the file to `docker/gam/ops/archive/` with File Station. On Windows, Notepad +saves it as `.env.txt` unless you pick **Save as type: All files**. Check the name in +File Station afterwards, and rename it if needed. + +## 5. Create the project + +**Container Manager → Project → Create**: + +| Field | Value | +| ------ | ----- | +| Project name | `gam-archive` | +| Path | `/docker/gam/ops/archive` | +| Source | **Use existing docker-compose.yml** | + +Pick that folder, not `docker/gam` itself. The `docker-compose.yml` at the repository +root is production's, and it includes the backup sidecar. + +Skip the web portal (Web Station) page if it is offered → **Done**. Container Manager +builds both images, which takes 10–30 minutes on a NAS CPU the first time, then starts +them. If the build stops with `set GAM_MIRROR_DIR in ops/archive/.env` (or one of the +other two), the `.env` was not found or that line is empty. Go back to step 4. + +## 6. Open it + +`http://:18083` + +You should see your library with an amber **Read-only archive copy** bar under the +header, no sign-in, and no sign-out button. Play a video, and try to rename something: +the rename is refused with "This is a read-only archive copy". + +An **empty library** means `READ_ONLY_USER` matches nobody in the database. Re-check +step 3, edit `.env`, and recreate the project (step 8). + +## 7. Keep it off the internet + +**Control Panel → Security → Firewall** → edit the profile → add, above any allow-all +rule: + +- Allow port `18083` from your LAN subnet (e.g. `192.168.1.0/24`). +- Allow port `18083` from `100.64.0.0/10` if you use Tailscale. +- Deny port `18083` from everything else. + +And on the router: **no** port-forward for 18083. + +## 8. Day to day + +**It refreshes on its own.** Each mirror run replaces `gam.db` with a rename, and the +archive opens a fresh connection per request. The next page load shows the new state, +with no restart needed. `GAM/mirror/MIRROR_INFO` says when the last run was. + +**After updating production**, update the archive the same way it was installed. Download the +new commit's ZIP, extract it, and replace `docker/gam` with it, keeping your +`ops/archive/.env`. Then **Container Manager → Project → gam-archive → Action → Stop**, +then **Build**, then **Start**. If the archive's logs (**Container → gam-archive-backend +→ Log**) say the database "is at migration …, which this checkout does not contain", +this is the step you missed. + +**After changing `.env`**, use Stop → Build → Start as well. A plain restart keeps the +old environment. + +## Troubleshooting + +| Symptom | Cause | +| ------- | ----- | +| Build fails at `set … in ops/archive/.env` | `.env` missing, misnamed (`.env.txt`), or that line empty — step 4 | +| Page loads, library empty | `READ_ONLY_USER` matches no user — step 3 | +| "Could not reach the asset library" | Backend container not running or unhealthy. Look at its log; a wrong `GAM_MIRROR_DIR` shows as `unable to open database file` | +| `unable to open database file` | `GAM_MIRROR_DIR` points at the wrong place, or the mirror has not run yet. `ls` the path in File Station: it must hold `db/gam.db` | +| Images show, videos never play | Usually a browser blocking plain-HTTP media on a LAN address. Try another browser, or reach it over Tailscale's MagicDNS name | +| Library hours or days old | The mirror has stopped. Check `docker logs backup` on the gam host for `[backup] FAILED` | diff --git a/ops/archive/docker-compose.yml b/ops/archive/docker-compose.yml new file mode 100644 index 0000000..556f263 --- /dev/null +++ b/ops/archive/docker-compose.yml @@ -0,0 +1,57 @@ +# A read-only copy of GAM, run on the NAS against the plain-file mirror the backup +# sidecar keeps there (ops/backup/README.md, "Plain-file mirror"). +# +# Its own folder, not a file beside the main docker-compose.yml: Synology's Container +# Manager opens a project by finding `docker-compose.yml` in the folder it is pointed +# at, and the repository root's is production's — backup sidecar and all. Setup, start +# to finish, is in ops/archive/README.md. +# +# No backup service and no reverse-proxy network. Nothing here may be reachable from the +# internet: the archive signs nobody in and shows READ_ONLY_USER's library to whoever +# reaches the port. + +services: + backend: + build: ../../backend + restart: unless-stopped + volumes: + # Read-only twice over: the mount, and the database opened `mode=ro` + # (app/database.py). Either alone would do; the mount also keeps a bug in the app + # from touching the only other copy of the library. + - ${GAM_MIRROR_DIR:?set GAM_MIRROR_DIR in ops/archive/.env}/db:/app/data/db:ro + - ${GAM_MIRROR_DIR}/media:/app/data/media:ro + environment: + - DATABASE_URL=sqlite:////app/data/db/gam.db + - MEDIA_DIR=/app/data/media + - READ_ONLY=true + - READ_ONLY_USER=${READ_ONLY_USER:?set READ_ONLY_USER in ops/archive/.env} + # Not production's. Here it only signs media URLs, and the production secret on a + # NAS would let anyone who can read the NAS mint a Notes session for any account. + - JWT_SECRET_KEY=${JWT_SECRET_KEY:?set JWT_SECRET_KEY in ops/archive/.env} + - NOTES_BASE_URL=${NOTES_BASE_URL:-https://notes.geckopico.com} + - ENVIRONMENT=production + - TZ=${TZ:-UTC} + expose: + - "8000" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + frontend: + build: ../../frontend + restart: unless-stopped + ports: + - "${ARCHIVE_PORT:-18083}:80" + environment: + - NOTES_BASE_URL=${NOTES_BASE_URL:-https://notes.geckopico.com} + depends_on: + - backend + +networks: + default: + # Not gam_default, so an archive started on the production host by mistake cannot + # join production's network and answer as its backend. + name: gam_archive diff --git a/ops/backup/Dockerfile b/ops/backup/Dockerfile index b7f6188..fcb6d4e 100644 --- a/ops/backup/Dockerfile +++ b/ops/backup/Dockerfile @@ -4,7 +4,7 @@ FROM alpine:3.20 # in every Alpine base image — add it explicitly rather than assume. RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.20/community" >> /etc/apk/repositories \ && apk update \ - && apk add --no-cache restic sqlite openssh-client tzdata ca-certificates + && apk add --no-cache restic rclone sqlite openssh-client tzdata ca-certificates COPY entrypoint.sh /entrypoint.sh COPY backup.sh /usr/local/bin/backup.sh diff --git a/ops/backup/README.md b/ops/backup/README.md index 2db7230..835b3ce 100644 --- a/ops/backup/README.md +++ b/ops/backup/README.md @@ -1,17 +1,126 @@ # Backup setup: gam → Synology NAS -Sets up the `backup` service from `docker-compose.yml`: a sidecar that takes a -whole-database snapshot and pushes it, with the entire `data/media/` tree, to a -Synology NAS over SFTP on a schedule. See `docs/backup-strategy.md` for design -rationale. This file is the setup procedure. +Sets up the `backup` service from `docker-compose.yml`, a sidecar on the gam host that, +on a schedule: -All backup logic runs in the `backup` container on the gam host. Nothing -restic-specific runs on the NAS — it only needs its stock SFTP service. +1. takes a consistent snapshot of the SQLite database; +2. pushes it, with the whole `data/media/` tree, into an encrypted **restic** + repository on the NAS — the history, kept 48 hours / 14 days / 8 weeks; +3. optionally keeps a **plain-file mirror** beside it — the same `db/` and + `media//.` layout as `data/`, as ordinary files you can open, and + that a read-only copy of the app can run from ([`ops/archive/`](../archive/README.md)). -gam runs on a separate remote host, so the NAS is reached over a router -port-forward. SSH is therefore genuinely exposed to the internet, and the steps -below lean on key-only auth, a non-default port, a locked-down account, a -source-IP firewall rule, and DSM's brute-force protection. +Why both. restic is what saves you from a mistake: a deleted or overwritten file is +still in last week's snapshot. But its repository is encrypted chunks — nothing on the +NAS can open it without restic and the password. The mirror is the opposite: browsable, +runnable, and it copies deletions, so it is a working copy and not a history. Each +covers the other's gap. + +**The mirror doubles the space media takes on the NAS**, and the first mirror run sends +the whole library over again. Size the share for twice `du -sh data/media`. + +All backup logic runs in the `backup` container on the gam host. The NAS only needs its +stock SFTP service: restic and rclone both speak SFTP, and both use the one account and +key set up below. + +gam runs on a separate remote host, so the NAS is reached over a router port-forward. +SSH is therefore genuinely exposed to the internet, and the steps below lean on key-only +auth, a non-default port, a locked-down account, a source-IP firewall rule, and DSM's +brute-force protection. (Or skip the exposure entirely with Tailscale — see +"Maintenance".) + +Paths below assume the repo lives at `/opt/gam` on the gam host, and a share named `GAM` +on volume 1 of the NAS. Adjust both to taste, consistently. + +**Steps 1–12 are the restic backup, 13–14 the mirror. Do them in order** — the mirror +reuses everything the earlier steps set up. + +--- + +## Already backing up Gecko Notes from this server? + +Then most of this is done, and GAM can reuse Notes' connection — the same account, key +and pinned host key. Notes' backup proves the hard parts work: SFTP enabled (step 1), +User Home service (2), the `PubkeyAuthentication` fix (6), Auto Block, firewall and +port-forward (7). Skip those. + +Sharing the key costs nothing here. Both apps' secrets sit on the same server, so anyone +who can read one key can read the other; a second key would protect against nothing. +Keep the **repositories** separate, though — each app gets its own directory and its own +restic password. + +What is left: + +**a. Pick GAM's directory — inside Notes' share.** Notes' backup account already has +Read/Write on `GeckoNotes` (`/volume1/GeckoNotes`), and its restic repository sits at +`/GeckoNotes/restic`. GAM goes beside it in a directory of its own, so nothing on the +NAS changes at all — no new share, no permission edits on an account Notes depends on: + +``` +/volume1/GeckoNotes/ +├── restic/ Notes' repository — untouched +└── gam/ + ├── restic/ GAM's repository (step 11 creates it) + └── mirror/ GAM's plain-file mirror (step 13 creates it) +``` + +Check the prefix against Notes' own `.env` first: GAM's paths start with whatever +Notes' `BACKUP_SFTP_REMOTE_PATH` starts with. `/GeckoNotes/restic` there means +`/GeckoNotes/gam/…` here. (If it is plain `/restic`, the account's chroot *is* the share +and GAM's paths are `/gam/…`.) + +If the `GeckoNotes` share has its **Recycle Bin** on, turn it off or the mirror's +deliberate deletions pile up in `#recycle` (step 3 explains). Notes' restic prune +deletes files too, so this was already worth doing. + +**b. Copy Notes' key and host key** from its checkout at `/opt/gecko-notes` (adjust if +yours lives elsewhere): + +```bash +mkdir -p /opt/gam/ops/backup/secrets && chmod 700 /opt/gam/ops/backup/secrets +cp /opt/gecko-notes/ops/backup/secrets/id_backup /opt/gam/ops/backup/secrets/ +cp /opt/gecko-notes/ops/backup/secrets/known_hosts /opt/gam/ops/backup/secrets/ +chmod 600 /opt/gam/ops/backup/secrets/id_backup +``` + +Copies rather than a bind mount of Notes' files, so a change to one checkout — a +`git clean`, a key rotation half done — cannot take the other app's backup down with it. + +**c. Test it** — step 9, with the copied key and Notes' host, port and user, then +`cd GeckoNotes` and the `put`/`rm` write test. It should pass first time; Notes' backup +already does exactly this every hour. + +**d. Fill `.env`** as step 10, with two rules: + +- `BACKUP_SFTP_HOST`, `BACKUP_SFTP_PORT` and `BACKUP_SFTP_USER` exactly as in Notes' + `.env`. `known_hosts` pins the key under the host *as written*: Notes' entry for + `nas.example.ddns.net` does not cover the same NAS reached as `203.0.113.7`, and + the run fails with `Host key verification failed`. +- A **new** `RESTIC_PASSWORD`, generated as step 10 says and saved in the password + manager under GAM. It encrypts GAM's repository, not the connection, and has nothing + to do with Notes'. + +``` +BACKUP_SFTP_REMOTE_PATH=/GeckoNotes/gam/restic +BACKUP_MIRROR_PATH= +``` + +Never Notes' `/GeckoNotes/restic`. restic would happily put two apps' snapshots in one +repository, and `--host gam` would keep their retention apart — but the restic password would then be +shared, and a repository corrupted by either app would take both histories with it. + +**e. Carry on from step 11**, then 13–14 for the mirror, with +`BACKUP_MIRROR_PATH=/GeckoNotes/gam/mirror` (and that path wherever step 14 says +`/GAM/mirror`). On the NAS the mirror is then `/volume1/GeckoNotes/gam/mirror` — the +`GAM_MIRROR_DIR` the archive needs ([`ops/archive/README.md`](../archive/README.md), +step 4). + +Two schedules now write to one NAS over one link. GAM's defaults to the top of the hour, +and so does Notes' (`0 * * * *`), so check `BACKUP_CRON_SCHEDULE` in Notes' `.env` and +offset GAM's from it — say `30 * * * *` — so the two uploads do not compete. + +**Later, if the key is rotated** for Notes, copy the new one here again (b), or GAM's +backup starts failing the hour the old key is removed from the NAS. --- @@ -75,8 +184,16 @@ automatically. No login required. ## 3. NAS: create the shared folder and account -**Control Panel → Shared Folder** → create a dedicated share, e.g. `GeckoNotes`. -Don't reuse a folder holding anything else. +**Control Panel → Shared Folder** → create a dedicated share, e.g. `GAM`. +Don't reuse a folder holding anything else. Leave "Encrypt this shared folder" off unless +you know you want it: an encrypted share is unmounted after every reboot until someone +types its key, and backups (and the archive) fail until they do. The restic repository is +encrypted regardless; the mirror is not — it is plain files, readable by any NAS account +granted the share. + +Untick **Enable Recycle Bin** for this share: the +mirror deletes files on purpose, and a recycle bin quietly keeps every one of them, +filling the volume. **Control Panel → User & Group → User** → create a non-admin local user, e.g. `gam-backup`. @@ -85,7 +202,7 @@ Don't reuse a folder holding anything else. | Share | Access | | ---------- | ----------------------------------------------- | -| `GeckoNotes` | Read/Write | +| `GAM` | Read/Write | | `homes` | Read Only (required — see "Read this first") | | everything else | No Access | @@ -135,7 +252,7 @@ chmod 600 "$HOME_DIR/.ssh/authorized_keys" ls -ld "$HOME_DIR" "$HOME_DIR/.ssh" ls -l "$HOME_DIR/.ssh/authorized_keys" wc -l "$HOME_DIR/.ssh/authorized_keys" -} > /volume1/GeckoNotes/keycheck.txt 2>&1 +} > /volume1/GAM/keycheck.txt 2>&1 ``` Read `keycheck.txt` via File Station. Home should be `755` owned by the user, @@ -162,7 +279,7 @@ sed -i '/^[[:space:]]*#*[[:space:]]*PubkeyAuthentication/d' "$CONF" sed -i '/^[[:space:]]*#*[[:space:]]*AuthorizedKeysFile/d' "$CONF" sed -i 's|^PasswordAuthentication yes|PasswordAuthentication yes\nPubkeyAuthentication yes\nAuthorizedKeysFile .ssh/authorized_keys|' "$CONF" -grep -v '^#' "$CONF" | grep -v '^$' > /volume1/GeckoNotes/sshdconf.txt 2>&1 +grep -v '^#' "$CONF" | grep -v '^$' > /volume1/GAM/sshdconf.txt 2>&1 ``` Check `sshdconf.txt` shows both new lines **above** any `Match User` blocks. @@ -209,7 +326,7 @@ fingerprint anywhere in the UI**, so get it with a Task Scheduler script: ```bash #!/bin/sh for f in /etc/ssh/ssh_host_*_key; do ssh-keygen -lf "$f"; done \ - > /volume1/GeckoNotes/hostkeys.txt + > /volume1/GAM/hostkeys.txt ``` Match the ED25519 line. That comparison is what establishes trust; skipping it @@ -246,14 +363,14 @@ Once connected: ``` sftp> pwd sftp> ls -sftp> cd GeckoNotes +sftp> cd GAM sftp> put /etc/hostname writetest.txt sftp> rm writetest.txt ``` Synology chroots the user, so `pwd` at login returns `/` — that is the chroot -root, not the filesystem root. The share appears inside it as `/GeckoNotes`. Use -that, never `/volume1/GeckoNotes`. +root, not the filesystem root. The share appears inside it as `/GAM`. Use +that, never `/volume1/GAM`. ## 10. Fill `.env` @@ -269,7 +386,12 @@ RESTIC_PASSWORD= BACKUP_SFTP_HOST= BACKUP_SFTP_PORT= BACKUP_SFTP_USER=gam-backup -BACKUP_SFTP_REMOTE_PATH=/GeckoNotes/restic +BACKUP_SFTP_REMOTE_PATH=/GAM/restic +# Leave the mirror off for now; step 13 turns it on. +BACKUP_MIRROR_PATH= +# Hourly by default. Times are in TZ. +BACKUP_CRON_SCHEDULE=0 * * * * +TZ=Europe/London ``` The `/restic` subdirectory keeps the repo's `data/`, `snapshots/` and `keys/` @@ -312,6 +434,96 @@ One snapshot listed means the chain works. Confirm the restart policy is Check `docker logs backup` again after the next scheduled tick — an unattended run is the only real proof. +## 13. Turn on the plain-file mirror + +Nothing to do on the NAS — the account from step 3 can already write to the share. On +the gam host, add to `.env`: + +``` +BACKUP_MIRROR_PATH=/GAM/mirror +``` + +The SFTP **chroot** path, like `BACKUP_SFTP_REMOTE_PATH` — not `/volume1/GAM/mirror`. +Keep it beside `restic/`, never inside it: restic owns that directory and `restic check` +would object to strangers in it. + +Then recreate the container (environment is read at creation) and force a first run +rather than waiting for the schedule. The first one copies the whole library, so for a +large one run it detached and follow the log: + +```bash +docker compose up -d --build --force-recreate backup +docker compose exec -d backup sh -c '/usr/local/bin/backup.sh >>/proc/1/fd/1 2>>/proc/1/fd/2' +docker logs -f backup +``` + +Every run from then on sends only what changed: new media, the database, and a small +`MIRROR_INFO` file. Ordering is deliberate — media first, then the database — so the +mirrored database never names a file that has not arrived yet. + +## 14. Verify the mirror + +```bash +docker compose run --rm backup rclone lsf nas:/GAM/mirror +docker compose run --rm backup rclone cat nas:/GAM/mirror/MIRROR_INFO +docker compose run --rm backup rclone size nas:/GAM/mirror/media +du -sh data/media +``` + +`lsf` should list `MIRROR_INFO`, `db/` and `media/`. `MIRROR_INFO` carries the time of +the last mirror and the database's migration revision. The two sizes should agree. + +On the NAS, File Station now shows `GAM/mirror/media//…` as ordinary files you +can download or play. To run the app against them, carry on with +[`ops/archive/README.md`](../archive/README.md). + +What to know about the mirror: + +- **It copies deletions.** Removing an asset in GAM removes it from the mirror on the + next run. To get one back, use restic ("Restoring", below). +- **It is unencrypted on the NAS.** Anyone who can read the share can read the library + and the database, including asset metadata and transcripts. Provider API keys in the + database are encrypted with production's `JWT_SECRET_KEY`, which never goes to the NAS. +- **A brief window:** an asset deleted in the seconds between the database snapshot and + the media sync shows as missing in the archive until the next run. +- **A failed mirror fails the run** (`[backup] FAILED` in `docker logs backup`), but only + after restic has finished, so a NAS problem with the mirror never costs you a snapshot. + +--- + +## Restoring + +Onto the gam host, with the app stopped so nothing writes while files land: + +```bash +docker compose stop backend +docker compose run --rm backup restic snapshots # pick one, or use latest +docker compose run --rm -v "$PWD/restore:/restore" backup \ + restic restore latest --host gam --target /restore +``` + +restic restores with full paths: the database lands at `restore/backup/tmp/gam.db` and +media at `restore/backup/src/media/`. Move them into place, then start again: + +```bash +mv data/db/gam.db data/db/gam.db.before-restore +rm -f data/db/gam.db-wal data/db/gam.db-shm +cp restore/backup/tmp/gam.db data/db/gam.db +rsync -a restore/backup/src/media/ data/media/ +docker compose up -d backend +``` + +Delete the `-wal` and `-shm` files with the old database, never keep them beside the +restored one — SQLite would replay the old database's log into it. + +**From the mirror instead**, when the newest copy is all you need (no restic password, no +restic at all): download `GAM/mirror/db/gam.db` and `GAM/mirror/media/` from the NAS +(File Station, or `sftp`/`rsync` from any machine) into `data/db/` and `data/media/`, the +same way. It is the state as of the last run, so anything deleted before it is not +there. + +Test one of these once, before you need it. A backup nobody has restored from is a hope. + --- ## Maintenance @@ -341,7 +553,7 @@ Task Scheduler, root. Runs a debug sshd on a spare port that logs why it refused ```bash #!/bin/sh timeout 180 /usr/bin/sshd -ddd -p 2299 -f /etc/ssh/sshd_config \ - > /volume1/GeckoNotes/sshd_debug.txt 2>&1 + > /volume1/GAM/sshd_debug.txt 2>&1 ``` Connect to port 2299 within three minutes from a machine on the LAN (that port @@ -401,7 +613,7 @@ Each of these was tried during the original setup and none of them mattered: ## Later: second destination -`docs/backup-strategy.md` describes fanning the same backup out to an off-site -S3-compatible bucket for geographic redundancy. Deliberately deferred. Adding it +An off-site S3-compatible bucket would add geographic redundancy — the NAS and the gam +host are then not the only two copies. Deliberately deferred. Adding it means a second `restic backup ... -r ` and its own `restic forget` in `backup.sh`, with separate credentials. No changes to the NAS setup above. diff --git a/ops/backup/backup.sh b/ops/backup/backup.sh index 0a9c3f4..65bffd5 100644 --- a/ops/backup/backup.sh +++ b/ops/backup/backup.sh @@ -1,7 +1,9 @@ #!/bin/sh # One backup run: a WAL-safe whole-database snapshot, a restic push of that # snapshot plus the media tree (restic sends only what the destination -# doesn't already have), then retention pruning. +# doesn't already have), then retention pruning. Then, if BACKUP_MIRROR_PATH is set, a +# plain-file mirror of the same two things that a read-only copy of the app can run +# from (docs/deployment.md, "Read-only archive"). set -eu trap 'echo "[backup] FAILED (exit $?)" >&2' ERR @@ -28,4 +30,41 @@ restic forget \ --keep-weekly "${BACKUP_KEEP_WEEKLY:-8}" \ --prune +if [ -n "${BACKUP_MIRROR_PATH:-}" ]; then + MIRROR="nas:${BACKUP_MIRROR_PATH%/}" + + # A copy, not the restic snapshot itself, which stays byte-for-byte what was taken. + # Rollback-journal mode because the archive opens it `mode=ro` from a read-only + # mount, and a WAL-flagged file would need a writable -shm beside it there. + rm -f /backup/tmp/mirror.db + cp /backup/tmp/gam.db /backup/tmp/mirror.db + sqlite3 /backup/tmp/mirror.db "PRAGMA journal_mode=DELETE;" >/dev/null + + # Media before the database, so the mirrored database never names a file that has + # not arrived. `sync`, so a removed asset leaves the mirror too — restic is what + # keeps history; this is a working copy. rclone's default --delete-after skips + # deletions entirely when anything in the run failed. The one gap: an asset deleted + # in the seconds between the snapshot and this sync can show as missing in the + # archive until the next run. + echo "[backup] mirroring media to ${MIRROR}/media" + rclone sync /backup/src/media "${MIRROR}/media" \ + --exclude '*.partial' \ + -v + + # Uploaded under a temporary name and renamed into place by rclone, so an archive + # reading the mirror never opens half a database. + echo "[backup] mirroring database to ${MIRROR}/db/gam.db" + rclone copyto /backup/tmp/mirror.db "${MIRROR}/db/gam.db" + + # What the archive's operator checks to know which code version to run: an archive + # older than the schema it is given cannot read it. + { + echo "mirrored_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "alembic_revision=$(sqlite3 /backup/tmp/mirror.db 'SELECT version_num FROM alembic_version' 2>/dev/null || true)" + } > /backup/tmp/MIRROR_INFO + rclone copyto /backup/tmp/MIRROR_INFO "${MIRROR}/MIRROR_INFO" + + rm -f /backup/tmp/mirror.db +fi + echo "[backup] done" diff --git a/ops/backup/entrypoint.sh b/ops/backup/entrypoint.sh index 4eff3b3..a4f0fde 100644 --- a/ops/backup/entrypoint.sh +++ b/ops/backup/entrypoint.sh @@ -24,6 +24,26 @@ EOF chmod 700 /root/.ssh chmod 600 /root/.ssh/config +# The plain-file mirror's remote, configured through rclone's environment variables so +# there is no config file to mount or keep in step. Same account, key and pinned host key +# as restic — no NAS change is needed to turn the mirror on. +# +# SHELL_TYPE=none and DISABLE_HASHCHECK: DSM gives the backup account SFTP and no shell, +# so rclone cannot run md5sum remotely, and without these it tries and logs a failure +# per file. Size + modtime is a sound comparison here because media names are +# write-once UUIDs: a file with a given name never changes. +# /dev/null: everything is in the environment, and without it rclone logs that it found +# no config file on every single invocation. +export RCLONE_CONFIG=/dev/null +export RCLONE_CONFIG_NAS_TYPE=sftp +export RCLONE_CONFIG_NAS_HOST="${BACKUP_SFTP_HOST}" +export RCLONE_CONFIG_NAS_PORT="${BACKUP_SFTP_PORT}" +export RCLONE_CONFIG_NAS_USER="${BACKUP_SFTP_USER}" +export RCLONE_CONFIG_NAS_KEY_FILE=/root/.ssh/id_backup +export RCLONE_CONFIG_NAS_KNOWN_HOSTS_FILE=/root/.ssh/known_hosts +export RCLONE_CONFIG_NAS_SHELL_TYPE=none +export RCLONE_CONFIG_NAS_DISABLE_HASHCHECK=true + export RESTIC_REPOSITORY="${RESTIC_REPOSITORY:-sftp:synology-backup:${BACKUP_SFTP_REMOTE_PATH}}" SCHEDULE="${BACKUP_CRON_SCHEDULE:-0 * * * *}" @@ -31,6 +51,7 @@ echo "${SCHEDULE} /usr/local/bin/backup.sh >>/proc/1/fd/1 2>>/proc/1/fd/2" > /et echo "[entrypoint] schedule: ${SCHEDULE}" echo "[entrypoint] repository: ${RESTIC_REPOSITORY}" +echo "[entrypoint] mirror: ${BACKUP_MIRROR_PATH:-off}" if [ "$#" -gt 0 ]; then exec "$@"