Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ apps/server/backups/
!.vscode/extensions.json
.DS_Store
Thumbs.db

# Python bytecode
__pycache__/
*.pyc
1 change: 1 addition & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions apps/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
15 changes: 15 additions & 0 deletions apps/server/compose.vds-nginx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Expand All @@ -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:
20 changes: 20 additions & 0 deletions apps/server/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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:
Expand Down
15 changes: 14 additions & 1 deletion apps/server/nginx/focusnook.conf
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 40 additions & 5 deletions apps/server/scripts/backup-vds-local.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import secrets
import shlex
import socket
from pathlib import Path

import paramiko
Expand All @@ -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")
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/scripts/generate-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions apps/server/scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}"
Loading
Loading