- 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>
10 KiB
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=Strictcookie - 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/webauthnlibrary
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
viewBoxmatches 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.
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
[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:
- Builder:
golang:1.25-alpine,CGO_ENABLED=0, stripped binary - 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/randfor all tokens and noncescrypto/subtlefor 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