Files
eng-pad-server/ARCHITECTURE.md
Kyle Isom 691301dade Update docs for Docker-on-deimos deployment, add grpc_plain_addr option
- ARCHITECTURE.md: document nginx + direct gRPC topology, add
  grpc_plain_addr config, update cert filenames to Let's Encrypt
  convention, add passwd to CLI table
- RUNBOOK.md: replace systemctl/journalctl with docker commands,
  fix cert path references, improve sync troubleshooting steps
- Example config: update cert paths, document grpc_plain_addr option
- grpcserver: add optional plaintext gRPC listener for reverse proxy
- config: add GRPCPlainAddr field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:58:01 -07:00

335 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ARCHITECTURE.md — eng-pad-server
## 1. System Overview
eng-pad-server is a read-only sync and viewing service for eng-pad
engineering notebooks. The Android app is the sole writer; the server
receives complete notebook data via gRPC and serves it through a web UI.
```
Android App eng-pad-server
+-----------+ gRPC/TLS +------------------+
| eng-pad | ───────────────> | Sync Service |
| (writer) | username/pass | (gRPC :9443) |
+-----------+ in metadata +------------------+
+──────────────+
│ SQLite DB │
+──────────────+
┌────────────┼────────────┐
▼ ▼ ▼
REST API Web UI Share Links
(:8443) (:8080) (/s/:token)
JSON htmx/SVG No auth
```
## 2. Data Model
### Users
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment |
| username | TEXT UNIQUE | Login identifier |
| password_hash | TEXT | Argon2id hash |
| created_at | INTEGER | Epoch millis |
| updated_at | INTEGER | Epoch millis |
### WebAuthn Credentials
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment |
| user_id | INTEGER FK | References users(id) CASCADE |
| credential_id | BLOB UNIQUE | WebAuthn credential ID |
| public_key | BLOB | COSE public key |
| name | TEXT | User-assigned label |
| sign_count | INTEGER | Signature counter |
| created_at | INTEGER | Epoch millis |
### Notebooks
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment |
| user_id | INTEGER FK | References users(id) CASCADE |
| remote_id | INTEGER | App-side notebook ID |
| title | TEXT | Notebook title |
| page_size | TEXT | "REGULAR" or "LARGE" |
| synced_at | INTEGER | Last sync epoch millis |
UNIQUE(user_id, remote_id)
### Pages
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment |
| notebook_id | INTEGER FK | References notebooks(id) CASCADE |
| remote_id | INTEGER | App-side page ID |
| page_number | INTEGER | 1-based page number |
UNIQUE(notebook_id, remote_id)
### Strokes
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment |
| page_id | INTEGER FK | References pages(id) CASCADE |
| pen_size | REAL | Width in canonical points (300 DPI) |
| color | INTEGER | ARGB packed int |
| style | TEXT | "plain", "dashed", "arrow", "double_arrow" |
| point_data | BLOB | Packed LE floats: [x0,y0,x1,y1,...] |
| stroke_order | INTEGER | Z-order within page |
### Share Links
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment |
| notebook_id | INTEGER FK | References notebooks(id) CASCADE |
| token | TEXT UNIQUE | 32-byte random, URL-safe base64 |
| expires_at | INTEGER | Epoch millis, NULL = never |
| created_at | INTEGER | Epoch millis |
## 3. Authentication
### gRPC (Android App Sync)
- Username and password sent in gRPC metadata on every RPC
- Unary interceptor verifies against Argon2id hash
- TLS required — plaintext rejected
- No tokens, no login RPC
### Web UI (Browser)
- `POST /v1/auth/login` — password → bearer token
- Token in `HttpOnly; Secure; SameSite=Strict` cookie
- 24h TTL, SHA-256 keyed lookup with cache
### FIDO2/U2F (Web UI Only)
- Register keys after password login
- Login with key as password alternative
- Multiple keys per user, user-assigned labels
- `go-webauthn/webauthn` library
### Shareable Links
- Token in URL, no auth required
- 32-byte `crypto/rand`, URL-safe base64
- Optional expiry (default: never)
- Expired → 410 Gone
- Revocable via gRPC API or web UI
## 4. gRPC API
Service: `engpad.v1.EngPadSync`
| RPC | Description | Auth |
|-----|-------------|------|
| SyncNotebook | Push complete notebook (upsert) | user/pass |
| DeleteNotebook | Remove notebook from server | user/pass |
| ListNotebooks | List user's synced notebooks | user/pass |
| CreateShareLink | Generate shareable URL | user/pass |
| RevokeShareLink | Invalidate a share link | user/pass |
| ListShareLinks | List links for a notebook | user/pass |
### Sync Semantics
`SyncNotebook` is a full replacement: all pages and strokes for the
notebook are deleted and re-inserted. The server mirrors exactly what
the tablet has. No incremental sync, no conflict resolution.
Keyed by (user_id, remote_id) where remote_id is the app-side
notebook ID.
## 5. REST API
### Auth Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /v1/auth/login | None | Password login → token |
| POST | /v1/auth/webauthn/register/begin | Bearer | Start key registration |
| POST | /v1/auth/webauthn/register/finish | Bearer | Complete registration |
| POST | /v1/auth/webauthn/login/begin | None | Start key login |
| POST | /v1/auth/webauthn/login/finish | None | Complete key login |
### Notebook Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /v1/notebooks | Bearer | List notebooks |
| GET | /v1/notebooks/:id | Bearer | Notebook + page list |
| GET | /v1/notebooks/:id/pages/:num/svg | Bearer | Page as SVG |
| GET | /v1/notebooks/:id/pages/:num/jpg | Bearer | Page as JPG (300 DPI) |
| GET | /v1/notebooks/:id/pdf | Bearer | Full notebook PDF |
### Share Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /s/:token | None | Notebook view |
| GET | /s/:token/pages/:num/svg | None | Page SVG |
| GET | /s/:token/pages/:num/jpg | None | Page JPG |
| GET | /s/:token/pdf | None | Notebook PDF |
## 6. Rendering
### SVG
Strokes rendered as SVG `<path>` elements. Coordinates scaled from
300 DPI to 72 DPI (×0.24) for standard SVG/PDF point units.
- `stroke-linecap="round"`, `stroke-linejoin="round"`
- Dashed strokes: `stroke-dasharray="7.2 4.8"`
- Arrow heads: separate `<line>` elements
- No grid — grid is a tablet writing aid only
- `viewBox` matches physical page dimensions in points
### JPG
Server-side rasterization at 300 DPI using Go's `image` package.
White background, strokes rendered with the same coordinate system.
### PDF
Generated with a Go PDF library. Coordinates in 72 DPI (native PDF
points). One page per notebook page.
## 7. Web Interface
Built with Go `html/template` + htmx. Embedded via `//go:embed`.
### Pages
| Route | Template | Description |
|-------|----------|-------------|
| /login | login.html | Login form (password + WebAuthn) |
| /notebooks | notebooks.html | Notebook list |
| /notebooks/:id | notebook.html | Page grid with thumbnails |
| /notebooks/:id/pages/:num | page.html | Full page SVG viewer |
| /s/:token | notebook.html | Shared notebook (no auth) |
| /s/:token/pages/:num | page.html | Shared page viewer |
### Security
- CSRF via signed double-submit cookies
- Session cookie: HttpOnly, Secure, SameSite=Strict
- html/template auto-escaping
## 8. Configuration
```toml
[server]
listen_addr = ":8443" # REST API (HTTPS)
grpc_addr = ":9443" # gRPC (TLS, exposed directly)
grpc_plain_addr = "" # Optional plaintext gRPC for reverse proxy
tls_cert = "/srv/eng-pad-server/certs/fullchain.pem"
tls_key = "/srv/eng-pad-server/certs/privkey.pem"
[web]
listen_addr = ":8080" # Web UI (plain HTTP behind nginx)
base_url = "https://pad.metacircular.net"
[database]
path = "/srv/eng-pad-server/eng-pad-server.db"
[auth]
token_ttl = "24h"
argon2_memory = 65536
argon2_time = 3
argon2_threads = 4
[webauthn]
rp_display_name = "Engineering Pad"
rp_id = "pad.metacircular.net"
rp_origins = ["https://pad.metacircular.net"]
[log]
level = "info"
```
## 9. Deployment
### Production (deimos.wntrmute.net)
Docker container behind nginx on deimos:
- **Web UI**: `https://pad.metacircular.net` — nginx (port 443) → container:8080
- **gRPC sync**: `pad.metacircular.net:9443` — direct TLS, exposed via ufw
- **REST API**: container:8443 — not exposed externally
- **TLS**: Let's Encrypt cert for `pad.metacircular.net`, shared by nginx
and the container (copied to `/srv/eng-pad-server/certs/`)
```
Internet
├── :443 → nginx (TLS termination) → container:8080 (Web UI, plain HTTP)
└── :9443 → container:9443 (gRPC, direct TLS)
```
### Container
Multi-stage Docker build:
1. Builder: `golang:1.25-alpine`, `CGO_ENABLED=0`, stripped binary
2. Runtime: `alpine:3.21`, non-root user (`engpad`, UID 1000)
### systemd (alternative)
systemd units are provided for non-Docker deployments:
| Unit | Purpose |
|------|---------|
| eng-pad-server.service | Main service |
| eng-pad-server-backup.service | Oneshot backup |
| eng-pad-server-backup.timer | Daily 02:00 UTC |
Security hardening: NoNewPrivileges, ProtectSystem=strict,
ReadWritePaths=/srv/eng-pad-server.
### Data Directory
```
/srv/eng-pad-server/
├── eng-pad-server.toml
├── eng-pad-server.db
├── certs/
│ ├── fullchain.pem # Let's Encrypt cert chain
│ └── privkey.pem # Let's Encrypt private key
└── backups/
```
## 10. Security
- TLS 1.3 minimum, no fallback
- Argon2id for password hashing
- `crypto/rand` for all tokens and nonces
- `crypto/subtle` for constant-time comparisons
- No secrets in logs
- Default deny: unauthenticated requests rejected
- Share links: scoped to single notebook, optional expiry, revocable
- Graceful shutdown: SIGINT/SIGTERM → drain → close DB → exit
## 11. CLI Commands
| Command | Purpose |
|---------|---------|
| server | Start the service |
| init | Create database, first user |
| passwd | Reset a user's password |
| snapshot | Database backup (VACUUM INTO) |
| status | Health check |
## 12. Future Work
- MCIAS integration for auth delegation
- Per-page share links (URL structure already supports it)
- Notebook version history (store previous syncs)
- WebSocket notifications for real-time sync status
- Thumbnail generation for notebook list