diff --git a/.gitignore b/.gitignore index 8e7f552..a9c999b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ apps/server/backups/ !.vscode/extensions.json .DS_Store Thumbs.db + +# Python bytecode +__pycache__/ +*.pyc diff --git a/apps/server/.env.example b/apps/server/.env.example index 3652ce4..07a1986 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -25,4 +25,5 @@ FOCUSNOOK_LEGAL_ADDRESS= FOCUSNOOK_MAX_OPS_PER_EXCHANGE=500 FOCUSNOOK_MAX_OPERATION_PAYLOAD_BYTES=262144 FOCUSNOOK_MAX_BLOB_BYTES=15728640 +FOCUSNOOK_MAX_REQUEST_BODY_BYTES=33554432 RUST_LOG=focusnook_sync_server=info,tower_http=info diff --git a/apps/server/README.md b/apps/server/README.md index 0ae306a..faee15b 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -46,6 +46,16 @@ python scripts/backup-vds-local.py ` --destination C:\path\to\FocusNook-backups ``` +If the operator already has key-based access in `~/.ssh/config`, prefer the +host alias so no password access file is needed: + +```powershell +python scripts/backup-vds-local.py ` + --ssh-host focusnook-vds ` + --destination C:\path\to\FocusNook-backups +``` + +Both modes require a known host key and fail closed for an unknown server. This helper requires Python and `paramiko`. Record the printed SHA-256. Then run the fail-closed deployment wrapper on the VDS as root (or with equivalent Docker and nginx permissions): @@ -172,6 +182,11 @@ otherwise arrive between two long-poll requests: "sequence": 0}` on timeout. It is a wakeup signal only - the client still calls `/v1/sync/exchange` to fetch the actual operations. +The aggregate JSON request limit defaults to 32 MiB. This is deliberately +larger than `FOCUSNOOK_MAX_BLOB_BYTES`: blob bytes are Base64-encoded in JSON, +so a 15 MiB encrypted recording needs roughly 20 MiB on the wire. nginx and +the Axum body limit must stay aligned when either value changes. + ## Restore Stop writers before restoring a dump: @@ -215,5 +230,7 @@ email together to enable public registration. `FOCUSNOOK_LEGAL_TAX_ID` and - Store `.env` outside the repository and database dumps only on the operator's local backup storage, not on the VDS. - Keep TLS termination in Caddy and do not expose the Rust server port publicly. +- Keep Docker JSON log rotation enabled and leave enough stop grace time for an + in-flight exchange to finish during a controlled deploy. - For stronger availability than a single VDS, add managed Postgres or streaming replication, offsite backups, and an external uptime monitor. diff --git a/apps/server/compose.vds-nginx.yml b/apps/server/compose.vds-nginx.yml index f92cb20..2d22785 100644 --- a/apps/server/compose.vds-nginx.yml +++ b/apps/server/compose.vds-nginx.yml @@ -13,6 +13,13 @@ services: interval: 10s timeout: 5s retries: 10 + start_period: 10s + stop_grace_period: 60s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" server: build: @@ -37,6 +44,7 @@ services: FOCUSNOOK_MAX_OPS_PER_EXCHANGE: ${FOCUSNOOK_MAX_OPS_PER_EXCHANGE:-500} FOCUSNOOK_MAX_OPERATION_PAYLOAD_BYTES: ${FOCUSNOOK_MAX_OPERATION_PAYLOAD_BYTES:-262144} FOCUSNOOK_MAX_BLOB_BYTES: ${FOCUSNOOK_MAX_BLOB_BYTES:-15728640} + FOCUSNOOK_MAX_REQUEST_BODY_BYTES: ${FOCUSNOOK_MAX_REQUEST_BODY_BYTES:-33554432} RUST_LOG: ${RUST_LOG:-focusnook_sync_server=info,tower_http=info} ports: - "127.0.0.1:${FOCUSNOOK_HOST_PORT:-18080}:8080" @@ -45,6 +53,13 @@ services: interval: 10s timeout: 5s retries: 10 + start_period: 10s + stop_grace_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" volumes: focusnook-postgres: diff --git a/apps/server/compose.yml b/apps/server/compose.yml index abc5150..04efad6 100644 --- a/apps/server/compose.yml +++ b/apps/server/compose.yml @@ -13,6 +13,13 @@ services: interval: 10s timeout: 5s retries: 10 + start_period: 10s + stop_grace_period: 60s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" server: build: @@ -37,6 +44,7 @@ services: FOCUSNOOK_MAX_OPS_PER_EXCHANGE: ${FOCUSNOOK_MAX_OPS_PER_EXCHANGE:-500} FOCUSNOOK_MAX_OPERATION_PAYLOAD_BYTES: ${FOCUSNOOK_MAX_OPERATION_PAYLOAD_BYTES:-262144} FOCUSNOOK_MAX_BLOB_BYTES: ${FOCUSNOOK_MAX_BLOB_BYTES:-15728640} + FOCUSNOOK_MAX_REQUEST_BODY_BYTES: ${FOCUSNOOK_MAX_REQUEST_BODY_BYTES:-33554432} RUST_LOG: ${RUST_LOG:-focusnook_sync_server=info,tower_http=info} ports: - "127.0.0.1:8080:8080" @@ -45,6 +53,13 @@ services: interval: 10s timeout: 5s retries: 10 + start_period: 10s + stop_grace_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" caddy: image: caddy:2-alpine @@ -61,6 +76,11 @@ services: - ./Caddyfile:/etc/caddy/Caddyfile:ro - focusnook-caddy-data:/data - focusnook-caddy-config:/config + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" volumes: focusnook-caddy-config: diff --git a/apps/server/nginx/focusnook.conf b/apps/server/nginx/focusnook.conf index 1bd2ba5..da10701 100644 --- a/apps/server/nginx/focusnook.conf +++ b/apps/server/nginx/focusnook.conf @@ -1,3 +1,5 @@ +limit_req_zone $binary_remote_addr zone=focusnook_auth:10m rate=30r/m; + server { listen 80; server_name focus.proanima.net; @@ -22,7 +24,8 @@ server { include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - client_max_body_size 20m; + client_max_body_size 32m; + limit_req_status 429; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options "nosniff" always; @@ -52,6 +55,16 @@ server { proxy_send_timeout 300s; } + location ~ ^/v1/(accounts/(login|register)|admin/web/login)$ { + limit_req zone=focusnook_auth burst=10 nodelay; + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + location = /healthz { proxy_pass http://127.0.0.1:18080; proxy_http_version 1.1; diff --git a/apps/server/scripts/backup-vds-local.py b/apps/server/scripts/backup-vds-local.py index 42d7d5e..13291a2 100644 --- a/apps/server/scripts/backup-vds-local.py +++ b/apps/server/scripts/backup-vds-local.py @@ -9,6 +9,7 @@ import json import secrets import shlex +import socket from pathlib import Path import paramiko @@ -22,6 +23,35 @@ def parse_access_file(path: Path) -> tuple[str, str, str]: raise ValueError("access file must contain a 'host user password' line") +def connect(ssh: paramiko.SSHClient, access_file: Path | None, ssh_host: str | None) -> None: + ssh.load_system_host_keys() + ssh.set_missing_host_key_policy(paramiko.RejectPolicy()) + if access_file is not None: + host, user, password = parse_access_file(access_file) + ssh.connect(host, username=user, password=password, timeout=15) + return + + if not ssh_host: + raise ValueError("either --access-file or --ssh-host is required") + config_path = Path.home() / ".ssh" / "config" + lookup: dict[str, object] = {} + if config_path.is_file(): + with config_path.open(encoding="utf-8") as source: + lookup = paramiko.SSHConfig().from_file(source).lookup(ssh_host) + hostname = str(lookup.get("hostname", ssh_host)) + username = lookup.get("user") + identity_files = lookup.get("identityfile") + key_filename = identity_files if isinstance(identity_files, list) else None + ssh.connect( + hostname, + username=str(username) if username else None, + key_filename=key_filename, + look_for_keys=True, + allow_agent=True, + timeout=15, + ) + + def run(ssh: paramiko.SSHClient, command: str) -> str: _, stdout, stderr = ssh.exec_command(command, timeout=120) output = stdout.read().decode("utf-8", errors="replace") @@ -42,7 +72,12 @@ def sha256(path: Path) -> str: def main() -> None: parser = argparse.ArgumentParser() - parser.add_argument("--access-file", required=True, type=Path) + access = parser.add_mutually_exclusive_group(required=True) + access.add_argument("--access-file", type=Path) + access.add_argument( + "--ssh-host", + help="OpenSSH host alias or hostname; uses ~/.ssh/config, keys, and known_hosts", + ) parser.add_argument( "--destination", type=Path, @@ -51,7 +86,6 @@ def main() -> None: parser.add_argument("--container", default="focusnook-postgres-1") args = parser.parse_args() - host, user, password = parse_access_file(args.access_file) timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") filename = f"focusnook-{timestamp}.dump" destination = args.destination / filename @@ -61,9 +95,10 @@ def main() -> None: quoted_remote = shlex.quote(remote_path) ssh = paramiko.SSHClient() - ssh.load_system_host_keys() - ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) - ssh.connect(host, username=user, password=password, timeout=15) + try: + connect(ssh, args.access_file, args.ssh_host) + except (OSError, paramiko.SSHException, socket.error) as err: + raise RuntimeError(f"SSH connection failed: {err}") from err try: labels = run( ssh, diff --git a/apps/server/scripts/generate-env.sh b/apps/server/scripts/generate-env.sh index 9171525..4d2ecd7 100644 --- a/apps/server/scripts/generate-env.sh +++ b/apps/server/scripts/generate-env.sh @@ -61,6 +61,7 @@ FOCUSNOOK_SUPPORT_EMAIL=${support_email} FOCUSNOOK_MAX_OPS_PER_EXCHANGE=500 FOCUSNOOK_MAX_OPERATION_PAYLOAD_BYTES=262144 FOCUSNOOK_MAX_BLOB_BYTES=15728640 +FOCUSNOOK_MAX_REQUEST_BODY_BYTES=33554432 RUST_LOG=focusnook_sync_server=info,tower_http=info EOF diff --git a/apps/server/scripts/smoke-test.sh b/apps/server/scripts/smoke-test.sh index 3fdf14b..058a890 100644 --- a/apps/server/scripts/smoke-test.sh +++ b/apps/server/scripts/smoke-test.sh @@ -24,8 +24,21 @@ check_page() { echo "OK ${path}" } +check_code() { + path="$1" + expected="$2" + code="$(curl --silent --output /dev/null --write-out '%{http_code}' --max-time 15 "${base_url}${path}")" + test "$code" = "$expected" || { + echo "expected ${path} to return ${expected}, got ${code}" >&2 + exit 1 + } + echo "OK ${path} ${code}" +} + check_json /healthz check_json /readyz +check_code /v1/admin/stats 401 +check_code /v1/sync/events 401 if [ "$expect_legal" -eq 1 ]; then check_page /privacy "Политика конфиденциальности FocusNook" check_page /terms "Пользовательское соглашение FocusNook" @@ -39,4 +52,10 @@ else echo "DISABLED /${path}" done fi +http_url="http://${base_url#https://}" +redirect="$(curl --silent --output /dev/null --write-out '%{http_code}|%{redirect_url}' --max-time 15 "$http_url")" +case "$redirect" in + 301\|https://*) echo "OK HTTP to HTTPS redirect" ;; + *) echo "invalid HTTP redirect: ${redirect}" >&2; exit 1 ;; +esac echo "Smoke test passed for ${base_url}" diff --git a/apps/server/src/config.rs b/apps/server/src/config.rs index 0d60e52..165b02f 100644 --- a/apps/server/src/config.rs +++ b/apps/server/src/config.rs @@ -3,6 +3,12 @@ use crate::legal::LegalIdentity; use base64::Engine; use std::net::SocketAddr; +const MAX_CONFIGURED_BLOB_BYTES: usize = 64 * 1024 * 1024; +const MAX_CONFIGURED_OPERATION_BYTES: usize = 1024 * 1024; +const MAX_CONFIGURED_REQUEST_BYTES: usize = 128 * 1024 * 1024; +const MAX_CONFIGURED_OPS: i64 = 1000; +const REQUEST_JSON_OVERHEAD_BYTES: usize = 1024 * 1024; + #[derive(Clone)] pub struct Config { pub admin_token: String, @@ -13,6 +19,7 @@ pub struct Config { pub max_blob_bytes: usize, pub max_operation_payload_bytes: usize, pub max_ops_per_exchange: i64, + pub max_request_body_bytes: usize, pub legal_identity: Option, pub public_base_url: String, pub token_pepper: Vec, @@ -20,19 +27,35 @@ pub struct Config { impl Config { pub fn from_env() -> AppResult { + let max_blob_bytes = bounded_usize( + "FOCUSNOOK_MAX_BLOB_BYTES", + "15728640", + MAX_CONFIGURED_BLOB_BYTES, + )?; + let max_request_body_bytes = bounded_usize( + "FOCUSNOOK_MAX_REQUEST_BODY_BYTES", + "33554432", + MAX_CONFIGURED_REQUEST_BYTES, + )?; + validate_blob_request_limit(max_blob_bytes, max_request_body_bytes)?; Ok(Self { admin_token: required("FOCUSNOOK_ADMIN_TOKEN")?, admin_web_password: required("FOCUSNOOK_WEB_SECONDARY_PASSWORD")?, bind_addr: optional("FOCUSNOOK_BIND_ADDR", "0.0.0.0:8080").parse()?, database_url: required("FOCUSNOOK_DATABASE_URL")?, encryption_key: decode_key("FOCUSNOOK_ENCRYPTION_KEY_B64")?, - max_blob_bytes: optional("FOCUSNOOK_MAX_BLOB_BYTES", "15728640").parse()?, - max_operation_payload_bytes: optional( + max_blob_bytes, + max_operation_payload_bytes: bounded_usize( "FOCUSNOOK_MAX_OPERATION_PAYLOAD_BYTES", "262144", - ) - .parse()?, - max_ops_per_exchange: optional("FOCUSNOOK_MAX_OPS_PER_EXCHANGE", "500").parse()?, + MAX_CONFIGURED_OPERATION_BYTES, + )?, + max_ops_per_exchange: bounded_i64( + "FOCUSNOOK_MAX_OPS_PER_EXCHANGE", + "500", + MAX_CONFIGURED_OPS, + )?, + max_request_body_bytes, legal_identity: optional_legal_identity()?, public_base_url: optional("FOCUSNOOK_PUBLIC_BASE_URL", "http://localhost:8080"), token_pepper: decode_key("FOCUSNOOK_TOKEN_PEPPER_B64")?, @@ -40,6 +63,45 @@ impl Config { } } +fn bounded_usize(name: &str, default: &str, maximum: usize) -> AppResult { + let value = optional(name, default).parse::()?; + if value == 0 || value > maximum { + return Err(AppError::Config(format!( + "{name} must be between 1 and {maximum}" + ))); + } + Ok(value) +} + +fn bounded_i64(name: &str, default: &str, maximum: i64) -> AppResult { + let value = optional(name, default).parse::()?; + if value <= 0 || value > maximum { + return Err(AppError::Config(format!( + "{name} must be between 1 and {maximum}" + ))); + } + Ok(value) +} + +fn minimum_blob_request_bytes(max_blob_bytes: usize) -> AppResult { + max_blob_bytes + .checked_add(2) + .and_then(|value| value.checked_div(3)) + .and_then(|value| value.checked_mul(4)) + .and_then(|value| value.checked_add(REQUEST_JSON_OVERHEAD_BYTES)) + .ok_or_else(|| AppError::Config("blob request limit overflow".to_string())) +} + +fn validate_blob_request_limit(max_blob_bytes: usize, max_request_bytes: usize) -> AppResult<()> { + let minimum = minimum_blob_request_bytes(max_blob_bytes)?; + if max_request_bytes < minimum { + return Err(AppError::Config(format!( + "FOCUSNOOK_MAX_REQUEST_BODY_BYTES must be at least {minimum} for the configured blob limit" + ))); + } + Ok(()) +} + fn optional_legal_identity() -> AppResult> { legal_identity_from_values([ std::env::var("FOCUSNOOK_LEGAL_ADDRESS").ok(), @@ -128,4 +190,14 @@ mod tests { let result = legal_identity_from_values([Some("address".into()), None, None, None]); assert!(matches!(result, Err(AppError::Config(_)))); } + + #[test] + fn request_limit_accounts_for_base64_blob_expansion() -> AppResult<()> { + let blob_bytes = 15 * 1024 * 1024; + let minimum = minimum_blob_request_bytes(blob_bytes)?; + assert_eq!(minimum, 21 * 1024 * 1024); + assert!(validate_blob_request_limit(blob_bytes, minimum).is_ok()); + assert!(validate_blob_request_limit(blob_bytes, minimum - 1).is_err()); + Ok(()) + } } diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 35c7050..1901e50 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -54,7 +54,32 @@ fn healthcheck() -> AppResult<()> { } async fn shutdown_signal() { - if let Err(err) = tokio::signal::ctrl_c().await { - tracing::warn!(%err, "failed to install Ctrl+C shutdown handler"); + let ctrl_c = tokio::signal::ctrl_c(); + #[cfg(unix)] + { + let terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()); + match terminate { + Ok(mut terminate) => { + tokio::select! { + result = ctrl_c => { + if let Err(err) = result { + tracing::warn!(%err, "Ctrl+C shutdown handler failed"); + } + } + _ = terminate.recv() => {} + } + } + Err(err) => { + tracing::warn!(%err, "SIGTERM shutdown handler failed"); + if let Err(err) = ctrl_c.await { + tracing::warn!(%err, "Ctrl+C shutdown handler failed"); + } + } + } } + #[cfg(not(unix))] + if let Err(err) = ctrl_c.await { + tracing::warn!(%err, "Ctrl+C shutdown handler failed"); + } + tracing::info!("graceful shutdown requested"); } diff --git a/apps/server/src/routes/mod.rs b/apps/server/src/routes/mod.rs index e8e1ffe..fe6e6d9 100644 --- a/apps/server/src/routes/mod.rs +++ b/apps/server/src/routes/mod.rs @@ -19,7 +19,7 @@ use tower_http::limit::RequestBodyLimitLayer; use tower_http::trace::TraceLayer; pub fn router(state: AppState) -> axum::Router { - let max_body = state.config.max_blob_bytes + 1024 * 1024; + let max_body = state.config.max_request_body_bytes; axum::Router::new() .route("/", get(admin_console)) .route("/healthz", get(healthz))