diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 7627872..bf42b1e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -36,6 +36,16 @@ jobs: mkdir -p ~/.ssh echo "StrictHostKeyChecking accept-new" >> ~/.ssh/config + - name: Build docs + run: nix build .#packages.x86_64-linux.docs -L + + - name: Publish to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy result --project-name=infra-dunkirk + - name: Deploy all configurations run: | nix run 'github:serokell/deploy-rs' -- \ diff --git a/docs/book.toml b/docs/book.toml new file mode 100644 index 0000000..e0ee86c --- /dev/null +++ b/docs/book.toml @@ -0,0 +1,14 @@ +[book] +title = "dunkirk.sh" +authors = ["Kieran Klukas"] +src = "src" + +[build] +build-dir = "./dist" +create-missing = false + +[output.html] +default-theme = "latte" +preferred-dark-theme = "mocha" +git-repository-url = "https://github.com/taciturnaxolotl/dots" +additional-css = ["./theme/catppuccin.css"] diff --git a/docs/src/README.md b/docs/src/README.md new file mode 100644 index 0000000..5453c82 --- /dev/null +++ b/docs/src/README.md @@ -0,0 +1,47 @@ +# dunkirk.sh + +Kieran's opinionated NixOS infrastructure — declarative server config, self-hosted services, and automated deployments. + +## Layout + +``` +~/dots +├── .github/workflows # CI/CD (deploy-rs + per-service reusable workflow) +├── dots # config files symlinked by home-manager +│ └── wallpapers +├── machines +│ ├── atalanta # macOS M4 (nix-darwin) +│ ├── ember # dell r210 server (basement) +│ ├── moonlark # framework 13 (dead) +│ ├── nest # shared tilde server (home-manager only) +│ ├── prattle # oracle cloud x86_64 +│ ├── tacyon # rpi 5 +│ └── terebithia # oracle cloud aarch64 (main server) +├── modules +│ ├── lib +│ │ └── mkService.nix # service factory (see Deployment section) +│ ├── home # home-manager modules +│ │ ├── aesthetics # theming and wallpapers +│ │ ├── apps # app configs (ghostty, helix, git, ssh, etc.) +│ │ ├── system # shell, environment +│ │ └── wm/hyprland +│ └── nixos # nixos modules +│ ├── apps # system-level app configs +│ ├── services # self-hosted services (mkService-based + custom) +│ │ ├── restic # backup system with CLI +│ │ └── bore # tunnel proxy +│ └── system # pam, wifi +├── packages # custom nix packages +└── secrets # agenix-encrypted secrets +``` + +## Machines + +| Name | Platform | Role | +|------|----------|------| +| **terebithia** | Oracle Cloud aarch64 | Main server — runs all services | +| **prattle** | Oracle Cloud x86_64 | Secondary server | +| **atalanta** | macOS M4 | Development laptop (nix-darwin) | +| **ember** | Dell R210 | Basement server | +| **tacyon** | Raspberry Pi 5 | Edge device | +| **nest** | Shared tilde | Home-manager only | diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md new file mode 100644 index 0000000..c2c3d9c --- /dev/null +++ b/docs/src/SUMMARY.md @@ -0,0 +1,30 @@ +# Summary + +[Overview](./README.md) + +- [Installation](./installation.md) +- [Deployment](./deployment.md) +- [Services](./services/README.md) + - [control](./services/control.md) + - [cedarlogic](./services/cedarlogic.md) + - [emojibot](./services/emojibot.md) + - [herald](./services/herald.md) + - [knot-sync](./services/knot-sync.md) + - [battleship-arena](./services/battleship-arena.md) + - [bore](./services/bore.md) +- [Backups](./backups.md) +- [Secrets](./secrets.md) +- [Modules](./modules/README.md) + - [tuigreet](./modules/tuigreet.md) + - [wifi](./modules/wifi.md) + - [shell](./modules/shell.md) + - [ssh](./modules/ssh.md) + - [helix](./modules/helix.md) + - [bore (client)](./modules/bore-client.md) + - [pbnj](./modules/pbnj.md) + - [wut](./modules/wut.md) + +# Reference + +- [mkService](./mkservice.md) +libdoc diff --git a/docs/src/backups.md b/docs/src/backups.md new file mode 100644 index 0000000..61d29d7 --- /dev/null +++ b/docs/src/backups.md @@ -0,0 +1,71 @@ +# Backups + +Services are automatically backed up nightly using restic to Backblaze B2. Backup targets are auto-discovered from `data.sqlite`/`data.postgres`/`data.files` declarations in mkService modules. + +## Schedule + +- **Time:** 02:00 AM daily +- **Random delay:** 0–2 hours (spreads load across services) +- **Retention:** 3 snapshots, 7 daily, 5 weekly, 12 monthly + +## CLI + +The `atelier-backup` command provides an interactive TUI: + +```bash +sudo atelier-backup # Interactive menu +sudo atelier-backup status # Show backup status for all services +sudo atelier-backup list # Browse snapshots +sudo atelier-backup backup # Trigger manual backup +sudo atelier-backup restore # Interactive restore wizard +sudo atelier-backup dr # Disaster recovery mode +``` + +## Service integration + +### Automatic (mkService) + +Services using `mkService` with `data.*` declarations get automatic backup: + +```nix +mkService { + name = "myapp"; + extraConfig = cfg: { + atelier.services.myapp.data = { + sqlite = "${cfg.dataDir}/data/app.db"; # Auto WAL checkpoint + stop/start + files = [ "${cfg.dataDir}/uploads" ]; # Just backed up, no hooks + }; + }; +} +``` + +The backup system automatically checkpoints SQLite WAL, stops the service during backup, and restarts after completion. + +### Manual registration + +For services not using `mkService`: + +```nix +atelier.backup.services.myservice = { + paths = [ "/var/lib/myservice" ]; + exclude = [ "*.log" "cache/*" ]; + preBackup = "systemctl stop myservice"; + postBackup = "systemctl start myservice"; +}; +``` + +## Disaster recovery + +On a fresh NixOS install: + +1. Rebuild from flake: `nixos-rebuild switch --flake .#hostname` +2. Run: `sudo atelier-backup dr` +3. All services restored from latest snapshots + +## Setup + +1. Create a B2 bucket and application key +2. Create agenix secrets for `restic/password`, `restic/env`, `restic/repo` +3. Enable: `atelier.backup.enable = true;` + +See [modules/nixos/services/restic/README.md](https://github.com/taciturnaxolotl/dots/blob/main/modules/nixos/services/restic/README.md) for full setup details. diff --git a/docs/src/deployment.md b/docs/src/deployment.md new file mode 100644 index 0000000..b9749ab --- /dev/null +++ b/docs/src/deployment.md @@ -0,0 +1,63 @@ +# Deployment + +Two deploy paths: **infrastructure** (NixOS config changes) and **application code** (per-service repos). + +## Infrastructure + +Pushing to `main` triggers `.github/workflows/deploy.yaml` which runs `deploy-rs` over Tailscale to rebuild NixOS on the target machine. + +```sh +# manual deploy +nix run 'github:serokell/deploy-rs' -- --remote-build --ssh-user kierank . +``` + +## Application Code + +Each service repo has a minimal workflow calling the reusable `.github/workflows/deploy-service.yml`. On push to `main`: + +1. Connects to Tailscale (`tag:deploy`) +2. SSHes as the **service user** (e.g., `cachet@terebithia`) via Tailscale SSH +3. Snapshots the SQLite DB (if `db_path` is provided) +4. `git pull` + `bun install --frozen-lockfile` + `sudo systemctl restart` +5. Health check (HTTP URL or systemd status fallback) +6. Auto-rollback on failure (restores DB snapshot + reverts to previous commit) + +Per-app workflow — copy and change the `with:` values: + +```yaml +name: Deploy +on: + push: + branches: [main] + workflow_dispatch: +jobs: + deploy: + uses: taciturnaxolotl/dots/.github/workflows/deploy-service.yml@main + with: + service: cachet + health_url: https://cachet.dunkirk.sh/health + db_path: /var/lib/cachet/data/cachet.db + secrets: + TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }} + TS_OAUTH_SECRET: ${{ secrets.TS_OAUTH_SECRET }} +``` + +Omit `health_url` to fall back to `systemctl is-active`. Omit `db_path` for stateless services. + +## mkService + +`modules/lib/mkService.nix` standardizes service modules. A call to `mkService { ... }` provides: + +- Systemd service with initial git clone (subsequent deploys via GitHub Actions) +- Caddy reverse proxy with TLS via Cloudflare DNS and optional rate limiting +- Data declarations (`sqlite`, `postgres`, `files`) that feed into automatic backups +- Dedicated system user with sudo for restart/stop/start (enables per-user Tailscale ACLs) +- Port conflict detection, security hardening, agenix secrets + +### Adding a new service + +1. Create a module in `modules/nixos/services/` +2. Enable it in `machines/terebithia/default.nix` +3. Add a deploy workflow to the app repo + +See `modules/nixos/services/cachet.nix` for a minimal example. diff --git a/docs/src/installation.md b/docs/src/installation.md new file mode 100644 index 0000000..32b09db --- /dev/null +++ b/docs/src/installation.md @@ -0,0 +1,72 @@ +# Installation + +> **Warning:** This configuration will not work without changing the [secrets](https://github.com/taciturnaxolotl/dots/tree/main/secrets) since they are encrypted with agenix. + +## macOS with nix-darwin + +1. Install Nix: + +```bash +curl -fsSL https://install.determinate.systems/nix | sh -s -- install +``` + +2. Clone and apply: + +```bash +git clone git@github.com:taciturnaxolotl/dots.git +cd dots +darwin-rebuild switch --flake .#atalanta +``` + +## Home Manager + +Install Nix, copy SSH keys, then: + +```bash +curl -fsSL https://install.determinate.systems/nix | sh -s -- install --determinate +git clone git@github.com:taciturnaxolotl/dots.git +cd dots +nix-shell -p home-manager +home-manager switch --flake .#nest +``` + +Set up [atuin](https://atuin.sh/) for shell history sync: + +```bash +atuin login +atuin import +``` + +## NixOS + +### Using nixos-anywhere (recommended for remote) + +> Only works with `prattle` and `terebithia` which have disko configs. + +```bash +nix run github:nix-community/nixos-anywhere -- \ + --flake .#prattle \ + --generate-hardware-config nixos-facter ./machines/prattle/facter.json \ + --build-on-remote \ + root@ +``` + +### Using the install script + +```bash +curl -L https://raw.githubusercontent.com/taciturnaxolotl/dots/main/install.sh -o install.sh +chmod +x install.sh +./install.sh +``` + +### Post-install + +After first boot, log in with user `kierank` and the default password, then: + +```bash +passwd kierank +sudo mv /etc/nixos ~/dots +sudo ln -s ~/dots /etc/nixos +sudo chown -R $(id -un):users ~/dots +atuin login && atuin sync +``` diff --git a/docs/src/mkservice.md b/docs/src/mkservice.md new file mode 100644 index 0000000..e537e9f --- /dev/null +++ b/docs/src/mkservice.md @@ -0,0 +1,101 @@ +# mkService + +`modules/lib/mkService.nix` is the service factory used by most atelier services. It takes a set of parameters and returns a NixOS module with standardized options, systemd service, Caddy reverse proxy, and backup integration. + +## Factory parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | string | *required* | Service identity — used for user, group, systemd unit, and option namespace | +| `description` | string | `" service"` | Human-readable description | +| `defaultPort` | int | `3000` | Default port if not overridden in config | +| `runtime` | string | `"bun"` | `"bun"`, `"node"`, or `"custom"` | +| `entryPoint` | string | `"src/index.ts"` | Script to run (ignored if `startCommand` is set) | +| `startCommand` | string | `null` | Override the full start command | +| `extraOptions` | attrset | `{}` | Additional NixOS options for this service | +| `extraConfig` | function | `cfg: {}` | Additional NixOS config when enabled (receives the service config) | + +## Options + +Every mkService module creates options under `atelier.services.`: + +### Core + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable the service | +| `domain` | string | *required* | Domain for Caddy reverse proxy | +| `port` | port | `defaultPort` | Port the service listens on | +| `dataDir` | path | `"/var/lib/"` | Data storage directory | +| `secretsFile` | path or null | `null` | Agenix secrets environment file | +| `repository` | string or null | `null` | Git repo URL — cloned once on first start | +| `healthUrl` | string or null | `null` | Health check URL for monitoring | +| `environment` | attrset | `{}` | Additional environment variables | + +### Data declarations + +Used by the backup system to automatically discover what to back up. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `data.sqlite` | string or null | `null` | SQLite database path (WAL checkpoint + stop/start during backup) | +| `data.postgres` | string or null | `null` | PostgreSQL database name (pg_dump during backup) | +| `data.files` | list of strings | `[]` | Additional file paths to back up | +| `data.exclude` | list of strings | `["*.log", "node_modules", ...]` | Glob patterns to exclude | + +### Caddy + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `caddy.enable` | bool | `true` | Enable Caddy reverse proxy | +| `caddy.extraConfig` | string | `""` | Additional Caddy directives | +| `caddy.rateLimit.enable` | bool | `false` | Enable rate limiting | +| `caddy.rateLimit.events` | int | `60` | Requests per window | +| `caddy.rateLimit.window` | string | `"1m"` | Rate limit time window | + +## What it sets up + +- **System user and group** — dedicated user in the `services` group with sudo for `systemctl restart/stop/start/status` +- **Systemd service** — `ExecStartPre` creates dirs as root, `preStart` clones repo and installs deps, `ExecStart` runs the application +- **Caddy virtual host** — TLS via Cloudflare DNS challenge, reverse proxy to localhost port +- **Port conflict detection** — assertions prevent two services from binding the same port +- **Security hardening** — `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp` + +## Example + +Minimal service module: + +```nix +let + mkService = import ../../lib/mkService.nix; +in +mkService { + name = "myapp"; + description = "My application"; + defaultPort = 3000; + runtime = "bun"; + entryPoint = "src/index.ts"; + + extraConfig = cfg: { + systemd.services.myapp.serviceConfig.Environment = [ + "DATABASE_PATH=${cfg.dataDir}/data/app.db" + ]; + + atelier.services.myapp.data = { + sqlite = "${cfg.dataDir}/data/app.db"; + }; + }; +} +``` + +Then enable in the machine config: + +```nix +atelier.services.myapp = { + enable = true; + domain = "myapp.dunkirk.sh"; + repository = "https://github.com/taciturnaxolotl/myapp"; + secretsFile = config.age.secrets.myapp.path; + healthUrl = "https://myapp.dunkirk.sh/health"; +}; +``` diff --git a/docs/src/modules/README.md b/docs/src/modules/README.md new file mode 100644 index 0000000..37326fe --- /dev/null +++ b/docs/src/modules/README.md @@ -0,0 +1,22 @@ +# Modules + +Custom NixOS and home-manager modules under the `atelier.*` namespace. These wrap and extend upstream packages with opinionated defaults and structured configuration. + +## NixOS modules + +| Module | Namespace | Description | +|--------|-----------|-------------| +| [tuigreet](./tuigreet.md) | `atelier.apps.tuigreet` | Login greeter with 30+ typed options | +| [wifi](./wifi.md) | `atelier.network.wifi` | Declarative Wi-Fi profiles with eduroam support | +| authentication | `atelier.authentication` | Fingerprint + PAM stack (fprintd, polkit, gnome-keyring) | + +## Home-manager modules + +| Module | Namespace | Description | +|--------|-----------|-------------| +| [shell](./shell.md) | `atelier.shell` | Zsh + oh-my-posh + Tangled workflow tooling | +| [ssh](./ssh.md) | `atelier.ssh` | SSH config with zmx persistent sessions | +| [helix](./helix.md) | `atelier.apps.helix` | Evil-helix with 15+ LSPs, wakatime, harper | +| [bore (client)](./bore-client.md) | `atelier.bore` | Tunnel client CLI for the bore server | +| [pbnj](./pbnj.md) | `atelier.pbnj` | Pastebin CLI with language detection | +| [wut](./wut.md) | `atelier.shell.wut` | Git worktree manager | diff --git a/docs/src/modules/bore-client.md b/docs/src/modules/bore-client.md new file mode 100644 index 0000000..53b5d36 --- /dev/null +++ b/docs/src/modules/bore-client.md @@ -0,0 +1,33 @@ +# bore (client) + +Interactive CLI for creating tunnels to the [bore server](../services/bore.md). Built with gum, supports HTTP, TCP, and UDP tunnels. + +## Options + +All options under `atelier.bore`: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Install the bore CLI | +| `serverAddr` | string | `"bore.dunkirk.sh"` | frps server address | +| `serverPort` | port | `7000` | frps server port | +| `domain` | string | `"bore.dunkirk.sh"` | Base domain for constructing public URLs | +| `authTokenFile` | path | — | Path to frp auth token file | + +## Usage + +```bash +bore # Interactive menu +bore myapp 3000 # Quick HTTP tunnel: myapp.bore.dunkirk.sh → localhost:3000 +bore myapp 3000 --auth # With OAuth authentication +bore myapp 3000 --save # Save to bore.toml for reuse +``` + +Tunnels can also be defined in a `bore.toml`: + +```toml +[myapp] +port = 3000 +auth = true +labels = ["dev"] +``` diff --git a/docs/src/modules/helix.md b/docs/src/modules/helix.md new file mode 100644 index 0000000..eabbff9 --- /dev/null +++ b/docs/src/modules/helix.md @@ -0,0 +1,36 @@ +# helix + +Evil-helix (vim-mode fork) with comprehensive LSP setup, wakatime tracking on every language, and harper grammar checking. + +## Options + +All options under `atelier.apps.helix`: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable helix configuration | +| `swift` | bool | `false` | Add sourcekit-lsp for Swift (platform-conditional) | + +## Language servers + +The module configures 15+ language servers out of the box: + +| Language | Server | +|----------|--------| +| Nix | nixd + nil | +| TypeScript/JavaScript | typescript-language-server + biome | +| Go | gopls | +| Python | pylsp | +| Rust | rust-analyzer | +| HTML/CSS | vscode-html-language-server, vscode-css-language-server | +| JSON | vscode-json-language-server + biome | +| TOML | taplo | +| Markdown | marksman | +| YAML | yaml-language-server | +| Swift | sourcekit-lsp (when `swift = true`) | + +All languages also get: +- **wakatime-ls** — coding time tracking +- **harper-ls** — grammar and spell checking + +> **Note:** After install, run `hx -g fetch && hx -g build` to compile tree-sitter grammars. diff --git a/docs/src/modules/pbnj.md b/docs/src/modules/pbnj.md new file mode 100644 index 0000000..e8ccccc --- /dev/null +++ b/docs/src/modules/pbnj.md @@ -0,0 +1,25 @@ +# pbnj + +Pastebin CLI with automatic language detection, clipboard integration, and agenix auth. + +## Options + +All options under `atelier.pbnj`: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Install the pbnj CLI | +| `host` | string | — | Pastebin instance URL | +| `authKeyFile` | path | — | Path to auth key file (e.g. agenix secret) | + +## Usage + +```bash +pbnj # Interactive menu +pbnj upload myfile.py # Upload file (auto-detects Python) +cat output.log | pbnj upload # Upload from stdin +pbnj list # List pastes +pbnj delete # Delete a paste +``` + +Supports 25+ languages via file extension detection. Automatically copies the URL to clipboard (wl-copy/xclip/pbcopy depending on platform). diff --git a/docs/src/modules/shell.md b/docs/src/modules/shell.md new file mode 100644 index 0000000..0e82daa --- /dev/null +++ b/docs/src/modules/shell.md @@ -0,0 +1,30 @@ +# shell + +Zsh configuration with oh-my-posh prompt, syntax highlighting, fzf-tab, zoxide, and Tangled git workflow tooling. + +## Options + +All options under `atelier.shell`: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable shell configuration | + +### Tangled + +Options for the `tangled-setup` and `mkdev` scripts that manage dual-remote git workflows (Tangled knot + GitHub). + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `tangled.plcId` | string | — | ATProto DID for Tangled identity | +| `tangled.githubUser` | string | — | GitHub username | +| `tangled.knotHost` | string | — | Knot git host (e.g. `knot.dunkirk.sh`) | +| `tangled.domain` | string | — | Tangled domain for repo URLs | +| `tangled.defaultBranch` | string | `"main"` | Default branch name | + +### Included tools + +- **`tangled-setup`** — configures a repo with `origin` pointing to knot and `github` pointing to GitHub +- **`mkdev`** — creates a new repo on both Tangled and GitHub simultaneously +- **oh-my-posh** — custom prompt showing path, git status (ahead/behind), exec time, nix-shell indicator, ZMX session, SSH hostname +- **Aliases** — `cat=bat`, `ls=eza`, `cd=z` (zoxide), and more diff --git a/docs/src/modules/ssh.md b/docs/src/modules/ssh.md new file mode 100644 index 0000000..25ff1c0 --- /dev/null +++ b/docs/src/modules/ssh.md @@ -0,0 +1,57 @@ +# ssh + +Declarative SSH config with per-host options and zmx (persistent tmux-like sessions over SSH) integration. + +## Options + +All options under `atelier.ssh`: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable SSH config management | +| `extraConfig` | string | `""` | Raw SSH config appended to the end | + +### zmx + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `zmx.enable` | bool | `false` | Install zmx and autossh | +| `zmx.hosts` | list of strings | `[]` | Host patterns to auto-attach via zmx | + +When zmx is enabled for a host, the SSH config injects `RemoteCommand`, `RequestTTY force`, and `ControlMaster`/`ControlPersist` settings. Shell aliases are also added: `zmls`, `zmk`, `zma`, `ash`. + +### Hosts + +Per-host config under `atelier.ssh.hosts.`: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `hostname` | string | — | SSH hostname or IP | +| `port` | int or null | `null` | SSH port | +| `user` | string or null | `null` | SSH user | +| `identityFile` | string or null | `null` | Path to SSH key | +| `forwardAgent` | bool | `false` | Forward SSH agent | +| `zmx` | bool | `false` | Enable zmx for this host | +| `extraOptions` | attrsOf string | `{}` | Arbitrary SSH options | + +## Example + +```nix +atelier.ssh = { + enable = true; + zmx.enable = true; + zmx.hosts = [ "terebithia" "ember" ]; + + hosts = { + terebithia = { + hostname = "terebithia"; + user = "kierank"; + forwardAgent = true; + zmx = true; + }; + "github.com" = { + identityFile = "~/.ssh/id_rsa"; + }; + }; +}; +``` diff --git a/docs/src/modules/tuigreet.md b/docs/src/modules/tuigreet.md new file mode 100644 index 0000000..391398e --- /dev/null +++ b/docs/src/modules/tuigreet.md @@ -0,0 +1,70 @@ +# tuigreet + +Configures greetd with tuigreet as the login greeter. Exposes nearly every tuigreet CLI flag as a typed Nix option. + +## Options + +All options under `atelier.apps.tuigreet`: + +### Core + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable tuigreet | +| `command` | string | `"Hyprland"` | Session command to run after login | +| `greeting` | string | *(unauthorized access warning)* | Greeting message | + +### Display + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `time` | bool | `false` | Show clock | +| `timeFormat` | string | `"%H:%M"` | Clock format | +| `issue` | bool | `false` | Show `/etc/issue` | +| `width` | int | `80` | UI width | +| `theme` | string | `""` | Theme string | +| `asterisks` | bool | `false` | Show asterisks for password | +| `asterisksChar` | string | `"*"` | Character for password masking | + +### Layout + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `windowPadding` | int | `0` | Window padding | +| `containerPadding` | int | `1` | Container padding | +| `promptPadding` | int | `1` | Prompt padding | +| `greetAlign` | enum | `"center"` | Greeting alignment: `left`, `center`, `right` | + +### Session management + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `remember` | bool | `false` | Remember last username | +| `rememberSession` | bool | `false` | Remember last session | +| `rememberUserSession` | bool | `false` | Per-user session memory | +| `sessions` | string | `""` | Wayland session search path | +| `xsessions` | string | `""` | X11 session search path | +| `sessionWrapper` | string | `""` | Session wrapper command | + +### User menu + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `userMenu` | bool | `false` | Show user selection menu | +| `userMenuMinUid` | int | `1000` | Minimum UID in user menu | +| `userMenuMaxUid` | int | `65534` | Maximum UID in user menu | + +### Power commands + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `powerShutdown` | string | `""` | Shutdown command | +| `powerReboot` | string | `""` | Reboot command | + +### Keybindings + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `kbCommand` | enum | `"F2"` | Key to switch command | +| `kbSessions` | enum | `"F3"` | Key to switch session | +| `kbPower` | enum | `"F12"` | Key for power menu | diff --git a/docs/src/modules/wifi.md b/docs/src/modules/wifi.md new file mode 100644 index 0000000..d8f3be6 --- /dev/null +++ b/docs/src/modules/wifi.md @@ -0,0 +1,53 @@ +# wifi + +Declarative Wi-Fi profile manager using NetworkManager. Supports three ways to supply passwords and has built-in eduroam (WPA-EAP) support. + +## Options + +All options under `atelier.network.wifi`: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable Wi-Fi management | +| `hostName` | string | — | Sets `networking.hostName` | +| `nameservers` | list of strings | `[]` | Custom DNS servers | +| `envFile` | path | — | Environment file providing PSK variables for all profiles | + +### Profiles + +Defined under `atelier.network.wifi.profiles.`: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `psk` | string or null | `null` | Literal WPA-PSK passphrase | +| `pskVar` | string or null | `null` | Environment variable name containing the PSK (from `envFile`) | +| `pskFile` | path or null | `null` | Path to file containing the PSK | +| `eduroam` | bool | `false` | Use WPA-EAP with MSCHAPV2 (for eduroam networks) | +| `identity` | string or null | `null` | EAP identity (required when `eduroam = true`) | + +Only one of `psk`, `pskVar`, or `pskFile` should be set per profile. + +## Example + +```nix +atelier.network.wifi = { + enable = true; + hostName = "moonlark"; + nameservers = [ "1.1.1.1" "8.8.8.8" ]; + envFile = config.age.secrets.wifi.path; + + profiles = { + "Home Network" = { + pskVar = "HOME_PSK"; # read from envFile + }; + "eduroam" = { + eduroam = true; + identity = "user@university.edu"; + pskVar = "EDUROAM_PSK"; + }; + "Phone Hotspot" = { + pskFile = config.age.secrets.hotspot.path; + }; + }; +}; +``` diff --git a/docs/src/modules/wut.md b/docs/src/modules/wut.md new file mode 100644 index 0000000..979cbea --- /dev/null +++ b/docs/src/modules/wut.md @@ -0,0 +1,43 @@ +# wut + +**W**orktrees **U**nexpectedly **T**olerable — a git worktree manager that keeps worktrees organized under `.worktrees/`. + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `atelier.shell.wut.enable` | bool | `false` | Install wut and the zsh shell wrapper | + +## Usage + +```bash +wut new feat/my-feature # Create worktree + branch under .worktrees/ +wut list # Show all worktrees +wut go feat/my-feature # cd into worktree (via shell wrapper) +wut go # Interactive picker +wut path feat/my-feature # Print worktree path +wut rm feat/my-feature # Remove worktree + delete branch +``` + +## Shell integration + +Wut needs to `cd` the calling shell, which a subprocess can't do directly. It works by printing a `__WUT_CD__=/path` marker that a zsh wrapper function intercepts: + +```zsh +wut() { + output=$(/path/to/wut "$@") + if [[ "$output" == *"__WUT_CD__="* ]]; then + cd "${output##*__WUT_CD__=}" + else + echo "$output" + fi +} +``` + +This wrapper is automatically injected into `initContent` when the module is enabled. + +## Safety + +- `wut rm` refuses to delete worktrees with uncommitted changes (use `--force` to override) +- `wut rm` warns before deleting unmerged branches +- The main/master branch worktree cannot be removed diff --git a/docs/src/secrets.md b/docs/src/secrets.md new file mode 100644 index 0000000..60ee99e --- /dev/null +++ b/docs/src/secrets.md @@ -0,0 +1,55 @@ +# Secrets + +Secrets are managed using [agenix](https://github.com/ryantm/agenix) — encrypted at rest in the repo and decrypted at activation time to `/run/agenix/`. + +## Usage + +Create or edit a secret: + +```bash +cd secrets && agenix -e myapp.age +``` + +The secret file contains environment variables, one per line: + +``` +DATABASE_URL=postgres://... +API_KEY=xxxxx +SECRET_TOKEN=yyyyy +``` + +## Adding a new secret + +1. Add the public key entry to `secrets/secrets.nix`: + +```nix +"service-name.age".publicKeys = [ kierank ]; +``` + +2. Create and encrypt the secret: + +```bash +agenix -e secrets/service-name.age +``` + +3. Declare in machine config: + +```nix +age.secrets.service-name = { + file = ../../secrets/service-name.age; + owner = "service-name"; +}; +``` + +4. Reference as `config.age.secrets.service-name.path` in the service module. + +## Identity paths + +The decryption keys are SSH keys configured per machine: + +```nix +age.identityPaths = [ + "/home/kierank/.ssh/id_rsa" + "/etc/ssh/id_rsa" +]; +``` diff --git a/docs/src/services/README.md b/docs/src/services/README.md new file mode 100644 index 0000000..0d4f5a6 --- /dev/null +++ b/docs/src/services/README.md @@ -0,0 +1,44 @@ +# Services + +All services run on **terebithia** (Oracle Cloud aarch64) behind Caddy with Cloudflare DNS TLS. + +## mkService-based + +| Service | Domain | Port | Runtime | Description | +|---------|--------|------|---------|-------------| +| cachet | cachet.dunkirk.sh | 3000 | bun | Slack emoji/profile cache | +| hn-alerts | hn.dunkirk.sh | 3001 | bun | Hacker News monitoring | +| indiko | indiko.dunkirk.sh | 3003 | bun | IndieAuth/OAuth2 server | +| l4 | l4.dunkirk.sh | 3004 | bun | Image CDN — Slack image optimizer | +| canvas-mcp | canvas.dunkirk.sh | 3006 | bun | Canvas MCP server | +| control | control.dunkirk.sh | 3010 | bun | Admin dashboard for Caddy toggles | +| traverse | traverse.dunkirk.sh | 4173 | bun | Code walkthrough diagram server | +| cedarlogic | cedarlogic.dunkirk.sh | 3100 | custom | Circuit simulator | + +## Multi-instance + +| Service | Domain | Port | Description | +|---------|--------|------|-------------| +| emojibot-hackclub | hc.emojibot.dunkirk.sh | 3002 | Emojibot for Hack Club | +| emojibot-df1317 | df.emojibot.dunkirk.sh | 3005 | Emojibot for df1317 | + +## Custom / external + +| Service | Domain | Description | +|---------|--------|-------------| +| bore (frps) | bore.dunkirk.sh | HTTP/TCP/UDP tunnel proxy | +| herald | herald.dunkirk.sh | Git SSH hosting + email | +| knot | knot.dunkirk.sh | Tangled git hosting | +| spindle | spindle.dunkirk.sh | Tangled CI | +| battleship-arena | battleship.dunkirk.sh | Battleship game server | +| n8n | n8n.dunkirk.sh | Workflow automation | + +## Architecture + +Each mkService module provides: + +- **Systemd service** — initial git clone for scaffolding, subsequent deploys via GitHub Actions +- **Caddy reverse proxy** — TLS via Cloudflare DNS challenge, optional rate limiting +- **Data declarations** — `sqlite`, `postgres`, `files` feed into automatic backups +- **Dedicated user** — sudo for restart/stop/start, per-user Tailscale SSH ACLs +- **Port conflict detection** — assertions prevent two services binding the same port diff --git a/docs/src/services/battleship-arena.md b/docs/src/services/battleship-arena.md new file mode 100644 index 0000000..94178e8 --- /dev/null +++ b/docs/src/services/battleship-arena.md @@ -0,0 +1,21 @@ +# battleship-arena + +Battleship game server with web interface and SSH-based bot submission. + +**Domain:** `battleship.dunkirk.sh` · **Web Port:** 8081 · **SSH Port:** 2222 + +This is a **custom module** — it does not use mkService. + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable battleship-arena | +| `domain` | string | `"battleship.dunkirk.sh"` | Domain for Caddy reverse proxy | +| `sshPort` | port | `2222` | SSH port for bot submissions | +| `webPort` | port | `8081` | Web interface port | +| `uploadDir` | string | `"/var/lib/battleship-arena/submissions"` | Bot upload directory | +| `resultsDb` | string | `"/var/lib/battleship-arena/results.db"` | SQLite results database path | +| `adminPasscode` | string | `"battleship-admin-override"` | Admin passcode | +| `secretsFile` | path or null | `null` | Agenix secrets file | +| `package` | package | — | Battleship-arena package (from flake input) | diff --git a/docs/src/services/bore.md b/docs/src/services/bore.md new file mode 100644 index 0000000..6c40bd0 --- /dev/null +++ b/docs/src/services/bore.md @@ -0,0 +1,43 @@ +# bore (server) + +Lightweight tunneling server built on frp. Supports HTTP (wildcard subdomains), TCP, and UDP tunnels with optional OAuth authentication via Indiko. + +**Domain:** `bore.dunkirk.sh` · **frp port:** 7000 + +This is a **custom module** — it does not use mkService. + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable bore server | +| `domain` | string | — | Base domain for wildcard subdomains | +| `bindAddr` | string | `"0.0.0.0"` | frps bind address | +| `bindPort` | port | `7000` | frps bind port | +| `vhostHTTPPort` | port | `7080` | Virtual host HTTP port | +| `allowedTCPPorts` | list of ports | `20000–20099` | Ports available for TCP tunnels | +| `allowedUDPPorts` | list of ports | `20000–20099` | Ports available for UDP tunnels | +| `authToken` | string or null | `null` | frp auth token (use `authTokenFile` instead) | +| `authTokenFile` | path or null | `null` | Path to file containing frp auth token | +| `enableCaddy` | bool | `true` | Auto-configure Caddy wildcard vhost | + +### Authentication + +When enabled, all HTTP tunnels are gated behind Indiko OAuth. Users must sign in before accessing tunneled services. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `auth.enable` | bool | `false` | Enable bore-auth OAuth middleware | +| `auth.indikoURL` | string | `"https://indiko.dunkirk.sh"` | Indiko server URL | +| `auth.clientID` | string | — | OAuth client ID from Indiko | +| `auth.clientSecretFile` | path | — | Path to OAuth client secret | +| `auth.cookieHashKeyFile` | path | — | 32-byte cookie signing key | +| `auth.cookieBlockKeyFile` | path | — | 32-byte cookie encryption key | + +After authentication, these headers are passed to tunneled services: + +- `X-Auth-User` — user's profile URL +- `X-Auth-Name` — display name +- `X-Auth-Email` — email address + +See [bore (client)](../modules/bore-client.md) for the home-manager client module. diff --git a/docs/src/services/cedarlogic.md b/docs/src/services/cedarlogic.md new file mode 100644 index 0000000..2556061 --- /dev/null +++ b/docs/src/services/cedarlogic.md @@ -0,0 +1,34 @@ +# cedarlogic + +Browser-based circuit simulator with real-time collaboration via WebSockets. + +**Domain:** `cedarlogic.dunkirk.sh` · **Port:** 3100 · **Runtime:** custom + +## Extra options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `wsPort` | port | `3101` | Hocuspocus WebSocket server for document collaboration | +| `cursorPort` | port | `3102` | Cursor relay WebSocket server for live cursors | +| `branch` | string | `"web"` | Git branch to clone (uses `web` branch, not `main`) | + +## Caddy routing + +Cedarlogic disables the default mkService Caddy config and uses path-based routing to three backends: + +| Path | Backend | +|------|---------| +| `/ws` | `wsPort` (Hocuspocus) | +| `/cursor-ws` | `cursorPort` (cursor relay) | +| `/api/*`, `/auth/*` | main `port` | +| Everything else | Static files from `dist/` | + +## Build step + +Unlike other services, cedarlogic runs a build during deploy: + +``` +bun install → parse-gates → bun run build (Vite) +``` + +The build has a 120s timeout to accommodate Vite compilation. diff --git a/docs/src/services/control.md b/docs/src/services/control.md new file mode 100644 index 0000000..1373071 --- /dev/null +++ b/docs/src/services/control.md @@ -0,0 +1,40 @@ +# control + +Admin dashboard for Caddy feature toggles. Provides a web UI to enable/disable paths on other services (e.g. blocking player tracking on the map). + +**Domain:** `control.dunkirk.sh` · **Port:** 3010 · **Runtime:** bun + +## Extra options + +### `flags` + +Defines per-domain feature flags that control blocks paths and redacts JSON fields. + +```nix +atelier.services.control.flags."map.dunkirk.sh" = { + name = "Map"; + flags = { + "block-tracking" = { + name = "Block Player Tracking"; + description = "Disable real-time player location updates"; + paths = [ + "/sse" + "/sse/*" + "/tiles/*/markers/pl3xmap_players.json" + ]; + redact."/tiles/settings.json" = [ "players" ]; + }; + }; +}; +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `flags` | attrsOf submodule | `{}` | Services and their feature flags, keyed by domain | +| `flags..name` | string | — | Display name for the service | +| `flags..flags..name` | string | — | Display name for the flag | +| `flags..flags..description` | string | — | What the flag does | +| `flags..flags..paths` | list of strings | `[]` | URL paths to block when flag is active | +| `flags..flags..redact` | attrsOf (list of strings) | `{}` | JSON fields to redact from responses, keyed by path | + +The flags config is serialized to `flags.json` and passed to control via the `FLAGS_CONFIG` environment variable. diff --git a/docs/src/services/emojibot.md b/docs/src/services/emojibot.md new file mode 100644 index 0000000..7f6fdf2 --- /dev/null +++ b/docs/src/services/emojibot.md @@ -0,0 +1,42 @@ +# emojibot + +Slack emoji management service. Supports multiple instances for different workspaces. + +**Runtime:** bun · **Stateless** (no database) + +This is a **custom module** — it does not use mkService. Each instance gets its own systemd service, user, and Caddy virtual host. + +## Instance options + +Instances are defined under `atelier.services.emojibot.instances.`: + +```nix +atelier.services.emojibot.instances = { + hackclub = { + enable = true; + domain = "hc.emojibot.dunkirk.sh"; + port = 3002; + workspace = "hackclub"; + channel = "C02T3CU03T3"; + repository = "https://github.com/taciturnaxolotl/emojibot"; + secretsFile = config.age.secrets."emojibot/hackclub".path; + }; +}; +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable this instance | +| `domain` | string | — | Domain for Caddy reverse proxy | +| `port` | port | — | Port to run on | +| `secretsFile` | path | — | Agenix secrets file with Slack credentials | +| `repository` | string | `"https://github.com/taciturnaxolotl/emojibot"` | Git repo URL | +| `workspace` | string or null | `null` | Slack workspace name (for identification) | +| `channel` | string or null | `null` | Slack channel ID | + +## Current instances + +| Instance | Domain | Port | Workspace | +|----------|--------|------|-----------| +| hackclub | hc.emojibot.dunkirk.sh | 3002 | Hack Club | +| df1317 | df.emojibot.dunkirk.sh | 3005 | df1317 | diff --git a/docs/src/services/herald.md b/docs/src/services/herald.md new file mode 100644 index 0000000..41720f4 --- /dev/null +++ b/docs/src/services/herald.md @@ -0,0 +1,39 @@ +# herald + +Git SSH hosting with email notifications. Provides a git push interface over SSH and sends email via SMTP/DKIM. + +**Domain:** `herald.dunkirk.sh` · **SSH Port:** 2223 · **HTTP Port:** 8085 + +This is a **custom module** — it does not use mkService. + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable herald | +| `domain` | string | — | Domain for Caddy reverse proxy | +| `host` | string | `"0.0.0.0"` | Listen address | +| `sshPort` | port | `2223` | SSH listen port | +| `externalSshPort` | port | `2223` | External SSH port (if behind NAT) | +| `httpPort` | port | `8085` | HTTP API port | +| `dataDir` | path | `"/var/lib/herald"` | Data directory | +| `allowAllKeys` | bool | `true` | Allow all SSH keys | +| `secretsFile` | path | — | Agenix secrets (must contain `SMTP_PASS`) | +| `package` | package | `pkgs.herald` | Herald package | + +### SMTP + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `smtp.host` | string | — | SMTP server hostname | +| `smtp.port` | port | `587` | SMTP server port | +| `smtp.user` | string | — | SMTP username | +| `smtp.from` | string | — | Sender address | + +### DKIM + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `smtp.dkim.selector` | string or null | `null` | DKIM selector | +| `smtp.dkim.domain` | string or null | `null` | DKIM signing domain | +| `smtp.dkim.privateKeyFile` | path or null | `null` | Path to DKIM private key | diff --git a/docs/src/services/knot-sync.md b/docs/src/services/knot-sync.md new file mode 100644 index 0000000..8f50e57 --- /dev/null +++ b/docs/src/services/knot-sync.md @@ -0,0 +1,16 @@ +# knot-sync + +Mirrors Tangled knot repositories to GitHub on a cron schedule. + +This is a **custom module** — it does not use mkService. Runs as a systemd timer, not a long-running service. + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable knot-sync | +| `repoDir` | string | `"/home/git/did:plc:..."` | Directory containing knot git repos | +| `githubUsername` | string | `"taciturnaxolotl"` | GitHub username to mirror to | +| `secretsFile` | path | — | Agenix secrets (must contain `GITHUB_TOKEN`) | +| `logFile` | string | `"/home/git/knot-sync.log"` | Log file path | +| `interval` | string | `"*/5 * * * *"` | Cron schedule for sync | diff --git a/flake.lock b/flake.lock index 0d297da..95bff8d 100644 --- a/flake.lock +++ b/flake.lock @@ -859,22 +859,6 @@ "type": "github" } }, - "nixpkgs-fetch-deno": { - "locked": { - "lastModified": 1766410835, - "narHash": "sha256-dRhVt0aFDyTqppyzRLxiO1JZEAoIA2fUnaeyJTe+UwU=", - "owner": "aMOPel", - "repo": "nixpkgs", - "rev": "c9801acc8c4fac6377d076bc1c102b15bd9cfa6f", - "type": "github" - }, - "original": { - "owner": "aMOPel", - "ref": "feat/fetchDenoDeps", - "repo": "nixpkgs", - "type": "github" - } - }, "nixpkgs-lib": { "locked": { "lastModified": 1740877520, @@ -1152,7 +1136,6 @@ "spicetify-nix": "spicetify-nix", "tangled": "tangled", "terminal-wakatime": "terminal-wakatime", - "tranquil-pds": "tranquil-pds", "wakatime-ls": "wakatime-ls", "zmx": "zmx" } @@ -1417,27 +1400,6 @@ "type": "github" } }, - "tranquil-pds": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ], - "nixpkgs-fetch-deno": "nixpkgs-fetch-deno" - }, - "locked": { - "lastModified": 1770060543, - "narHash": "sha256-bc8z8o96Rbud7KgBHWFOM+3GNxGPMHIaVEPVCULYJUA=", - "ref": "refs/heads/main", - "rev": "442ca1434f81d1fe2164846d2391f0e33bea47a4", - "revCount": 164, - "type": "git", - "url": "https://tangled.org/tranquil.farm/tranquil-pds" - }, - "original": { - "type": "git", - "url": "https://tangled.org/tranquil.farm/tranquil-pds" - } - }, "utils": { "inputs": { "systems": "systems_4" diff --git a/flake.nix b/flake.nix index dc600cb..4c377f7 100644 --- a/flake.nix +++ b/flake.nix @@ -119,10 +119,6 @@ url = "github:neurosnap/zmx"; }; - tranquil-pds = { - url = "git+https://tangled.org/tranquil.farm/tranquil-pds"; - inputs.nixpkgs.follows = "nixpkgs"; - }; }; outputs = @@ -275,6 +271,33 @@ }; }; + # Service manifest for infra dashboard + # Evaluate with: nix eval --json .#services-manifest + services-manifest = import ./lib/services-manifest.nix { + config = self.nixosConfigurations.terebithia.config; + lib = nixpkgs.lib; + }; + + # Documentation site (mdBook + nixdoc + atelier options) + # Build with: nix build .#docs + # Serve with: nix run .#docs.serve + packages = + let + mkDocs = system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + pkgs.callPackage ./packages/docs.nix { + servicesManifest = self.services-manifest; + inherit self; + }; + in + { + x86_64-linux.docs = mkDocs "x86_64-linux"; + aarch64-linux.docs = mkDocs "aarch64-linux"; + aarch64-darwin.docs = mkDocs "aarch64-darwin"; + }; + formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-tree; formatter.aarch64-darwin = nixpkgs.legacyPackages.aarch64-darwin.nixfmt-tree; diff --git a/lib/services-manifest.nix b/lib/services-manifest.nix new file mode 100644 index 0000000..61e8033 --- /dev/null +++ b/lib/services-manifest.nix @@ -0,0 +1,17 @@ +# Generate a JSON-serialisable manifest of all atelier services. +# +# Called from flake.nix: +# services-manifest = import ./lib/services-manifest.nix { +# config = self.nixosConfigurations.terebithia.config; +# inherit lib; +# }; +# +# Evaluate with: +# nix eval --json .#services-manifest + +{ config, lib }: + +let + services = import ./services.nix { inherit lib; }; +in +services.mkManifest config diff --git a/lib/services.nix b/lib/services.nix new file mode 100644 index 0000000..fe3b042 --- /dev/null +++ b/lib/services.nix @@ -0,0 +1,142 @@ +/** Service utility functions for the atelier infrastructure. + + These functions operate on NixOS configurations to extract + service metadata for dashboards, monitoring, and documentation. +*/ +{ lib }: + +{ + /** + Check whether an atelier service config value has the standard + mkService shape (has `enable`, `domain`, `port`, `_description`). + + # Arguments + + - `cfg` — an attribute set from `config.atelier.services.` + + # Type + + ``` + AttrSet -> Bool + ``` + + # Example + + ```nix + isMkService config.atelier.services.cachet + => true + ``` + */ + isMkService = cfg: + (cfg.enable or false) + && (cfg ? domain) + && (cfg ? port) + && (cfg ? _description); + + /** + Convert a single mkService config into a manifest entry. + + # Arguments + + - `name` — the service name (attribute key) + - `cfg` — the service config attrset + + # Type + + ``` + String -> AttrSet -> AttrSet + ``` + + # Example + + ```nix + mkServiceEntry "cachet" config.atelier.services.cachet + => { name = "cachet"; domain = "cachet.dunkirk.sh"; ... } + ``` + */ + mkServiceEntry = name: cfg: { + inherit name; + description = cfg._description or "${name} service"; + domain = cfg.domain; + port = cfg.port; + runtime = cfg._runtime or "unknown"; + repository = cfg.repository or null; + health_url = cfg.healthUrl or null; + data = { + sqlite = cfg.data.sqlite or null; + postgres = cfg.data.postgres or null; + files = cfg.data.files or []; + }; + }; + + /** + Build the full services manifest from an evaluated NixOS config. + + Discovers all enabled mkService-based services plus emojibot + instances. Returns a sorted list of service entries suitable + for JSON serialisation. + + # Arguments + + - `config` — the fully evaluated NixOS configuration + + # Type + + ``` + AttrSet -> [ AttrSet ] + ``` + + # Example + + ```nix + mkManifest config + => [ { name = "cachet"; domain = "cachet.dunkirk.sh"; ... } ... ] + ``` + */ + mkManifest = config: + let + allServices = config.atelier.services; + + isMkSvc = _: v: + (v.enable or false) + && (v ? domain) + && (v ? port) + && (v ? _description); + + standardServices = lib.filterAttrs isMkSvc allServices; + + mkEntry = name: cfg: { + inherit name; + description = cfg._description or "${name} service"; + domain = cfg.domain; + port = cfg.port; + runtime = cfg._runtime or "unknown"; + repository = cfg.repository or null; + health_url = cfg.healthUrl or null; + data = { + sqlite = cfg.data.sqlite or null; + postgres = cfg.data.postgres or null; + files = cfg.data.files or []; + }; + }; + + emojibotInstances = + let + instances = allServices.emojibot.instances or {}; + enabled = lib.filterAttrs (_: v: v.enable or false) instances; + in + lib.mapAttrsToList (name: inst: { + name = "emojibot-${name}"; + description = "Emojibot for ${inst.workspace or name}"; + domain = inst.domain; + port = inst.port; + runtime = "bun"; + repository = inst.repository or null; + health_url = null; + data = { sqlite = null; postgres = null; files = []; }; + }) enabled; + + serviceList = (lib.mapAttrsToList mkEntry standardServices) ++ emojibotInstances; + in + lib.sort (a: b: a.name < b.name) serviceList; +} diff --git a/machines/terebithia/default.nix b/machines/terebithia/default.nix index 7c57a8f..161eea7 100644 --- a/machines/terebithia/default.nix +++ b/machines/terebithia/default.nix @@ -384,6 +384,7 @@ domain = "cachet.dunkirk.sh"; repository = "https://github.com/taciturnaxolotl/cachet"; secretsFile = config.age.secrets.cachet.path; + healthUrl = "https://cachet.dunkirk.sh/health?detailed=true"; }; atelier.services.hn-alerts = { @@ -391,6 +392,7 @@ domain = "hn.dunkirk.sh"; repository = "https://github.com/taciturnaxolotl/hn-alerts"; secretsFile = config.age.secrets.hn-alerts.path; + healthUrl = "https://hn.dunkirk.sh/health"; }; atelier.services.emojibot.instances = { @@ -486,6 +488,7 @@ enable = true; domain = "indiko.dunkirk.sh"; repository = "https://github.com/taciturnaxolotl/indiko"; + healthUrl = "https://indiko.dunkirk.sh/health"; }; atelier.services.l4 = { @@ -494,6 +497,7 @@ port = 3004; repository = "https://github.com/taciturnaxolotl/l4"; secretsFile = config.age.secrets.l4.path; + healthUrl = "https://l4.dunkirk.sh/health"; }; atelier.services.control = { @@ -501,6 +505,7 @@ domain = "control.dunkirk.sh"; repository = "https://github.com/taciturnaxolotl/control"; secretsFile = config.age.secrets.control.path; + healthUrl = "https://control.dunkirk.sh/health"; flags."map.dunkirk.sh" = { name = "Map"; @@ -523,6 +528,7 @@ enable = true; domain = "traverse.dunkirk.sh"; repository = "https://github.com/taciturnaxolotl/traverse"; + healthUrl = "https://traverse.dunkirk.sh"; }; atelier.services.herald = { @@ -550,6 +556,7 @@ domain = "canvas.dunkirk.sh"; repository = "https://github.com/taciturnaxolotl/canvas-mcp"; secretsFile = config.age.secrets.canvas-mcp.path; + healthUrl = "https://canvas.dunkirk.sh/health?detailed=true"; environment = { DKIM_PRIVATE_KEY_FILE = "${config.age.secrets.canvas-mcp-dkim.path}"; }; @@ -560,6 +567,7 @@ domain = "cedarlogic.dunkirk.sh"; repository = "https://github.com/taciturnaxolotl/CedarLogic"; secretsFile = config.age.secrets.cedarlogic.path; + healthUrl = "https://cedarlogic.dunkirk.sh/health"; }; services.caddy.virtualHosts."terebithia.dunkirk.sh" = { diff --git a/modules/home/apps/anthropic-manager/anthropic-manager.1.md b/modules/home/apps/anthropic-manager/anthropic-manager.1.md deleted file mode 100644 index 3c5e801..0000000 --- a/modules/home/apps/anthropic-manager/anthropic-manager.1.md +++ /dev/null @@ -1,170 +0,0 @@ -% ANTHROPIC-MANAGER(1) | Anthropic OAuth Profile Manager -% Kieran Klukas -% December 2024 - -# NAME - -anthropic-manager - Manage Anthropic OAuth credential profiles - -# SYNOPSIS - -**anthropic-manager** [*OPTIONS*] - -**anthropic-manager** **--init** [*PROFILE*] - -**anthropic-manager** **--swap** [*PROFILE*] - -**anthropic-manager** **--delete** [*PROFILE*] - -**anthropic-manager** **--token** - -**anthropic-manager** **--list** - -**anthropic-manager** **--current** - -# DESCRIPTION - -**anthropic-manager** is a tool for managing multiple Anthropic OAuth credential profiles. It implements PKCE-based OAuth authentication with automatic token refresh, allowing you to switch between different Anthropic accounts easily. - -Profile credentials are stored in **~/.config/crush/anthropic.\***profile\* directories with individual bearer tokens, refresh tokens, and expiration timestamps. - -# OPTIONS - -**--init**, **-i** [*PROFILE*] -: Initialize a new OAuth profile. Opens browser for authentication and stores credentials. - -**--swap**, **-s** [*PROFILE*] -: Switch to a different profile. If no profile specified, shows interactive selection. - -**--delete**, **-d** [*PROFILE*] -: Delete a profile and its credentials. If no profile specified, shows interactive selection. Prompts for confirmation before deletion. If the deleted profile is active, the symlink is removed. - -**--token**, **-t** -: Print the current bearer token to stdout. Automatically refreshes if expired. Designed for non-interactive use. - -**--list**, **-l** -: List all available profiles with their status (valid/expired/invalid). - -**--current**, **-c** -: Show the currently active profile name. - -**--help**, **-h** -: Display help information. - -# INTERACTIVE MENU - -When run without arguments in an interactive terminal, **anthropic-manager** displays a menu with the following options: - -- Switch profile -- Create new profile -- Delete profile -- List all profiles -- Get current token - -# PROFILE STORAGE - -Profiles are stored in **~/.config/crush/** with the following structure: - -``` -~/.config/crush/ -├── anthropic -> anthropic.work (symlink to active profile) -├── anthropic.work/ -│ ├── bearer_token (OAuth access token, mode 600) -│ ├── bearer_token.expires (Unix timestamp) -│ └── refresh_token (OAuth refresh token, mode 600) -└── anthropic.personal/ - └── ... -``` - -The active profile is determined by the **anthropic** symlink. - -# ENVIRONMENT - -**ANTHROPIC_CONFIG_DIR** -: Override the default configuration directory (~/.config/crush). - -# EXIT STATUS - -**0** -: Success - -**1** -: Error (no active profile, authentication failed, invalid token, etc.) - -# EXAMPLES - -Initialize a new work profile: - -``` -$ anthropic-manager --init work -``` - -Switch to the work profile: - -``` -$ anthropic-manager --swap work -``` - -Delete a profile: - -``` -$ anthropic-manager --delete work -``` - -Get the current bearer token (for scripts): - -``` -$ TOKEN=$(anthropic-manager --token) -``` - -List all profiles: - -``` -$ anthropic-manager --list -``` - -Open interactive menu: - -``` -$ anthropic-manager -``` - -# INTEGRATION - -**anthropic-manager** is designed to replace **bunx anthropic-api-key** in crush configurations: - -```nix -api_key = "Bearer $(anthropic-manager --token)"; -``` - -The **--token** flag automatically handles: -- Loading cached tokens -- Checking expiration (refreshes if <60s remaining) -- Refreshing using refresh token -- Non-interactive operation (errors to stderr, token to stdout) - -# FILES - -**~/.config/crush/anthropic** -: Symlink to active profile directory - -**~/.config/crush/anthropic.*/bearer_token** -: OAuth access token for each profile - -**~/.config/crush/anthropic.*/refresh_token** -: OAuth refresh token for each profile - -**~/.config/crush/anthropic.*/bearer_token.expires** -: Token expiration timestamp (Unix epoch) - -# SEE ALSO - -**crush**(1) - -# BUGS - -Report bugs to: - -# COPYRIGHT - -Copyright © 2024 Kieran Klukas. Licensed under MIT License. diff --git a/modules/home/apps/anthropic-manager/completions/anthropic-manager.bash b/modules/home/apps/anthropic-manager/completions/anthropic-manager.bash deleted file mode 100644 index 796137b..0000000 --- a/modules/home/apps/anthropic-manager/completions/anthropic-manager.bash +++ /dev/null @@ -1,25 +0,0 @@ -# Bash completion for anthropic-manager - -_anthropic_manager() { - local cur prev opts - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - - # Main options - opts="--init -i --swap -s --delete -d --token -t --list -l --current -c --help -h" - - # If previous word was --init, --swap, or --delete, complete with profile names - if [[ "$prev" == "--init" ]] || [[ "$prev" == "-i" ]] || [[ "$prev" == "--swap" ]] || [[ "$prev" == "-s" ]] || [[ "$prev" == "--delete" ]] || [[ "$prev" == "-d" ]]; then - local config_dir="${ANTHROPIC_CONFIG_DIR:-$HOME/.config/crush}" - local profiles=$(find "$config_dir" -maxdepth 1 -type d -name "anthropic.*" 2>/dev/null | sed 's/.*anthropic\.//' | sort) - COMPREPLY=( $(compgen -W "${profiles}" -- ${cur}) ) - return 0 - fi - - # Complete with options - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) - return 0 -} - -complete -F _anthropic_manager anthropic-manager diff --git a/modules/home/apps/anthropic-manager/completions/anthropic-manager.fish b/modules/home/apps/anthropic-manager/completions/anthropic-manager.fish deleted file mode 100644 index da1d17a..0000000 --- a/modules/home/apps/anthropic-manager/completions/anthropic-manager.fish +++ /dev/null @@ -1,18 +0,0 @@ -# Fish completion for anthropic-manager - -# Helper function to get profile list -function __anthropic_manager_profiles - set -l config_dir (test -n "$ANTHROPIC_CONFIG_DIR"; and echo $ANTHROPIC_CONFIG_DIR; or echo "$HOME/.config/crush") - if test -d "$config_dir" - find "$config_dir" -maxdepth 1 -type d -name "anthropic.*" 2>/dev/null | sed 's/.*anthropic\.//' | sort - end -end - -# Main options -complete -c anthropic-manager -s h -l help -d "Show help information" -complete -c anthropic-manager -s i -l init -d "Initialize a new profile" -xa "(__anthropic_manager_profiles)" -complete -c anthropic-manager -s s -l swap -d "Switch to a profile" -xa "(__anthropic_manager_profiles)" -complete -c anthropic-manager -s d -l delete -d "Delete a profile" -xa "(__anthropic_manager_profiles)" -complete -c anthropic-manager -s t -l token -d "Print current bearer token" -complete -c anthropic-manager -s l -l list -d "List all profiles" -complete -c anthropic-manager -s c -l current -d "Show current profile" diff --git a/modules/home/apps/anthropic-manager/completions/anthropic-manager.zsh b/modules/home/apps/anthropic-manager/completions/anthropic-manager.zsh deleted file mode 100644 index 2fdaafa..0000000 --- a/modules/home/apps/anthropic-manager/completions/anthropic-manager.zsh +++ /dev/null @@ -1,22 +0,0 @@ -#compdef anthropic-manager - -_anthropic_manager() { - local config_dir="${ANTHROPIC_CONFIG_DIR:-$HOME/.config/crush}" - local -a profiles - - # Get list of profiles - if [[ -d "$config_dir" ]]; then - profiles=(${(f)"$(find "$config_dir" -maxdepth 1 -type d -name "anthropic.*" 2>/dev/null | sed 's/.*anthropic\.//' | sort)"}) - fi - - _arguments -C \ - '(- *)'{-h,--help}'[Show help information]' \ - '(-i --init)'{-i,--init}'[Initialize a new profile]:profile name:' \ - '(-s --swap)'{-s,--swap}'[Switch to a profile]:profile:($profiles)' \ - '(-d --delete)'{-d,--delete}'[Delete a profile]:profile:($profiles)' \ - '(-t --token)'{-t,--token}'[Print current bearer token]' \ - '(-l --list)'{-l,--list}'[List all profiles]' \ - '(-c --current)'{-c,--current}'[Show current profile]' -} - -_anthropic_manager "$@" diff --git a/modules/home/apps/anthropic-manager/default.nix b/modules/home/apps/anthropic-manager/default.nix deleted file mode 100644 index 5f2aee5..0000000 --- a/modules/home/apps/anthropic-manager/default.nix +++ /dev/null @@ -1,561 +0,0 @@ -{ - lib, - pkgs, - config, - ... -}: -let - cfg = config.atelier.apps.anthropic-manager; - - anthropicManagerScript = pkgs.writeShellScript "anthropic-manager" '' - # Manage Anthropic OAuth credential profiles - # Implements the same functionality as anthropic-api-key but with profile management - - set -uo pipefail - - CONFIG_DIR="''${ANTHROPIC_CONFIG_DIR:-$HOME/.config/crush}" - CLIENT_ID="9d1c250a-e61b-44d9-88ed-5944d1962f5e" - - # Utilities - base64url() { - ${pkgs.coreutils}/bin/base64 -w0 | ${pkgs.gnused}/bin/sed 's/=//g; s/+/-/g; s/\//_/g' - } - - sha256() { - echo -n "$1" | ${pkgs.openssl}/bin/openssl dgst -binary -sha256 - } - - pkce_pair() { - verifier=$(${pkgs.openssl}/bin/openssl rand 32 | base64url) - challenge=$(printf '%s' "$verifier" | ${pkgs.openssl}/bin/openssl dgst -binary -sha256 | base64url) - echo "$verifier $challenge" - } - - authorize_url() { - local challenge="$1" - local state="$2" - echo "https://claude.ai/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=https://console.anthropic.com/oauth/code/callback&scope=org:create_api_key+user:profile+user:inference+user:sessions:claude_code&code_challenge=$challenge&code_challenge_method=S256&state=$state" - } - - clean_pasted_code() { - local input="$1" - input="''${input#code:}" - input="''${input#code=}" - input="''${input#\"}" - input="''${input%\"}" - input="''${input#\'}" - input="''${input%\'}" - input="''${input#\`}" - input="''${input%\`}" - echo "$input" | ${pkgs.gnused}/bin/sed -E 's/[^A-Za-z0-9._~#-]//g' - } - - exchange_code() { - local code="$1" - local verifier="$2" - local cleaned - cleaned=$(clean_pasted_code "$code") - local pure="''${cleaned%%#*}" - local state="''${cleaned#*#}" - [[ "$state" == "$pure" ]] && state="" - - ${pkgs.curl}/bin/curl -s -X POST \ - -H "Content-Type: application/json" \ - -H "User-Agent: anthropic-manager/1.0" \ - -d "$(${pkgs.jq}/bin/jq -n \ - --arg code "$pure" \ - --arg state "$state" \ - --arg verifier "$verifier" \ - '{ - code: $code, - state: $state, - grant_type: "authorization_code", - client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", - redirect_uri: "https://console.anthropic.com/oauth/code/callback", - code_verifier: $verifier - }')" \ - "https://console.anthropic.com/v1/oauth/token" - } - - exchange_refresh() { - local refresh_token="$1" - ${pkgs.curl}/bin/curl -s -X POST \ - -H "Content-Type: application/json" \ - -H "User-Agent: anthropic-manager/1.0" \ - -d "$(${pkgs.jq}/bin/jq -n \ - --arg refresh "$refresh_token" \ - '{ - grant_type: "refresh_token", - refresh_token: $refresh, - client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - }')" \ - "https://console.anthropic.com/v1/oauth/token" - } - - save_tokens() { - local profile_dir="$1" - local access_token="$2" - local refresh_token="$3" - local expires_at="$4" - - mkdir -p "$profile_dir" - echo -n "$access_token" > "$profile_dir/bearer_token" - echo -n "$refresh_token" > "$profile_dir/refresh_token" - echo -n "$expires_at" > "$profile_dir/bearer_token.expires" - chmod 600 "$profile_dir/bearer_token" "$profile_dir/refresh_token" "$profile_dir/bearer_token.expires" - } - - load_tokens() { - local profile_dir="$1" - [[ -f "$profile_dir/bearer_token" ]] || return 1 - [[ -f "$profile_dir/refresh_token" ]] || return 1 - [[ -f "$profile_dir/bearer_token.expires" ]] || return 1 - - cat "$profile_dir/bearer_token" - cat "$profile_dir/refresh_token" - cat "$profile_dir/bearer_token.expires" - return 0 - } - - get_token() { - local profile_dir="$1" - local print_token="''${2:-true}" - - if ! load_tokens "$profile_dir" >/dev/null 2>&1; then - return 1 - fi - - local bearer refresh expires - read -r bearer < "$profile_dir/bearer_token" - read -r refresh < "$profile_dir/refresh_token" - read -r expires < "$profile_dir/bearer_token.expires" - - local now - now=$(date +%s) - - # If token valid for more than 60s, return it - if [[ $now -lt $((expires - 60)) ]]; then - [[ "$print_token" == "true" ]] && echo "$bearer" - return 0 - fi - - # Try to refresh - local response - response=$(exchange_refresh "$refresh") - - if ! echo "$response" | ${pkgs.jq}/bin/jq -e '.access_token' >/dev/null 2>&1; then - return 1 - fi - - local new_access new_refresh new_expires_in - new_access=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.access_token') - new_refresh=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.refresh_token // empty') - new_expires_in=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.expires_in') - - [[ -z "$new_refresh" ]] && new_refresh="$refresh" - local new_expires=$((now + new_expires_in)) - - save_tokens "$profile_dir" "$new_access" "$new_refresh" "$new_expires" - [[ "$print_token" == "true" ]] && echo "$new_access" - return 0 - } - - oauth_flow() { - local profile_dir="$1" - - ${pkgs.gum}/bin/gum style --foreground 212 "Starting OAuth flow..." - echo - - read -r verifier challenge < <(pkce_pair) - local state - state=$(${pkgs.openssl}/bin/openssl rand -base64 32 | ${pkgs.gnused}/bin/sed 's/[^A-Za-z0-9]//g') - local auth_url - auth_url=$(authorize_url "$challenge" "$state") - - ${pkgs.gum}/bin/gum style --foreground 35 "Opening browser for authorization..." - ${pkgs.gum}/bin/gum style --foreground 117 "$auth_url" - echo - - if command -v ${pkgs.xdg-utils}/bin/xdg-open &>/dev/null; then - ${pkgs.xdg-utils}/bin/xdg-open "$auth_url" 2>/dev/null & - elif command -v open &>/dev/null; then - open "$auth_url" 2>/dev/null & - fi - - local code - code=$(${pkgs.gum}/bin/gum input --placeholder "Paste the authorization code from Anthropic" --prompt "Code: ") - - if [[ -z "$code" ]]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No code provided" - return 1 - fi - - ${pkgs.gum}/bin/gum style --foreground 212 "Exchanging code for tokens..." - - local response - response=$(exchange_code "$code" "$verifier") - - if ! echo "$response" | ${pkgs.jq}/bin/jq -e '.access_token' >/dev/null 2>&1; then - ${pkgs.gum}/bin/gum style --foreground 196 "Failed to exchange code" - echo "$response" | ${pkgs.jq}/bin/jq '.' 2>&1 || echo "$response" - return 1 - fi - - local access_token refresh_token expires_in - access_token=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.access_token') - refresh_token=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.refresh_token') - expires_in=$(echo "$response" | ${pkgs.jq}/bin/jq -r '.expires_in') - - local expires_at - expires_at=$(($(date +%s) + expires_in)) - - save_tokens "$profile_dir" "$access_token" "$refresh_token" "$expires_at" - ${pkgs.gum}/bin/gum style --foreground 35 "✓ Authenticated successfully" - return 0 - } - - list_profiles() { - ${pkgs.gum}/bin/gum style --bold --foreground 212 "Available Anthropic profiles:" - echo - - local current_profile="" - if [[ -L "$CONFIG_DIR/anthropic" ]]; then - current_profile=$(basename "$(readlink "$CONFIG_DIR/anthropic")" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//') - fi - - local found_any=false - for profile_dir in "$CONFIG_DIR"/anthropic.*; do - if [[ -d "$profile_dir" ]]; then - found_any=true - local profile_name - profile_name=$(basename "$profile_dir" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//') - - local status="" - if get_token "$profile_dir" false 2>/dev/null; then - local expires - read -r expires < "$profile_dir/bearer_token.expires" - local now - now=$(date +%s) - if [[ $now -lt $expires ]]; then - status=" (valid)" - else - status=" (expired)" - fi - else - status=" (invalid)" - fi - - if [[ "$profile_name" == "$current_profile" ]]; then - ${pkgs.gum}/bin/gum style --foreground 35 " ✓ $profile_name$status (active)" - else - echo " $profile_name$status" - fi - fi - done - - if [[ "$found_any" == "false" ]]; then - ${pkgs.gum}/bin/gum style --foreground 214 "No profiles found. Use 'anthropic-manager --init ' to create one." - fi - } - - show_current() { - if [[ -L "$CONFIG_DIR/anthropic" ]]; then - local current - current=$(basename "$(readlink "$CONFIG_DIR/anthropic")" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//') - ${pkgs.gum}/bin/gum style --foreground 35 "Current profile: $current" - else - ${pkgs.gum}/bin/gum style --foreground 214 "No active profile" - fi - } - - init_profile() { - local profile="$1" - - if [[ -z "$profile" ]]; then - profile=$(${pkgs.gum}/bin/gum input --placeholder "Profile name (e.g., work, personal)" --prompt "Profile name: ") - if [[ -z "$profile" ]]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No profile name provided" - exit 1 - fi - fi - - local profile_dir="$CONFIG_DIR/anthropic.$profile" - - if [[ -d "$profile_dir" ]]; then - ${pkgs.gum}/bin/gum style --foreground 214 "Profile '$profile' already exists" - if ${pkgs.gum}/bin/gum confirm "Re-authenticate?"; then - rm -rf "$profile_dir" - else - exit 1 - fi - fi - - if ! oauth_flow "$profile_dir"; then - rm -rf "$profile_dir" - exit 1 - fi - - # Ask to set as active - if [[ ! -L "$CONFIG_DIR/anthropic" ]] || ${pkgs.gum}/bin/gum confirm "Set '$profile' as active profile?"; then - [[ -L "$CONFIG_DIR/anthropic" ]] && rm "$CONFIG_DIR/anthropic" - ln -sf "anthropic.$profile" "$CONFIG_DIR/anthropic" - ${pkgs.gum}/bin/gum style --foreground 35 "✓ Set as active profile" - fi - } - - delete_profile() { - local target="$1" - - if [[ -z "$target" ]]; then - # Interactive selection - local profiles=() - for profile_dir in "$CONFIG_DIR"/anthropic.*; do - if [[ -d "$profile_dir" ]]; then - profiles+=("$(basename "$profile_dir" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//')") - fi - done - - if [[ ''${#profiles[@]} -eq 0 ]]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No profiles found" - exit 1 - fi - - target=$(printf '%s\n' "''${profiles[@]}" | ${pkgs.gum}/bin/gum choose --header "Select profile to delete:") - [[ -z "$target" ]] && exit 0 - fi - - local target_dir="$CONFIG_DIR/anthropic.$target" - if [[ ! -d "$target_dir" ]]; then - ${pkgs.gum}/bin/gum style --foreground 196 "Profile '$target' does not exist" - exit 1 - fi - - if ! ${pkgs.gum}/bin/gum confirm "Delete profile '$target'?"; then - exit 0 - fi - - # Check if this is the active profile - if [[ -L "$CONFIG_DIR/anthropic" ]]; then - local current - current=$(basename "$(readlink "$CONFIG_DIR/anthropic")" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//') - if [[ "$current" == "$target" ]]; then - rm "$CONFIG_DIR/anthropic" - ${pkgs.gum}/bin/gum style --foreground 214 "Unlinked active profile" - fi - fi - - rm -rf "$target_dir" - ${pkgs.gum}/bin/gum style --foreground 35 "✓ Deleted profile '$target'" - } - - swap_profile() { - local target="$1" - - if [[ -n "$target" ]]; then - local target_dir="$CONFIG_DIR/anthropic.$target" - if [[ ! -d "$target_dir" ]]; then - ${pkgs.gum}/bin/gum style --foreground 196 "Profile '$target' does not exist" - echo - list_profiles - exit 1 - fi - - [[ -L "$CONFIG_DIR/anthropic" ]] && rm "$CONFIG_DIR/anthropic" - ln -sf "anthropic.$target" "$CONFIG_DIR/anthropic" - ${pkgs.gum}/bin/gum style --foreground 35 "✓ Switched to profile '$target'" - exit 0 - fi - - # Interactive selection - local profiles=() - for profile_dir in "$CONFIG_DIR"/anthropic.*; do - if [[ -d "$profile_dir" ]]; then - profiles+=("$(basename "$profile_dir" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//')") - fi - done - - if [[ ''${#profiles[@]} -eq 0 ]]; then - ${pkgs.gum}/bin/gum style --foreground 196 "No profiles found" - ${pkgs.gum}/bin/gum style --foreground 214 "Use 'anthropic-manager --init ' to create one" - exit 1 - fi - - local selected - selected=$(printf '%s\n' "''${profiles[@]}" | ${pkgs.gum}/bin/gum choose --header "Select profile:") - - if [[ -n "$selected" ]]; then - [[ -L "$CONFIG_DIR/anthropic" ]] && rm "$CONFIG_DIR/anthropic" - ln -sf "anthropic.$selected" "$CONFIG_DIR/anthropic" - ${pkgs.gum}/bin/gum style --foreground 35 "✓ Switched to profile '$selected'" - fi - } - - print_token() { - if [[ ! -L "$CONFIG_DIR/anthropic" ]]; then - echo "Error: No active profile" >&2 - exit 1 - fi - - local profile_dir - profile_dir=$(readlink -f "$CONFIG_DIR/anthropic") - - if ! get_token "$profile_dir" true 2>/dev/null; then - echo "Error: Token invalid or expired" >&2 - exit 1 - fi - } - - interactive_menu() { - echo - ${pkgs.gum}/bin/gum style --bold --foreground 212 "Anthropic Profile Manager" - echo - - local current_profile="" - if [[ -L "$CONFIG_DIR/anthropic" ]]; then - current_profile=$(basename "$(readlink "$CONFIG_DIR/anthropic")" | ${pkgs.gnused}/bin/sed 's/^anthropic\.//') - ${pkgs.gum}/bin/gum style --foreground 117 "Active: $current_profile" - else - ${pkgs.gum}/bin/gum style --foreground 214 "No active profile" - fi - - echo - - local choice - choice=$(${pkgs.gum}/bin/gum choose \ - "Switch profile" \ - "Create new profile" \ - "Delete profile" \ - "List all profiles" \ - "Get current token") - - case "$choice" in - "Switch profile") - swap_profile "" - ;; - "Create new profile") - init_profile "" - ;; - "Delete profile") - echo - delete_profile "" - ;; - "List all profiles") - echo - list_profiles - ;; - "Get current token") - echo - print_token - ;; - esac - } - - # Main - mkdir -p "$CONFIG_DIR" - - case "''${1:-}" in - --init|-i) - init_profile "''${2:-}" - ;; - --list|-l) - list_profiles - ;; - --current|-c) - show_current - ;; - --token|-t|token) - print_token - ;; - --swap|-s|swap) - swap_profile "''${2:-}" - ;; - --delete|-d|delete) - delete_profile "''${2:-}" - ;; - --help|-h|help) - ${pkgs.gum}/bin/gum style --bold --foreground 212 "anthropic-manager - Manage Anthropic OAuth profiles" - echo - echo "Usage:" - echo " anthropic-manager Interactive menu" - echo " anthropic-manager --init [profile] Initialize/create a new profile" - echo " anthropic-manager --swap [profile] Switch to a profile (interactive if no profile given)" - echo " anthropic-manager --delete [profile] Delete a profile (interactive if no profile given)" - echo " anthropic-manager --token Print current bearer token (refresh if needed)" - echo " anthropic-manager --list List all profiles with status" - echo " anthropic-manager --current Show current active profile" - echo " anthropic-manager --help Show this help" - echo - echo "Examples:" - echo " anthropic-manager Open interactive menu" - echo " anthropic-manager --init work Create 'work' profile" - echo " anthropic-manager --swap work Switch to 'work' profile" - echo " anthropic-manager --delete work Delete 'work' profile" - echo " anthropic-manager --token Get current bearer token" - ;; - "") - # No args - check if interactive - if [[ ! -t 0 ]] || [[ ! -t 1 ]]; then - echo "Error: anthropic-manager requires an interactive terminal when called without arguments" >&2 - exit 1 - fi - interactive_menu - ;; - *) - ${pkgs.gum}/bin/gum style --foreground 196 "Unknown option: $1" - echo "Use --help for usage information" - exit 1 - ;; - esac - ''; - - anthropicManager = pkgs.stdenv.mkDerivation { - pname = "anthropic-manager"; - version = "1.0"; - - dontUnpack = true; - - nativeBuildInputs = with pkgs; [ pandoc installShellFiles ]; - - manPageSrc = ./anthropic-manager.1.md; - bashCompletionSrc = ./completions/anthropic-manager.bash; - zshCompletionSrc = ./completions/anthropic-manager.zsh; - fishCompletionSrc = ./completions/anthropic-manager.fish; - - buildPhase = '' - # Convert markdown man page to man format - ${pkgs.pandoc}/bin/pandoc -s -t man $manPageSrc -o anthropic-manager.1 - ''; - - installPhase = '' - mkdir -p $out/bin - - # Install binary - cp ${anthropicManagerScript} $out/bin/anthropic-manager - chmod +x $out/bin/anthropic-manager - - # Install man page - installManPage anthropic-manager.1 - - # Install completions - installShellCompletion --bash --name anthropic-manager $bashCompletionSrc - installShellCompletion --zsh --name _anthropic-manager $zshCompletionSrc - installShellCompletion --fish --name anthropic-manager.fish $fishCompletionSrc - ''; - - meta = with lib; { - description = "Anthropic OAuth profile manager"; - homepage = "https://github.com/taciturnaxolotl/dots"; - license = licenses.mit; - maintainers = [ ]; - }; - }; -in -{ - options.atelier.apps.anthropic-manager.enable = lib.mkEnableOption "Enable anthropic-manager"; - - config = lib.mkIf cfg.enable { - home.packages = [ - anthropicManager - ]; - }; -} diff --git a/modules/lib/mkService.nix b/modules/lib/mkService.nix index c38aa4b..239d718 100644 --- a/modules/lib/mkService.nix +++ b/modules/lib/mkService.nix @@ -89,6 +89,27 @@ in { description = "Git repository URL — cloned once on first start for scaffolding"; }; + healthUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Health check URL for monitoring"; + }; + + # Internal metadata set by mkService factory — used by services-manifest + _description = lib.mkOption { + type = lib.types.str; + default = description; + internal = true; + readOnly = true; + }; + + _runtime = lib.mkOption { + type = lib.types.str; + default = runtime; + internal = true; + readOnly = true; + }; + # Data declarations for automatic backup data = { sqlite = lib.mkOption { diff --git a/modules/nixos/services/tranquil-pds.nix b/modules/nixos/services/tranquil-pds.nix deleted file mode 100644 index e2d6a84..0000000 --- a/modules/nixos/services/tranquil-pds.nix +++ /dev/null @@ -1,322 +0,0 @@ -# Tranquil PDS - AT Protocol Personal Data Server -# -# A feature-rich PDS with passkeys, 2FA, did:web support, and more. -# Requires PostgreSQL, Redis, and S3-compatible storage. - -{ - config, - lib, - pkgs, - inputs, - ... -}: - -let - cfg = config.atelier.services.tranquil-pds; -in -{ - options.atelier.services.tranquil-pds = { - enable = lib.mkEnableOption "Tranquil PDS"; - - package = lib.mkOption { - type = lib.types.package; - default = inputs.tranquil-pds.packages.${pkgs.stdenv.hostPlatform.system}.default; - description = "The tranquil-pds package to use"; - }; - - domain = lib.mkOption { - type = lib.types.str; - description = "Primary domain for the PDS (e.g., serif.blue)"; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 3100; - description = "Port for the PDS to listen on"; - }; - - dataDir = lib.mkOption { - type = lib.types.path; - default = "/var/lib/tranquil-pds"; - description = "Directory to store PDS data"; - }; - - secretsFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to agenix secrets file containing JWT_SECRET, DPOP_SECRET, MASTER_KEY, and S3 credentials"; - }; - - database = { - name = lib.mkOption { - type = lib.types.str; - default = "tranquil-pds"; - description = "PostgreSQL database name"; - }; - - user = lib.mkOption { - type = lib.types.str; - default = "tranquil-pds"; - description = "PostgreSQL user"; - }; - }; - - s3 = { - endpoint = lib.mkOption { - type = lib.types.str; - default = "http://localhost:9000"; - description = "S3-compatible endpoint URL"; - }; - - bucket = lib.mkOption { - type = lib.types.str; - default = "pds-blobs"; - description = "S3 bucket name for blob storage"; - }; - - region = lib.mkOption { - type = lib.types.str; - default = "us-east-1"; - description = "S3 region"; - }; - }; - - minio = { - enable = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Enable local MinIO for S3-compatible storage. Disable if using Backblaze B2 or AWS S3."; - }; - }; - - redis = { - enable = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Enable Redis for caching and rate limiting"; - }; - }; - - crawlers = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ "https://bsky.network" ]; - description = "Relay URLs to notify via requestCrawl"; - }; - - acceptingRepoImports = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether to accept repository imports (account migration)"; - }; - - availableUserDomains = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - description = "Available user domains for handles (e.g., [\"serif.blue\"])"; - }; - - requireInviteCode = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Require invite codes for account creation"; - }; - - mail = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable email notifications"; - }; - - fromAddress = lib.mkOption { - type = lib.types.str; - default = "noreply@${cfg.domain}"; - description = "Email sender address"; - }; - - fromName = lib.mkOption { - type = lib.types.str; - default = "Serif PDS"; - description = "Email sender name"; - }; - - smtp = { - host = lib.mkOption { - type = lib.types.str; - default = "smtp.mailchannels.net"; - description = "SMTP server hostname"; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 587; - description = "SMTP server port"; - }; - - username = lib.mkOption { - type = lib.types.str; - description = "SMTP username (set in secrets file with SMTP_USERNAME)"; - }; - - tls = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Use STARTTLS"; - }; - }; - }; - }; - - config = lib.mkIf cfg.enable { - users.users.tranquil-pds = { - isSystemUser = true; - group = "tranquil-pds"; - home = cfg.dataDir; - createHome = true; - }; - users.groups.tranquil-pds = { }; - - services.postgresql = { - enable = true; - ensureDatabases = [ cfg.database.name ]; - ensureUsers = [ - { - name = cfg.database.user; - ensureDBOwnership = true; - } - ]; - }; - - services.redis.servers.tranquil-pds = lib.mkIf cfg.redis.enable { - enable = true; - port = 6379; - }; - - services.minio = lib.mkIf cfg.minio.enable { - enable = true; - dataDir = [ "${cfg.dataDir}/minio" ]; - rootCredentialsFile = cfg.secretsFile; - }; - - # Configure msmtp for email sending - programs.msmtp = lib.mkIf cfg.mail.enable { - enable = true; - accounts.default = { - auth = true; - tls = cfg.mail.smtp.tls; - tls_starttls = cfg.mail.smtp.tls; - host = cfg.mail.smtp.host; - port = cfg.mail.smtp.port; - from = cfg.mail.fromAddress; - user = cfg.mail.smtp.username; - passwordeval = "${pkgs.coreutils}/bin/cat ${cfg.secretsFile} | ${pkgs.gnugrep}/bin/grep SMTP_PASSWORD | ${pkgs.coreutils}/bin/cut -d= -f2"; - }; - }; - - systemd.services.tranquil-pds = { - description = "Tranquil PDS - AT Protocol Personal Data Server"; - wantedBy = [ "multi-user.target" ]; - after = - [ - "network.target" - "postgresql.service" - ] - ++ lib.optional cfg.minio.enable "minio.service" - ++ lib.optional cfg.redis.enable "redis-tranquil-pds.service"; - requires = - [ "postgresql.service" ] - ++ lib.optional cfg.minio.enable "minio.service" - ++ lib.optional cfg.redis.enable "redis-tranquil-pds.service"; - - environment = - { - SERVER_HOST = "127.0.0.1"; - SERVER_PORT = toString cfg.port; - PDS_HOSTNAME = cfg.domain; - DATABASE_URL = "postgres:///${cfg.database.name}?host=/run/postgresql"; - S3_ENDPOINT = cfg.s3.endpoint; - S3_BUCKET = cfg.s3.bucket; - AWS_REGION = cfg.s3.region; - CRAWLERS = lib.concatStringsSep "," cfg.crawlers; - ACCEPTING_REPO_IMPORTS = if cfg.acceptingRepoImports then "true" else "false"; - AVAILABLE_USER_DOMAINS = lib.concatStringsSep "," cfg.availableUserDomains; - INVITE_CODE_REQUIRED = if cfg.requireInviteCode then "true" else "false"; - } - // lib.optionalAttrs cfg.redis.enable { - REDIS_URL = "redis://localhost:6379"; - } - // lib.optionalAttrs cfg.mail.enable { - MAIL_FROM_ADDRESS = cfg.mail.fromAddress; - MAIL_FROM_NAME = cfg.mail.fromName; - SENDMAIL_PATH = "${pkgs.msmtp}/bin/msmtp"; - }; - - serviceConfig = { - Type = "simple"; - User = "tranquil-pds"; - Group = "tranquil-pds"; - WorkingDirectory = cfg.dataDir; - EnvironmentFile = lib.mkIf (cfg.secretsFile != null) cfg.secretsFile; - ExecStart = "${cfg.package}/bin/tranquil-pds"; - Restart = "always"; - RestartSec = "10s"; - - NoNewPrivileges = true; - ProtectSystem = "strict"; - ProtectHome = true; - ReadWritePaths = [ cfg.dataDir ]; - PrivateTmp = true; - }; - }; - - systemd.tmpfiles.rules = [ - "d ${cfg.dataDir} 0755 tranquil-pds tranquil-pds -" - ] ++ lib.optional cfg.minio.enable "d ${cfg.dataDir}/minio 0755 minio minio -"; - - services.caddy.virtualHosts."${cfg.domain}" = { - extraConfig = '' - tls { - dns cloudflare {env.CLOUDFLARE_API_TOKEN} - } - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - } - - reverse_proxy localhost:${toString cfg.port} { - header_up X-Forwarded-Proto {scheme} - header_up X-Forwarded-For {remote} - } - ''; - }; - - services.caddy.virtualHosts."*.${cfg.domain}" = { - extraConfig = '' - tls { - dns cloudflare {env.CLOUDFLARE_API_TOKEN} - } - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - } - reverse_proxy localhost:${toString cfg.port} { - header_up X-Forwarded-Proto {scheme} - header_up X-Forwarded-For {remote} - } - ''; - }; - - networking.firewall.allowedTCPPorts = [ - 443 - 80 - ]; - - atelier.backup.services.tranquil-pds = { - paths = [ cfg.dataDir ]; - exclude = [ "*.log" ] ++ lib.optional cfg.minio.enable "minio/*"; - preBackup = '' - systemctl stop tranquil-pds - ${pkgs.sudo}/bin/sudo -u postgres ${pkgs.postgresql}/bin/pg_dump ${cfg.database.name} > /tmp/tranquil-pds-pg-dump.sql - ''; - postBackup = "systemctl start tranquil-pds"; - }; - }; -} diff --git a/packages/docs.nix b/packages/docs.nix new file mode 100644 index 0000000..f4da935 --- /dev/null +++ b/packages/docs.nix @@ -0,0 +1,63 @@ +{ + stdenvNoCC, + lib, + mdbook, + nixdoc, + fetchurl, + simple-http-server, + writeShellApplication, + jq, + # Injected from flake.nix + servicesManifest, + self, +}: + +stdenvNoCC.mkDerivation (finalAttrs: { + name = "dunkirk-docs"; + src = self + /docs; + + nativeBuildInputs = [ mdbook nixdoc jq ]; + + buildPhase = '' + # Set up catppuccin theme + mkdir -p theme + cp ${finalAttrs.passthru.catppuccin-mdbook} theme/catppuccin.css + + # Generate lib docs via nixdoc + mkdir -p src/lib + nixdoc -c services -d "Service utility functions" \ + -p "" \ + -f ${self + /lib/services.nix} > src/lib/services.md + + # Build the lib index for SUMMARY.md injection + echo '- [services](lib/services.md)' > src/lib/index.md + + # Inject libdoc entries into SUMMARY.md + substituteInPlace src/SUMMARY.md \ + --replace-fail "libdoc" "$(cat src/lib/index.md)" + + # Build the book + mdbook build + ''; + + installPhase = '' + cp -r ./dist $out + + # Place services.json alongside the book + echo '${builtins.toJSON servicesManifest}' | jq . > $out/services.json + ''; + + passthru.catppuccin-mdbook = fetchurl { + url = "https://github.com/catppuccin/mdBook/releases/download/v4.0.0/catppuccin.css"; + hash = "sha256-4IvmqQrfOSKcx6PAhGD5G7I44UN2596HECCFzzr/p/8="; + }; + + passthru.serve = writeShellApplication { + name = "docs-serve"; + runtimeInputs = [ simple-http-server ]; + text = '' + echo "Serving docs at http://localhost:8000" + simple-http-server -i -p 8000 -- ${finalAttrs.finalPackage} + ''; + }; +}) diff --git a/packages/tranquil-pds.nix b/packages/tranquil-pds.nix deleted file mode 100644 index d70edcb..0000000 --- a/packages/tranquil-pds.nix +++ /dev/null @@ -1,71 +0,0 @@ -{ lib -, rustPlatform -, pkg-config -, openssl -, deno -, nodejs -, buildNpmPackage -}: -let - toml = (lib.importTOML ../tranquil-pds-src/Cargo.toml).package; - - frontend = buildNpmPackage { - pname = "tranquil-pds-frontend"; - inherit (toml) version; - - src = ../tranquil-pds-src/frontend; - - npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # Will need to update - - buildPhase = '' - runHook preBuild - npm run build - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - cp -r dist $out - runHook postInstall - ''; - }; -in -rustPlatform.buildRustPackage { - pname = "tranquil-pds"; - inherit (toml) version; - - src = lib.fileset.toSource { - root = ../tranquil-pds-src; - fileset = lib.fileset.intersection - (lib.fileset.fromSource (lib.sources.cleanSource ../tranquil-pds-src)) - (lib.fileset.unions [ - ../tranquil-pds-src/Cargo.toml - ../tranquil-pds-src/Cargo.lock - ../tranquil-pds-src/src - ../tranquil-pds-src/.sqlx - ../tranquil-pds-src/migrations - ]); - }; - - nativeBuildInputs = [ - pkg-config - ]; - - buildInputs = [ - openssl - ]; - - cargoLock.lockFile = ../tranquil-pds-src/Cargo.lock; - - doCheck = false; - - # Install frontend alongside binary - postInstall = '' - mkdir -p $out/share/tranquil-pds - cp -r ${frontend} $out/share/tranquil-pds/frontend - ''; - - meta = { - license = lib.licenses.agpl3Plus; - }; -} diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 5173362..27d10ef 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -66,10 +66,6 @@ in "restic/password.age".publicKeys = [ kierank ]; - "tranquil-pds.age".publicKeys = [ - kierank - ]; - "pbnj.age".publicKeys = [ kierank ]; diff --git a/secrets/tranquil-pds.age b/secrets/tranquil-pds.age deleted file mode 100644 index 0b399566d5b8a0e5283660217eb6055b44e67ab5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1045 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!*`Do#{zDNJ@Z2;|BubTN%g3k=IJ zDvO9LHa2thF9-=LcJz13$T4v?PjYe#4^B=iGK+A`&MPYkbWab?b#hGei3rsWbvASi zcl76Ss>~=3NX;`&PBSabk1)(lHFb2)G)xN3adPqU($296_i#=N^KmXtb@MSx%Xjt< z4~z6kE(u7DO7TuHFDy0S3X4efE6aDW@G7%N^3!+A$TzhN&rI`hDGms+bWQOyF*A-R z@bFD3&rT}$O)twgFLX-F)KB!v4|5EPOmQlV;_@{&vZ$ymFLbjk^E4_h@%7O*&i0Co z^eW6QHP8>OD5?kvH3@LZO-ywva5o7u46!V5%}RDDF-|Y?56Vd`4&chLF!9OrbPEl2 z4A07Qa?&meO|>lXF*eKy^m8`%Er_Zxv@Edj4@^pS@=rGQ$}rCKDRN0nDs~J{cX3WJ zjnL-usm#pKPtz_9s|wL}%XZDQ^e*%W_N(wHaLw>@EB5iuFfvFoD#|hlDt2_UG>UY~ z@=MS4ck~KKHBL1RGB3^J%B#vMcFivG39>K_Hcc|m_wdV0G>Nc8iJO!YF(;mRql@Jh-p4Kr}D2=z^h2(!#IbSZZZ z@%J{!F;6lxvna_eG0gNXOi%X8@UJY1^l=R>i11HMNpy4cOmzzMPv_#&)zwv~D9@;} zNO#nBt@6)`a?Vc5E4D222~Ew-i71Q=O!6)+smw`sH;xSP(DvnO511uZB=r1P&jFV< z+f5(6jf18#yT>kN{(a$dUn{50^rKTkFTL%kX2@Eg{pGlBe?a)-T>FG&;wm+be+9+e zjlvJcKVper`)j(5^RM^syuaHyTTIL1pT~RlV2E}K!-u0MGW(3v#r<50-)p2!y3)Az z>GxkwKRzp@?Y^_-)&=joyDhG^yqqMtdwKYp=JgrH5%XErKUvoJFq>(KMzPcdkGx-p z{Nu8=>b$YJzfs-b)0Rkw?S->vL>lQTEbW^lRj7J)T9x7_rc2)vCo7$NlxD(_rV!J! zGovD-!g-^JajYThanp}C&0pX46p?x#)xC(x$^FoM#*E@EUlmO|>Q34<9IDdO{9Yit z-_ub)rL%8J{M~$^UFW)_{y+XIw6|@Msl~e&d>7};YxK!YVxOenl(*sXt7o^jGnCC{ z*J^3_arVR!ce%}i)#aP6SvjQ@x?T34<)qG2Ij5+1?Si@;ft5mBX|W47H+@O{nB~0O zFDakrwPTi+8|TY4>L>MlE}P3(d^q`=|L&Bmncg8jsUDgu=PaC(6Pf>A;ho68h!trQ imOT3LFYLeZjic#XSo{6idyV4PojK&ZdsnfNt2+Qx;ht;&