# Opal Infrastructure Integration Plan ## Context This document describes how to integrate opal-task (Go API) and opal-web (SvelteKit PWA) into the existing your-infra Ansible infrastructure on the your-server homelab server. ## Current your-infra Architecture The VPS (your-server / 203.0.113.10) runs Arch Linux with: - **Caddy** reverse proxy with auto-HTTPS (Let's Encrypt + Cloudflare DNS challenge) - **PostgreSQL** and **Valkey** as native systemd services, exposed via Unix sockets only - **Podman** with Quadlet for containerized services (Authentik, etc.) - **Authentik** SSO at `auth.example.com` - Services added as Ansible roles in `roles//` - Secrets managed via Ansible Vault (`vault.yml`) - Caddy configs use sites-enabled pattern (`/etc/caddy/sites-enabled/*.caddy`) ## Opal Service Profile | Property | opal-task | opal-web | |----------------|----------------------------------|----------------------------------| | Type | Go binary (REST API) | Static site (SvelteKit PWA) | | Database | SQLite (embedded) | None (client-side localStorage) | | External deps | None | None | | Port | `:8080` (configurable) | N/A (static files) | | Auth | OAuth2/JWT via Authentik | Passes tokens to API | | Build output | Single binary | `build/` directory (HTML/JS/CSS) | Key observation: opal has **zero external service dependencies** — no PostgreSQL, no Valkey, no message queues. SQLite is embedded in the Go binary. ## Recommended Deployment Architecture ``` opal.example.com (Caddy, auto-HTTPS) ├── /* → file_server /var/www/opal/ (static PWA) ├── /api/* → reverse_proxy :8080 (opal-task binary) └── /auth/* → reverse_proxy :8080 (opal-task OAuth endpoints) auth.example.com (Authentik, already running) └── OAuth2/OIDC provider for opal ``` ### Decision: Native systemd service, not containerized opal-task should run as a **native systemd service**, not in a Podman container. **Rationale:** - Single static binary with no runtime dependencies - SQLite is embedded — no database socket mounts needed - No benefit from container isolation (no network services to fence off, no complex filesystem needs) - Simpler deployment: copy binary, restart service - Matches patterns used by other native services in the infrastructure - Containerizing would add Quadlet files, image builds, and volume mounts for zero gain ### Decision: Static file serving for frontend opal-web builds to static HTML/JS/CSS via `adapter-static`. Caddy serves these directly with `file_server` and SPA fallback. No Node.js runtime on the server. **Rationale:** - SvelteKit adapter-static produces plain files — no SSR, no server process - Caddy already handles compression, caching headers, and HTTP/2 - Same pattern as other static frontend - PWA service worker handles offline caching client-side ### Decision: Keep SQLite, skip PostgreSQL opal-task is a single-user personal task manager with device sync. SQLite is the correct database. **Rationale:** - Single writer (one API server process) — no write contention - Backup is `sqlite3 .backup` or just `cp` the file - No connection pooling, no socket permissions, no user/role management - Moving to PostgreSQL would require rewriting the data layer for no benefit - If multi-user support is ever needed, this can be revisited ## Ansible Role Design Create `roles/opal/` in your-infra: ``` roles/opal/ ├── defaults/main.yml ├── meta/main.yml ├── tasks/ │ ├── main.yml │ ├── user.yml │ ├── backend.yml │ ├── frontend.yml │ └── caddy.yml ├── templates/ │ ├── opal.service.j2 │ ├── opal.env.j2 │ └── opal.caddy.j2 ├── handlers/ │ └── main.yml └── files/ └── (binary + static build copied here during deploy) ``` ### Role Dependencies ```yaml # meta/main.yml dependencies: - role: caddy ``` No dependency on `postgresql` or `valkey` — opal doesn't use them. ### Default Variables ```yaml # defaults/main.yml opal_domain: "opal.example.com" opal_user: "opal" opal_group: "opal" # Paths opal_binary_path: "/usr/local/bin/opal" opal_data_dir: "/var/lib/opal" opal_config_dir: "/etc/opal" opal_web_root: "/var/www/opal" opal_db_path: "{{ opal_data_dir }}/opal.db" # Server opal_server_addr: ":8080" # OAuth (secrets from vault) opal_oauth_enabled: true opal_oauth_issuer: "https://auth.example.com/application/o/opal/" opal_oauth_redirect_uri: "https://{{ opal_domain }}/auth/callback" opal_oauth_client_id: "{{ vault_opal_oauth_client_id }}" opal_oauth_client_secret: "{{ vault_opal_oauth_client_secret }}" # JWT (secret from vault) opal_jwt_secret: "{{ vault_opal_jwt_secret }}" opal_jwt_expiry: 3600 opal_refresh_token_expiry: 604800 ``` ### Vault Secrets Add to `host_vars/your-server/vault.yml`: ```yaml vault_opal_oauth_client_id: "" vault_opal_oauth_client_secret: "" vault_opal_jwt_secret: "" ``` ### Task Breakdown **user.yml** — Create dedicated system user: ```yaml - name: Create opal system user ansible.builtin.user: name: "{{ opal_user }}" group: "{{ opal_group }}" system: true shell: /bin/false home: "{{ opal_data_dir }}" create_home: false ``` **backend.yml** — Deploy binary and systemd unit: 1. Create directories (`/var/lib/opal`, `/etc/opal`) 2. Copy pre-built binary to `/usr/local/bin/opal` 3. Template environment file to `/etc/opal/opal.env` 4. Template systemd unit to `/etc/systemd/system/opal.service` 5. Enable and start the service **frontend.yml** — Deploy static build: 1. Create `/var/www/opal/` 2. Synchronize build output to web root 3. Set ownership to `root:root` (Caddy reads as root) **caddy.yml** — Deploy reverse proxy config: 1. Template Caddy site config to `/etc/caddy/sites-enabled/opal.caddy` 2. Notify Caddy reload handler ### systemd Unit Template ```ini # opal.service.j2 [Unit] Description=Opal Task API Server After=network.target [Service] Type=simple User={{ opal_user }} Group={{ opal_group }} WorkingDirectory={{ opal_data_dir }} EnvironmentFile={{ opal_config_dir }}/opal.env ExecStart={{ opal_binary_path }} server start --addr {{ opal_server_addr }} Restart=always RestartSec=5 # Hardening NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths={{ opal_data_dir }} ReadOnlyPaths={{ opal_config_dir }} ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true RestrictSUIDSGID=true RestrictNamespaces=true MemoryDenyWriteExecute=true [Install] WantedBy=multi-user.target ``` ### Caddy Site Template ```caddyfile # opal.caddy.j2 {{ opal_domain }} { root * {{ opal_web_root }} # API and auth endpoints → Go backend handle /api/* { uri strip_prefix /api reverse_proxy localhost{{ opal_server_addr }} } handle /auth/* { reverse_proxy localhost{{ opal_server_addr }} } # Static PWA with SPA fallback handle { try_files {path} /index.html file_server } header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Frame-Options "SAMEORIGIN" X-Content-Type-Options "nosniff" Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://auth.example.com;" -Server } log { output file /var/log/caddy/opal.log format json } encode gzip zstd } ``` ### Environment File Template ```bash # opal.env.j2 # Server SERVER_ADDR={{ opal_server_addr }} OPAL_CONFIG_DIR={{ opal_config_dir }} OPAL_DATA_DIR={{ opal_data_dir }} # OAuth OAUTH_ENABLED={{ opal_oauth_enabled | lower }} OAUTH_ISSUER={{ opal_oauth_issuer }} OAUTH_CLIENT_ID={{ opal_oauth_client_id }} OAUTH_CLIENT_SECRET={{ opal_oauth_client_secret }} OAUTH_REDIRECT_URI={{ opal_oauth_redirect_uri }} # JWT JWT_SECRET={{ opal_jwt_secret }} JWT_EXPIRY={{ opal_jwt_expiry }} REFRESH_TOKEN_EXPIRY={{ opal_refresh_token_expiry }} ``` ## Build & Deploy Strategy ### Option A: Local build + Ansible deploy (recommended for now) Build on the dev machine, deploy with Ansible: ```bash # Build backend (cross-compile for linux/amd64) cd opal-task GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o opal main.go # Build frontend cd opal-web npm run build # Deploy ansible-playbook playbooks/deploy-opal.yml --ask-vault-pass ``` The Ansible playbook copies the pre-built binary and static files to the server. This keeps the server clean (no Go toolchain, no Node.js). **Note on CGO:** opal-task uses `mattn/go-sqlite3` which requires CGO. Cross-compiling with `CGO_ENABLED=1` needs a C cross-compiler (`x86_64-linux-gnu-gcc` or equivalent). If building on the same architecture as the server, this just works. For true cross-compilation, consider using `zig cc` as the C compiler or building in a Docker container matching the target. ### Option B: GitHub Actions CI/CD (future) Follow the existing CI/CD pattern already in your-infra: 1. Push to `main` triggers workflow 2. Build Go binary in a Linux container (solves CGO cross-compilation) 3. Build SvelteKit static site 4. SSH to your-server, copy artifacts, restart service ```yaml # .github/workflows/deploy.yml (sketch) on: push: branches: [main] paths: - 'opal-task/**' - 'opal-web/**' jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build backend run: | cd opal-task go build -o opal main.go - name: Build frontend run: | cd opal-web npm ci npm run build - name: Deploy run: | scp opal-task/opal ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/opal rsync -avz --delete opal-web/build/ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/opal/ ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \ "sudo cp /tmp/opal /usr/local/bin/ && sudo systemctl restart opal" ``` ## Backup Strategy SQLite backup is trivial — one file, atomic snapshots: ```bash # Daily backup via cron or systemd timer sqlite3 /var/lib/opal/opal.db ".backup /var/backups/opal/opal-$(date +%Y%m%d).db" # Retention: keep 14 days find /var/backups/opal/ -name "opal-*.db" -mtime +14 -delete ``` This can be added as a systemd timer in the Ansible role, or as a cron entry. ## Monitoring opal-task exposes `GET /health` with no authentication. Add this to the existing metrics stack: - Caddy access logs in JSON at `/var/log/caddy/opal.log` - systemd journal for opal service (`journalctl -u opal`) - Health check polling from VictoriaMetrics or a simple uptime probe ## Playbook Integration Add to `your-infra.yml` (homelab playbook): ```yaml - hosts: your-server roles: # ... existing roles ... - role: opal tags: [opal] ``` Deploy independently: ```bash ansible-playbook your-infra.yml --tags opal --ask-vault-pass ``` Or create a dedicated `playbooks/deploy-opal.yml` for quick redeploys. ## Checklist - [ ] Create Authentik OAuth application for opal (see `docs/authentik-setup.md`) - [ ] Add vault secrets to `host_vars/your-server/vault.yml` - [ ] Create `roles/opal/` in your-infra - [ ] Point DNS `opal.example.com` to your-server IP - [ ] Build and deploy backend binary - [ ] Build and deploy frontend static files - [ ] Verify health check: `curl https://opal.example.com/api/health` - [ ] Test OAuth login flow end-to-end - [ ] Set up SQLite backup timer - [ ] (Optional) Add GitHub Actions CI/CD workflow