Migration: consolidate Echo6 docs to cortex with full infrastructure cleanup sync

- Documents recent infrastructure cleanup (8 CTs destroyed, 35 DNS records removed, Headscale cleanup)
- Adds 24 new runbooks covering Authentik, PeerTube, Meshtastic, RECON, Proxmox, Mailcow, Internet Archive, GPU routing
- Adds project documentation for headscale, vaultwarden, peertube, matrix, mmud, advbbs, arr stack
- Updates services.md, environment.md, caddy.md, authentik.md to match live infrastructure
- Removes 4 deprecated runbook duplicates (canonical versions live in projects/)
- Adds .gitignore for binary archives and editor temp files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-04-13 06:02:16 +00:00
commit e9231ac24a
93 changed files with 51223 additions and 254 deletions

View file

@ -0,0 +1,173 @@
# Add PeerTube Channel
## Overview
Add a YouTube channel to the PeerTube bulk import pipeline. Creates the PeerTube channel, adds to channel-map.json, and the downloader will begin syncing videos automatically.
## Prerequisites
- SSH access from CT 130 (RECON) → CT 110 (PeerTube): working
- Sudoers: `/etc/sudoers.d/recon-mgmt` on CT 110 (allows zvx to run yt-dlp, psql, tee as peertube)
- YouTube cookies at `/opt/bulk-import/config/cookies.txt` on CT 110 (not stale)
## Method 1: Web UI (Preferred)
1. Open **RECON Dashboard** → Upload tab: `http://192.168.1.130:8420/upload`
2. Scroll to **PeerTube Channels** section
3. Enter YouTube URL, category, priority
4. Click **Add Channel**
5. Wait for "Added: ChannelName" confirmation
**Note:** If the channel has members-only content, the API will automatically retry with `--ignore-errors` on the `/videos` tab.
## Method 2: CLI (For Troubleshooting)
Use when the web UI fails or you need manual control.
### Variables
```bash
YT_URL="https://www.youtube.com/@ChannelName"
CATEGORY="CategoryName"
PRIORITY="M" # H, M, or L
```
### Step 1: Resolve Channel Info
```bash
# From CT 130 or cortex:
ssh zvx@192.168.1.170 "sudo -u peertube /usr/local/bin/yt-dlp \
--cookies /opt/bulk-import/config/cookies.txt \
--print channel --print channel_url --print channel_id \
--playlist-items 1 --skip-download '$YT_URL'"
```
**If members-only error:** Append `/videos` to URL and add `--ignore-errors --playlist-items 1:5`:
```bash
ssh zvx@192.168.1.170 "sudo -u peertube /usr/local/bin/yt-dlp \
--cookies /opt/bulk-import/config/cookies.txt \
--print channel --print channel_url --print channel_id \
--ignore-errors --playlist-items 1:5 --skip-download '${YT_URL}/videos' 2>/dev/null" | head -3
```
Record the output:
```
CHANNEL_NAME="Civilian Rifleman"
CHANNEL_URL="https://www.youtube.com/channel/UC..."
CHANNEL_ID="UC..."
```
### Step 2: Slugify Actor Name
```bash
ACTOR_NAME=$(echo "$CHANNEL_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' | cut -c1-50)
echo "$ACTOR_NAME"
```
### Step 3: Check for Duplicates
```bash
ssh zvx@192.168.1.170 "cat /opt/bulk-import/config/channel-map.json" \
| python3 -c "import sys,json; d=json.load(sys.stdin); \
matches=[c for c in d if c.get('actor_name')=='$ACTOR_NAME' or c.get('youtube_channel_id')=='$CHANNEL_ID']; \
print('DUPLICATE:', matches[0]['channel_name']) if matches else print('OK - no conflicts')"
```
### Step 4: Create PeerTube Channel
```bash
ssh zvx@192.168.1.170 bash << 'REMOTE'
CLIENT=$(curl -s http://localhost:9000/api/v1/oauth-clients/local -H "Host: stream.echo6.co")
CID=$(echo "$CLIENT" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])")
CSEC=$(echo "$CLIENT" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])")
TOKEN=$(curl -s http://localhost:9000/api/v1/users/token -H "Host: stream.echo6.co" \
--data "client_id=$CID&client_secret=$CSEC&grant_type=password&username=root&password=7redditGold" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
curl -s -X POST http://localhost:9000/api/v1/video-channels \
-H "Host: stream.echo6.co" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"ACTOR_NAME\",\"displayName\":\"(YT)CHANNEL_NAME\"}"
REMOTE
```
Replace `ACTOR_NAME` and `CHANNEL_NAME` in the `-d` payload. Record the returned `videoChannel.id`.
### Step 5: Update channel-map.json
**IMPORTANT:** Write to temp file first, then tee into place. Never pipe directly into tee on the same file being read — it causes a race condition that empties the file.
```bash
ssh zvx@192.168.1.170 bash << 'REMOTE'
python3 -c "
import json
with open('/opt/bulk-import/config/channel-map.json') as f:
channels = json.load(f)
channels.append({
'category': 'CATEGORY',
'channel_name': '(YT)CHANNEL_NAME',
'actor_name': 'ACTOR_NAME',
'youtube_url': 'CHANNEL_URL',
'youtube_channel_id': 'CHANNEL_ID',
'peertube_channel_id': PT_CHANNEL_ID,
'video_count': 0,
'priority': 'PRIORITY',
'est_videos': 0,
'est_gb': 0
})
print(json.dumps(channels, indent=2))
" > /tmp/channel-map-new.json \
&& sudo -u peertube tee /opt/bulk-import/config/channel-map.json < /tmp/channel-map-new.json > /dev/null \
&& rm -f /tmp/channel-map-new.json \
&& echo "OK"
REMOTE
```
Replace all placeholder values (CATEGORY, CHANNEL_NAME, ACTOR_NAME, CHANNEL_URL, CHANNEL_ID, PT_CHANNEL_ID, PRIORITY).
### Step 6: Verify
```bash
# Check channel count
curl -s http://192.168.1.130:8420/api/peertube/channels/stats | python3 -m json.tool
# Verify new channel in list
curl -s http://192.168.1.130:8420/api/peertube/channels \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d[-1]['actor_name'], d[-1]['category'])"
```
## Recovery: Empty channel-map.json
If `tee` race condition empties the file:
1. Check Contabo backup: `ssh root@100.64.0.1 ls -la /opt/backups/recon/`
2. Or rebuild from PeerTube DB:
```bash
ssh zvx@192.168.1.170 "sudo -u peertube psql peertube_prod -t -A -c \
\"SELECT name, \\\"displayName\\\" FROM \\\"videoChannel\\\" WHERE name != 'root_channel' AND name != 'default' ORDER BY id;\""
```
## API Endpoints (RECON Dashboard)
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/peertube/channels` | GET | List all channels with video counts |
| `/api/peertube/channels/stats` | GET | Total channels, videos, downloader status |
| `/api/peertube/channels/add` | POST | Add channel (JSON: youtube_url, category, priority) |
| `/api/peertube/channels/<actor_name>` | DELETE | Remove channel from JSON and PeerTube |
## Common Issues
| Issue | Cause | Fix |
|-------|-------|-----|
| yt-dlp "Join this channel" error | Members-only first video | API auto-retries with `/videos` tab. CLI: add `--ignore-errors --playlist-items 1:5` and use `/videos` URL |
| channel-map.json empty (0 bytes) | tee race condition | Always write to temp file first, then tee. Restore from backup or Contabo |
| sudo: password required | Sudoers not set up | Create `/etc/sudoers.d/recon-mgmt` via `pct exec 110` from root@192.168.1.243 |
| PeerTube "actor name already exists" | Channel exists in PeerTube but not in JSON | Add entry to JSON manually with correct `peertube_channel_id` |
---
*Last updated: 2026-02-18 — Initial creation*

View file

@ -0,0 +1,337 @@
# Authentik Access Groups
Manage group-based application access via the Authentik API. No web UI interaction required.
**Authentik instance:** https://auth.echo6.co (Contabo, 100.64.0.1)
**Key behavior:** Users in `authentik Admins` (is_superuser=true) bypass ALL policy checks automatically. Group bindings only restrict non-superuser access.
---
## How It Works
By default, any authenticated Authentik user can access any application. Adding a **policy binding** that ties a **group** to an **application** restricts that app to group members only (plus superusers).
- One binding per group-application pair
- An app can have multiple group bindings (policy_engine_mode=`any` means membership in ANY bound group grants access)
- Apps with zero bindings remain open to all authenticated users
- Superusers always have access regardless of bindings
---
## Setup
```bash
AK_TOKEN="$(grep 'AUTHENTIK_API_TOKEN=' /home/zvx/projects/.ref/credentials | tail -1 | sed 's/.*=//' | tr -d '"')"
AK_API="https://auth.echo6.co/api/v3"
```
Verify:
```bash
curl -s -H "Authorization: Bearer $AK_TOKEN" "$AK_API/core/groups/?page_size=1" | jq '.pagination.count'
```
Must return a number. If `403`, the token is invalid or expired.
---
## Procedure A: Create a New Access Group
### Inputs
```
GROUP_NAME= # lowercase, hyphenated (e.g., "finance-users", "dev-users")
```
Convention: `<category>-users` (e.g., `media-users`, `cloud-users`, `security-users`).
### Create the group
```bash
curl -s -X POST "$AK_API/core/groups/" \
-H "Authorization: Bearer $AK_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$GROUP_NAME\"}" | jq '{name: .name, pk: .pk}'
```
Store the returned `pk` as `GROUP_PK`.
### Gate
Response must include a valid UUID `pk`. If it returns an error, the group name likely already exists.
---
## Procedure B: Bind a Group to an Application
This restricts the application so only members of the bound group (and superusers) can access it.
### Inputs
```
APP_SLUG= # Application slug (e.g., "jellyfin", "nextcloud")
GROUP_PK= # Group UUID from Procedure A or the reference table below
```
### Look up the application PK
```bash
APP_PK=$(curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/core/applications/?slug=$APP_SLUG&superuser_full_list=true" \
| jq -r '.results[0].pk')
echo "App PK: $APP_PK"
```
Must return a UUID. Use `superuser_full_list=true` because apps that already have bindings won't appear without it.
### Check for existing bindings
```bash
curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/policies/bindings/?target=$APP_PK" \
| jq '.results[] | {pk: .pk, group: .group_obj.name}'
```
Review output. If the desired group is already bound, skip creation.
### Create the binding
```bash
curl -s -X POST "$AK_API/policies/bindings/" \
-H "Authorization: Bearer $AK_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"group\": \"$GROUP_PK\",
\"target\": \"$APP_PK\",
\"order\": 0,
\"enabled\": true,
\"negate\": false,
\"timeout\": 30
}" | jq '{pk: .pk, group: .group_obj.name, target: .target}'
```
### Gate
Response must include a valid UUID `pk`. If it fails:
- **"target" invalid** — the application PK is wrong
- **"group" invalid** — the group PK is wrong
---
## Procedure C: Add a User to a Group
### Inputs
```
USERNAME= # Authentik username (e.g., "jodie")
GROUP_PK= # Group UUID
```
### Look up the user PK
```bash
USER_PK=$(curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/core/users/?search=$USERNAME" \
| jq -r '.results[0].pk')
echo "User PK: $USER_PK"
```
### Get current group members
```bash
CURRENT_USERS=$(curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/core/groups/$GROUP_PK/" \
| jq -r '[.users[]] | join(",")')
echo "Current user PKs: $CURRENT_USERS"
```
### Add user to group
```bash
curl -s -X PATCH "$AK_API/core/groups/$GROUP_PK/" \
-H "Authorization: Bearer $AK_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"users\": [$CURRENT_USERS, $USER_PK]}" \
| jq '{name: .name, users: [.users_obj[].username]}'
```
### Gate
Response must list the user in `users`. The `users` field is a **replace** operation — always include existing user PKs to avoid removing them.
---
## Procedure D: Remove a User from a Group
Same as Procedure C, but omit the user PK from the `users` array:
```bash
# Get current members, filter out the target user
NEW_USERS=$(curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/core/groups/$GROUP_PK/" \
| jq -r "[.users[] | select(. != $USER_PK)] | join(\",\")")
curl -s -X PATCH "$AK_API/core/groups/$GROUP_PK/" \
-H "Authorization: Bearer $AK_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"users\": [$NEW_USERS]}" \
| jq '{name: .name, users: [.users_obj[].username]}'
```
---
## Procedure E: Remove a Group Binding from an Application
This re-opens the application to all authenticated users (if it was the only binding).
### Find the binding PK
```bash
curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/policies/bindings/?target=$APP_PK" \
| jq '.results[] | {binding_pk: .pk, group: .group_obj.name}'
```
### Delete the binding
```bash
BINDING_PK= # From the output above
curl -s -X DELETE -w "%{http_code}" \
-H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/policies/bindings/$BINDING_PK/"
```
Must return `204`.
---
## Procedure F: Rename a Group
```bash
OLD_GROUP_PK= # UUID of the group to rename
NEW_NAME= # New name (e.g., "media-users")
curl -s -X PATCH "$AK_API/core/groups/$OLD_GROUP_PK/" \
-H "Authorization: Bearer $AK_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$NEW_NAME\"}" \
| jq '{name: .name, pk: .pk}'
```
Renaming propagates to all existing bindings automatically — no need to recreate bindings.
---
## Verification
### List all groups and members
```bash
curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/core/groups/?page_size=50" \
| jq '.results[] | {name: .name, pk: .pk, superuser: .is_superuser, users: [.users_obj[].username]}'
```
### List all application bindings
```bash
curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/core/applications/?superuser_full_list=true&page_size=50" \
| jq -r '.results[] | .slug' | while read slug; do
echo "--- $slug ---"
APP_PK=$(curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/core/applications/?slug=$slug&superuser_full_list=true" \
| jq -r '.results[0].pk')
curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/policies/bindings/?target=$APP_PK" \
| jq -r 'if .results | length == 0 then " (open to all)" else .results[] | " \(.group_obj.name)" end'
done
```
### Check what a specific user can see
```bash
# This shows apps visible to the API token owner without superuser bypass
curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/core/applications/?superuser_full_list=false" \
| jq '[.results[].name]'
```
For a non-superuser, this returns only apps they have group access to plus unbound apps.
---
## Troubleshooting
### User gets "access denied" after binding was added
1. Verify the user is in the correct group:
```bash
curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/core/groups/$GROUP_PK/" \
| jq '[.users_obj[].username]'
```
2. Verify the binding exists and is enabled:
```bash
curl -s -H "Authorization: Bearer $AK_TOKEN" \
"$AK_API/policies/bindings/?target=$APP_PK" \
| jq '.results[] | {group: .group_obj.name, enabled: .enabled, negate: .negate}'
```
3. Check that `negate` is `false` — if `true`, the binding denies access instead of granting it.
### Superuser can't see all apps in the UI
The Authentik user library page uses `superuser_full_list=false` by default. Superusers always have SSO access to all apps, but the library page only shows apps the user is explicitly authorized for. This is cosmetic — direct URL access still works.
### App disappeared from user's library after adding first binding
Expected behavior. Before any bindings exist, the app is open to everyone. The moment you add the first group binding, only that group's members (and superusers) see it. Make sure all intended users are in the group before binding.
---
## Quick Reference: Current State
### Groups
| Group | PK | Members |
|-------|----|---------|
| authentik Admins | `9944e153-f860-4443-81d1-ae544f611806` | akadmin, matt (superuser) |
| media-users | `0820b2b8-6c54-4c20-9a0a-872820e6d9ea` | jodie |
| communication-users | `31bce176-cd86-4aea-8db3-a57e03d5c2d1` | — |
| security-users | `f345a043-c2a4-4906-a43b-9860eae86ee1` | — |
| productivity-users | `698d80c7-7c29-43cd-b5d4-9eb24c85a6cc` | — |
| cloud-users | `db3cbf5d-8057-4e33-8e8d-95bfdb35fbac` | — |
| proxmox_admins | `d85a868d-7d1e-4585-92a8-b8bb86771b53` | akadmin, matt |
| proxmox_users | `cf26703a-a824-47dd-9550-30b848a8ce5f` | — |
### Application Bindings
| Application | Slug | Group | Binding PK |
|-------------|------|-------|------------|
| Jellyfin | jellyfin | media-users | `31515ffc-f937-442f-9813-263e68247687` |
| Jellyseer | jellyseer | media-users | *(existing)* |
| PeerTube | peertube | media-users | `c0f79fd3-9270-49f6-8457-42affc96c50a` |
| Mailcow | mailcow | communication-users | `5a8f92de-81d5-4cf6-9273-7093de0f568d` |
| Vaultwarden | vaultwarden | security-users | `a39e9d0c-237d-4a62-976e-b6c74ee31629` |
| Forgejo | forgejo | productivity-users | `49953de3-af0b-4b1b-954d-70684d127445` |
| Nextcloud | nextcloud | cloud-users | `6ac8ccfc-7ca3-4288-a281-b78a1c675e57` |
| Immich | immich | cloud-users | `4fdf2887-e94f-4c24-81d2-d8cd4587ef38` |
### Unbound Applications (open to all authenticated users)
| Application | Slug | Reason |
|-------------|------|--------|
| Headplane | headplane | Admin tool — superuser access only needed |
| Headscale VPN | headscale | Admin tool — superuser access only needed |
| Proxmox VE | proxmox | Admin tool — superuser access only needed |
| WATCHTOWER | watchtower | Admin tool — superuser access only needed |
---
*Last updated: 2026-02-14 — Initial creation with 5 access groups and 8 application bindings*

View file

@ -0,0 +1,204 @@
# Authentik: Create Invitation
Create user invitations via the Authentik Admin UI. Supports two modes: email (automatic delivery) and link-sharing (manual delivery).
---
## When to Use This
Any time a new user needs to be invited to Echo6 services. Invitations create a time-limited enrollment link that lets the invitee set up their own username and password.
---
## Prerequisites
- Authentik admin access at https://auth.echo6.co
- For email mode: SMTP must be configured and working (no-reply@echo6.co via Mailcow)
---
## Mode 1: Invite via Email (Automatic)
The invitation email is sent automatically when the invitation is created with an `email` field in custom attributes.
### Steps
1. Log in to https://auth.echo6.co as admin
2. Navigate to **Directory → Invitations → Create**
3. Fill in:
| Field | Value |
|-------|-------|
| Name | Descriptive name (e.g., `jane-smith-2026-02`) |
| Flow | **Invitation Enrollment** |
| Single use | **On** (recommended) |
| Expires | Set appropriately (e.g., 7 days from now) |
4. In **Custom attributes** (YAML format):
```yaml
name: Jane Smith
email: jane@example.com
```
5. Click **Create**
The expression policy (`invitation-email-sender`) detects the `email` field and calls `ak_send_email()` to deliver the enrollment link to the invitee. The email includes the invitation URL with the `?itoken=` parameter.
### What the Invitee Receives
- Email from `no-reply@echo6.co` with subject "You've been invited to join Echo6"
- Contains a link to `https://auth.echo6.co/if/flow/invitation-enrollment/?itoken=<token>`
- The link takes them through the enrollment flow: accept invitation → set username/password → auto-login
---
## Mode 2: Invite via Link (Manual)
For cases where you want to share the link yourself (Slack, Signal, in person, etc.), omit the `email` field.
### Steps
1. Log in to https://auth.echo6.co as admin
2. Navigate to **Directory → Invitations → Create**
3. Fill in:
| Field | Value |
|-------|-------|
| Name | Descriptive name (e.g., `jane-smith-link`) |
| Flow | **Invitation Enrollment** |
| Single use | **On** (recommended) |
| Expires | Set appropriately |
4. **Custom attributes** — either leave empty `{}` or include only the name:
```yaml
name: Jane Smith
```
Do **not** include an `email` field — this prevents the automatic email from being sent.
5. Click **Create**
6. In the invitation list, **expand the row** to reveal the invitation link
7. Copy and share the link manually
---
## Custom Attributes Reference
| Field | Required | Purpose |
|-------|----------|---------|
| `name` | No | Pre-fills the invitee's display name (if enrollment flow uses it) |
| `email` | No | Triggers automatic email delivery. Omit for link-sharing mode |
Only `email` affects system behavior. Any other fields are stored as metadata on the invitation.
---
## Best Practices
### Expiry
- **Email invitations:** 7 days is reasonable — gives time for the email to arrive and the user to act
- **Link invitations:** 2448 hours if sharing in real-time; 7 days if async
- **Never use no-expiry** — orphaned invitations are a security risk
### Single Use
- **Always enable** for individual invitations — prevents link reuse after the invitee enrolls
- Only disable if you're creating a batch enrollment link for a group (rare)
### Naming Convention
Use `firstname-lastname-YYYY-MM` or `purpose-YYYY-MM` for easy identification:
- `jane-smith-2026-02`
- `jodie-media-access-2026-02`
- `batch-beta-testers-2026-03`
---
## After Enrollment
New users are created under the `users/enrolled` path. To grant them access to services:
1. Navigate to **Directory → Groups**
2. Add the user to the appropriate group(s):
| Group | Grants Access To |
|-------|-----------------|
| media-users | Jellyfin, Jellyseer, PeerTube |
| ai-users | Open WebUI |
| cloud-users | Immich, Nextcloud |
| communication-users | Mailcow, Matrix |
See the [Access Groups runbook](authentik-access-groups.md) for detailed group management procedures.
---
## Troubleshooting
### Email not sent (email mode)
1. **Check custom attributes** — the `email` field must be present and correctly formatted
2. **Check SMTP** — verify Authentik can send email:
```bash
ssh root@100.64.0.1
docker exec authentik-server ak test_email matt@echo6.co
```
3. **Check Mailcow authsource** — if SMTP auth fails, the no-reply@echo6.co mailbox may have reverted to `generic-oidc`. See [Mailcow Create Mailbox runbook](mailcow-create-mailbox.md), Step 2
4. **Check Authentik logs**:
```bash
docker compose -f /opt/authentik/docker-compose.yml logs server --since 5m 2>&1 | grep -i email
```
### "Invalid invite/invite not found" when clicking link
- The invitation has expired or was already used (single-use)
- The invitation was deleted
- The `?itoken=` parameter is missing or malformed in the URL
### User enrolled but can't access any apps
- The user needs to be added to at least one service group (see "After Enrollment" above)
- By default, enrolled users have no group memberships
---
## Managing Existing Invitations
### View All Invitations
Admin UI → **Directory → Invitations** — shows all active invitations with name, expiry, and usage status.
### Delete an Invitation
Click the trash icon next to the invitation. This immediately invalidates the link — anyone who hasn't enrolled yet will see "Invalid invite."
### Via API
```bash
# List all invitations
curl -s "https://auth.echo6.co/api/v3/stages/invitation/invitations/" \
-H "Authorization: Bearer $AUTHENTIK_API_TOKEN" | python3 -m json.tool
# Delete by PK
curl -s -X DELETE "https://auth.echo6.co/api/v3/stages/invitation/invitations/<PK>/" \
-H "Authorization: Bearer $AUTHENTIK_API_TOKEN"
```
---
## Checklist
```
[ ] Invitation created with correct flow (Invitation Enrollment)
[ ] Single use enabled
[ ] Expiry set appropriately
[ ] Email mode: email field in custom attributes, delivery confirmed
[ ] Link mode: link copied and shared manually
[ ] After enrollment: user added to appropriate groups
```
---
*Created: 2026-02-16*

View file

@ -0,0 +1,353 @@
# Add Authentik OIDC to an Application
Fully automated via Authentik API. No web UI interaction required.
**Prerequisite:** DNS must already exist for the service (run expose-service-contabo.md or expose-service-home.md first).
**Authentik instance:** https://auth.echo6.co (Contabo, 100.64.0.6)
---
## Inputs
Prompt the user for all of these before executing any steps:
```
SERVICE_NAME= # Human-readable (e.g., "Vaultwarden", "Headplane")
SERVICE_SLUG= # URL-safe, lowercase (e.g., "vaultwarden", "headplane")
SERVICE_URL= # Base URL (e.g., "https://vault.echo6.co")
OIDC_CALLBACK_PATH= # App's OIDC callback (e.g., "/oidc/callback")
NEEDS_OFFLINE_ACCESS= # yes/no — does the app need refresh tokens?
CLIENT_TYPE= # confidential (server-side) or public (SPA/mobile)
```
The redirect URI is `${SERVICE_URL}${OIDC_CALLBACK_PATH}`.
### When to set NEEDS_OFFLINE_ACCESS=yes
- The app stores sessions that must survive service restarts (Headscale, Vaultwarden)
- The app uses refresh tokens for long-lived sessions
- Users shouldn't have to re-authenticate after every restart
### Reserved slugs
These conflict with Authentik's internal OAuth2 endpoints and **cannot be used**: `authorize`, `token`, `device`, `userinfo`, `introspect`, `revoke`.
---
## Step 1: Get API Token
Create an API token from the Authentik admin account. This only needs to happen once — reuse the token across all OIDC setups.
```bash
ssh root@100.64.0.6 "docker exec authentik-server \
ak create_token --user akadmin --identifier oidc-automation --expiring 2>/dev/null \
|| echo 'Token may already exist — check credentials file'"
```
If the token already exists, retrieve it from `/home/zvx/projects/.ref/credentials` (`AUTHENTIK_API_TOKEN`).
Store it for use in subsequent steps:
```bash
AK_TOKEN="<token>"
AK_API="https://auth.echo6.co/api/v3"
```
---
## Step 2: Look Up Authentik Internal IDs
The API requires UUIDs for flows, scope mappings, and signing keys. These are stable per Authentik instance but must be looked up once.
### Authorization flow
```bash
ssh root@100.64.0.6 "curl -s \
-H 'Authorization: Bearer $AK_TOKEN' \
'$AK_API/flows/instances/?slug=default-provider-authorization-implicit-consent' \
| jq -r '.results[0].pk'"
```
Store as `AUTH_FLOW_PK`.
### Scope mappings
```bash
# Get all scope mapping UUIDs at once
ssh root@100.64.0.6 "curl -s \
-H 'Authorization: Bearer $AK_TOKEN' \
'$AK_API/propertymappings/provider/scope/?ordering=scope_name' \
| jq -r '.results[] | select(.scope_name == \"openid\" or .scope_name == \"email\" or .scope_name == \"profile\" or .scope_name == \"offline_access\") | \"\(.scope_name): \(.pk)\"'"
```
Store each UUID: `SCOPE_OPENID_PK`, `SCOPE_EMAIL_PK`, `SCOPE_PROFILE_PK`, `SCOPE_OFFLINE_PK`.
### Signing key
```bash
ssh root@100.64.0.6 "curl -s \
-H 'Authorization: Bearer $AK_TOKEN' \
'$AK_API/crypto/certificatekeypairs/?name=authentik+Self-signed+Certificate&has_key=true' \
| jq -r '.results[0].pk'"
```
Store as `SIGNING_KEY_PK`.
### Gate
All five values must be non-null. If any are missing, Authentik's default objects may not have been created yet — check that the instance is healthy.
---
## Step 3: Create the OAuth2 Provider
Build the scope mappings array based on whether offline_access is needed:
```bash
# Base scopes (always included)
SCOPES="[\"$SCOPE_OPENID_PK\", \"$SCOPE_EMAIL_PK\", \"$SCOPE_PROFILE_PK\"]"
# Add offline_access if needed
if [ "$NEEDS_OFFLINE_ACCESS" = "yes" ]; then
SCOPES="[\"$SCOPE_OPENID_PK\", \"$SCOPE_EMAIL_PK\", \"$SCOPE_PROFILE_PK\", \"$SCOPE_OFFLINE_PK\"]"
fi
```
Create the provider:
```bash
PROVIDER_RESPONSE=$(ssh root@100.64.0.6 "curl -s \
-X POST '$AK_API/providers/oauth2/' \
-H 'Authorization: Bearer $AK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
\"name\": \"$SERVICE_NAME\",
\"authorization_flow\": \"$AUTH_FLOW_PK\",
\"client_type\": \"$CLIENT_TYPE\",
\"redirect_uris\": [{
\"matching_mode\": \"strict\",
\"url\": \"${SERVICE_URL}${OIDC_CALLBACK_PATH}\"
}],
\"signing_key\": \"$SIGNING_KEY_PK\",
\"property_mappings\": $SCOPES,
\"access_token_validity\": \"hours=1\",
\"refresh_token_validity\": \"days=30\"
}'")
# Extract the values we need
PROVIDER_PK=$(echo "$PROVIDER_RESPONSE" | jq -r '.pk')
CLIENT_ID=$(echo "$PROVIDER_RESPONSE" | jq -r '.client_id')
CLIENT_SECRET=$(echo "$PROVIDER_RESPONSE" | jq -r '.client_secret')
echo "Provider PK: $PROVIDER_PK"
echo "Client ID: $CLIENT_ID"
echo "Client Secret: $CLIENT_SECRET"
```
### Gate
`PROVIDER_PK` must be a number (not null or an error). If the API returns an error, common causes:
- **Duplicate name** — a provider with this name already exists
- **Invalid flow PK** — the authorization flow UUID is wrong
- **Invalid scope PK** — one of the scope mapping UUIDs is wrong
---
## Step 4: Create the Application
```bash
ssh root@100.64.0.6 "curl -s \
-X POST '$AK_API/core/applications/' \
-H 'Authorization: Bearer $AK_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
\"name\": \"$SERVICE_NAME\",
\"slug\": \"$SERVICE_SLUG\",
\"provider\": $PROVIDER_PK,
\"meta_launch_url\": \"$SERVICE_URL\"
}' | jq '{name: .name, slug: .slug, provider: .provider}'"
```
### Gate
Response must include the slug and provider PK. If it fails, the slug may already be in use.
---
## Step 5: Verify Authentik Side
### Discovery endpoint
```bash
curl -s "https://auth.echo6.co/application/o/$SERVICE_SLUG/.well-known/openid-configuration" | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri}'
```
Must return all four fields with valid URLs.
### JWKS endpoint
```bash
curl -s "https://auth.echo6.co/application/o/$SERVICE_SLUG/jwks/" | jq '.keys | length'
```
Must return at least `1`. If it returns `0`, the signing key was not attached to the provider — go back and fix Step 3.
---
## Step 6: Configure the Application
This step varies per application. Use the Client ID, Client Secret, and issuer URL from above.
### OIDC endpoints (all derived from the slug)
```
Issuer: https://auth.echo6.co/application/o/$SERVICE_SLUG/
Authorize: https://auth.echo6.co/application/o/authorize/
Token: https://auth.echo6.co/application/o/token/
User Info: https://auth.echo6.co/application/o/userinfo/
JWKS: https://auth.echo6.co/application/o/$SERVICE_SLUG/jwks/
```
Most apps only need the **Issuer** (or Discovery URL) plus Client ID and Client Secret. The app auto-discovers the rest.
### Common config patterns
**Environment variables (Docker):**
```bash
OIDC_ISSUER=https://auth.echo6.co/application/o/$SERVICE_SLUG/
OIDC_CLIENT_ID=$CLIENT_ID
OIDC_CLIENT_SECRET=$CLIENT_SECRET
OIDC_SCOPES="openid email profile" # add offline_access if needed
OIDC_REDIRECT_URI=${SERVICE_URL}${OIDC_CALLBACK_PATH}
```
**Config file (YAML):**
```yaml
oidc:
issuer: "https://auth.echo6.co/application/o/$SERVICE_SLUG/"
client_id: "$CLIENT_ID"
client_secret: "$CLIENT_SECRET"
scope: ["openid", "profile", "email"] # add "offline_access" if needed
```
### Common alternate names for these values
| Concept | Names you'll see |
|---------|-----------------|
| Issuer | `authority`, `issuer_url`, `sso_authority`, `provider_url` |
| Client ID | `client_id`, `oidc_client_id`, `sso_client_id` |
| Client Secret | `client_secret`, `oidc_client_secret`, `sso_client_secret` |
| Redirect URI | `redirect_uri`, `callback_url`, `oidc_redirect_url` |
| Scopes | `scope`, `scopes`, `oidc_scopes`, `sso_scopes` |
---
## Step 7: Test Login
1. Open `$SERVICE_URL` in a browser
2. Click SSO / OIDC login
3. Should redirect to `auth.echo6.co` → authenticate → redirect back to the app
4. Verify user info is correct (email, display name)
---
## Step 8: Store Credentials
```bash
cat >> /home/zvx/projects/.ref/credentials << EOF
# $SERVICE_NAME OIDC
${SERVICE_SLUG^^}_OIDC_CLIENT_ID=$CLIENT_ID
${SERVICE_SLUG^^}_OIDC_CLIENT_SECRET=$CLIENT_SECRET
${SERVICE_SLUG^^}_OIDC_ISSUER=https://auth.echo6.co/application/o/$SERVICE_SLUG/
EOF
```
---
## Troubleshooting
### SSO login redirects back to login page (loop)
Check in order:
1. **Access token validity too short** — increase to at least `hours=1`
2. **Missing `offline_access` scope** — app can't refresh tokens, session expires immediately
3. **Missing signing key** — JWKS endpoint returns empty, app can't verify tokens
Debug via API:
```bash
ssh root@100.64.0.6 "curl -s \
-H 'Authorization: Bearer $AK_TOKEN' \
'$AK_API/providers/oauth2/?search=$SERVICE_NAME' \
| jq '.results[0] | {name, client_id, signing_key, access_token_validity, refresh_token_validity, property_mappings}'"
```
Or via ak shell:
```bash
ssh root@100.64.0.6 "docker exec authentik-server ak shell -c \"
from authentik.providers.oauth2.models import OAuth2Provider
p = OAuth2Provider.objects.get(name='$SERVICE_NAME')
print(f'Access Token: {p.access_token_validity}')
print(f'Refresh Token: {p.refresh_token_validity}')
print(f'Signing Key: {p.signing_key}')
print(f'Scopes: {list(p.property_mappings.values_list(\\\"scope_name\\\", flat=True))}')
\""
```
### "Failed to discover OpenID provider" / discovery error
1. JWKS endpoint is empty → signing key missing from provider
2. Authentik unreachable from the app → test with `curl` from the app's host
3. Wrong issuer URL → must include trailing slash, must match the slug exactly
### "Invalid redirect URI"
The redirect URI in the app config must **exactly** match what's in Authentik — scheme, trailing slashes, path, everything.
### User authenticated but gets "access denied"
User isn't authorized for the application. By default all authenticated users have access. If you've added group restrictions via policy bindings, verify the user is in the correct group:
```bash
ssh root@100.64.0.6 "curl -s \
-H 'Authorization: Bearer $AK_TOKEN' \
'$AK_API/core/applications/$SERVICE_SLUG/' \
| jq '{name, slug, policy_engine_mode}'"
```
### Token/session breaks after service restart
Missing `offline_access` scope. Without refresh tokens, sessions only last as long as the access token validity.
### Delete and recreate (nuclear option)
```bash
# Delete application first (it references the provider)
ssh root@100.64.0.6 "curl -s -X DELETE \
-H 'Authorization: Bearer $AK_TOKEN' \
'$AK_API/core/applications/$SERVICE_SLUG/'"
# Then delete provider
ssh root@100.64.0.6 "curl -s -X DELETE \
-H 'Authorization: Bearer $AK_TOKEN' \
'$AK_API/providers/oauth2/$PROVIDER_PK/'"
```
Then re-run from Step 3.
---
## Quick Reference: Existing OIDC Applications
| Application | Slug | Redirect URI | offline_access |
|-------------|------|-------------|----------------|
| Headscale | `headscale` | `https://vpn.echo6.co/oidc/callback` | Yes |
| Headplane | `headplane` | `https://vpn.echo6.co/admin/oidc/callback` | No |
| Vaultwarden | `vaultwarden` | `https://vault.echo6.co/identity/connect/oidc-signin` | Yes |

View file

@ -0,0 +1,314 @@
# Authentik: Major Version Upgrade
Upgrade Authentik between major versions on Contabo. Covers backup, upgrade, verification, and rollback.
---
## When to Use This
Any time Authentik is upgraded across major versions (e.g., 2024.12 → 2025.6 → 2025.12). Minor patch upgrades within the same major (e.g., 2025.12.3 → 2025.12.4) are lower risk but should still follow the backup steps.
---
## Prerequisites
- SSH access to Contabo (`ssh root@100.64.0.1`)
- Authentik compose directory: `/opt/authentik/`
- Current version: check with `docker exec authentik-server ak --version`
---
## Inputs
```
CURRENT_VERSION=2025.12.4 # Current running version
TARGET_VERSION=2026.2.1 # Version to upgrade to
```
---
## Step 1: Check Release Notes
Before upgrading, read the release notes for **every major version between current and target**:
```
https://docs.goauthentik.io/docs/releases/
```
Look for:
- **Breaking changes** — removed features, changed defaults, API changes
- **Dependency changes** — added/removed services (e.g., Redis removed in 2025.10)
- **Configuration changes** — new required env vars, changed mount paths
- **Database migrations** — large migrations that may take time
### Known Breaking Changes (Reference)
| Version | Change | Impact |
|---------|--------|--------|
| 2025.10 | Redis completely removed | Delete redis service + all `AUTHENTIK_REDIS` env vars |
| 2025.10 | Default email scope returns `email_verified: false` | Use custom scope mapping (PK `02c22323`) that forces `true` |
| 2025.10 | Worker requires `user: root` | Add `user: root` to worker service in compose |
| 2025.12 | Stage creation endpoints moved | `stages/<type>/stages/` for POST (some types) |
---
## Step 2: Backup
### 2a. Snapshot Contabo (if Proxmox-managed)
If Contabo were a Proxmox VM, take a snapshot. Since it's a bare-metal VPS, skip this and rely on the file-level backups below.
### 2b. PostgreSQL Dump
```bash
ssh root@100.64.0.1
cd /opt/authentik
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
docker exec authentik-postgres \
pg_dump -U authentik -d authentik \
--clean --if-exists \
> /opt/authentik/backups/authentik_pre_upgrade_${TIMESTAMP}.sql
ls -lh /opt/authentik/backups/authentik_pre_upgrade_${TIMESTAMP}.sql
```
### 2c. Compose Directory Backup
```bash
cp -a /opt/authentik /opt/authentik.bak_${TIMESTAMP}
```
This preserves `docker-compose.yml`, `.env`, `certs/`, and any custom files.
### 2d. Record Current State
```bash
# Save current version
docker exec authentik-server ak --version
# Save current provider list (for post-upgrade comparison)
curl -s "https://auth.echo6.co/api/v3/providers/oauth2/" \
-H "Authorization: Bearer $(grep AUTHENTIK_API_TOKEN /home/zvx/projects/.ref/credentials | cut -d= -f2)" \
| python3 -c "import sys,json; [print(f'{p[\"pk\"]:3d} {p[\"name\"]}') for p in json.load(sys.stdin)['results']]"
```
---
## Step 3: Apply Compose Changes
Review release notes and update `docker-compose.yml` **before** pulling the new image:
```bash
cd /opt/authentik
nano docker-compose.yml
```
Common changes by version:
**Removing Redis (2025.10+):**
```yaml
# DELETE the redis service entirely
# DELETE these env vars from server + worker:
# AUTHENTIK_REDIS__HOST
# AUTHENTIK_REDIS__PORT
```
**Worker user requirement (2025.10+):**
```yaml
services:
worker:
user: root # ADD this line
```
**New env vars:**
Check release notes for any new required `AUTHENTIK_*` env vars. Add to both server and worker services.
---
## Step 4: Upgrade
```bash
cd /opt/authentik
# Update image tag in docker-compose.yml
# Change: image: ghcr.io/goauthentik/server:CURRENT_VERSION
# To: image: ghcr.io/goauthentik/server:TARGET_VERSION
nano docker-compose.yml
# Pull new image
docker compose pull
# Stop and recreate containers (migrations run automatically on start)
docker compose down && docker compose up -d
# Watch logs for migration progress
docker compose logs -f server --since 1m
```
Migrations may take 15 minutes depending on database size. Wait until you see:
```
Starting gunicorn
```
---
## Step 5: Verify
### 5a. Version Check
```bash
docker exec authentik-server ak --version
# Should show TARGET_VERSION
```
### 5b. API Token
API tokens are sometimes invalidated during major upgrades. Test:
```bash
curl -s -o /dev/null -w "%{http_code}" \
"https://auth.echo6.co/api/v3/core/applications/" \
-H "Authorization: Bearer $(grep AUTHENTIK_API_TOKEN /home/zvx/projects/.ref/credentials | cut -d= -f2)"
```
If this returns `403`, regenerate the token:
```bash
docker exec -i authentik-server ak shell <<'PYEOF'
from authentik.core.models import Token, TokenIntents, User
user = User.objects.get(username="akadmin")
Token.objects.filter(identifier="claude-api-token").delete()
t = Token(identifier="claude-api-token", user=user, intent=TokenIntents.INTENT_API, expiring=False, managed=None)
t.save()
print(t.key)
PYEOF
```
Update `/home/zvx/projects/.ref/credentials` with the new token.
### 5c. Email Scope Mapping
Check that the custom email scope (`02c22323`) is still assigned to providers. The default scope (`096b0d6f`) may return `email_verified: false` in 2025.10+:
```bash
# Check a canary provider (Forgejo, PK 2)
curl -s "https://auth.echo6.co/api/v3/providers/oauth2/2/" \
-H "Authorization: Bearer $AUTHENTIK_API_TOKEN" \
| python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['property_mappings'], indent=2))"
```
Verify `02c22323-da89-457a-bc12-7f4dd6a3d8ab` is in the list. If missing, re-add it to all providers.
### 5d. Spot-Check OAuth2 Apps
Test SSO login on 23 apps as canaries:
1. **Forgejo**`https://forge.echo6.co` → click "Sign in with Authentik"
2. **Proxmox**`https://proxmox.echo6.co` → select OpenID realm
Both should redirect to Authentik, authenticate, and return to the app.
### 5e. SMTP Delivery
```bash
docker exec authentik-server ak test_email matt@echo6.co
```
Check that the test email arrives. If SMTP auth fails, verify the no-reply@echo6.co mailbox authsource (see Mailcow runbook).
### 5f. Invitation System
Create a test invitation in Admin UI → Directory → Invitations with `email: matt@echo6.co` in custom attributes. Confirm the email is sent. Delete the test invitation after.
---
## Rollback
If the upgrade breaks critical functionality:
### Option A: Roll Back Image (Quick)
```bash
cd /opt/authentik
# Revert image tag to previous version
nano docker-compose.yml
# Change TARGET_VERSION back to CURRENT_VERSION
docker compose down && docker compose up -d
```
This works if no breaking database migrations occurred. Check logs for migration errors.
### Option B: Full Restore (Nuclear)
```bash
cd /opt
# Stop everything
cd /opt/authentik && docker compose down
# Restore compose directory
rm -rf /opt/authentik
cp -a /opt/authentik.bak_${TIMESTAMP} /opt/authentik
# Restore database
cd /opt/authentik
docker compose up -d postgres
sleep 10
docker exec -i authentik-postgres \
psql -U authentik -d authentik \
< /opt/authentik/backups/authentik_pre_upgrade_${TIMESTAMP}.sql
docker compose up -d
```
### Post-Rollback
- Verify the old version is running: `docker exec authentik-server ak --version`
- Test SSO login on Forgejo
- Test SMTP: `docker exec authentik-server ak test_email matt@echo6.co`
---
## Cleanup
After confirming the upgrade is stable (wait at least 24 hours):
```bash
# Remove backup
rm -rf /opt/authentik.bak_${TIMESTAMP}
# Keep the SQL dump for archival (or remove if space is needed)
# rm /opt/authentik/backups/authentik_pre_upgrade_${TIMESTAMP}.sql
# Prune old Docker images
docker image prune -a --filter "until=168h"
```
---
## Checklist
```
[ ] Release notes reviewed for all versions between current and target
[ ] PostgreSQL dump taken
[ ] Compose directory backed up
[ ] Compose changes applied (removed/added services, env vars, user directives)
[ ] Image tag updated and pulled
[ ] Containers recreated, migrations completed
[ ] Version confirmed
[ ] API token tested (regenerated if needed)
[ ] Custom email scope verified on providers
[ ] SSO login tested on Forgejo + Proxmox
[ ] SMTP delivery tested
[ ] Invitation system tested
[ ] Backup files cleaned up (after 24h+ stability)
```
---
*Created: 2026-02-16*

View file

@ -0,0 +1,279 @@
# Binary Wrapper Interception
Transparently intercept a CLI binary with a wrapper script that adds pre-flight logic (routing, validation, logging) before exec-ing the real binary. The caller — whether a service, cron job, or another script — never knows the difference.
Use this when you need to modify the behavior of a tool that's called by a system you don't control (e.g., a runner, scheduler, or third-party service), without changing the caller's config or code.
---
## Prerequisites
- The binary to intercept is installed and working
- You have root/sudo access on the target machine
- The caller invokes the binary by absolute path or via PATH lookup
---
## Inputs
Prompt the user for all of these before executing:
```
TARGET_HOST= # SSH alias or IP (e.g., cortex). Use "localhost" if local.
BINARY_NAME= # Name of the binary to intercept (e.g., "whisper-ctranslate2")
BINARY_PATH= # Full path to the binary (e.g., "/usr/local/bin/whisper-ctranslate2")
WRAPPER_NAME= # Name for the wrapper script (e.g., "whisper-smart")
WRAPPER_DIR= # Directory for the wrapper (e.g., "/usr/local/bin")
REAL_SUFFIX= # Suffix for the renamed real binary (default: "-real")
```
---
## Step 1: Locate the Real Binary
Find the actual binary or symlink that will be intercepted.
```bash
ssh $TARGET_HOST "ls -la $BINARY_PATH && file $BINARY_PATH"
```
If it's already a symlink, follow it to the real target:
```bash
ssh $TARGET_HOST "readlink -f $BINARY_PATH"
```
### Gate
Must return a valid file. Record the real binary location — you'll need it for the rename.
---
## Step 2: Rename the Real Binary
Move the original binary out of the way so the wrapper can take its place.
```bash
ssh $TARGET_HOST "sudo mv $BINARY_PATH ${BINARY_PATH}${REAL_SUFFIX}"
```
If the original was a symlink (e.g., pip-installed Python tool):
```bash
# Preserve the symlink target
REAL_TARGET=$(ssh $TARGET_HOST "readlink -f $BINARY_PATH")
ssh $TARGET_HOST "sudo rm $BINARY_PATH && sudo ln -s $REAL_TARGET ${BINARY_PATH}${REAL_SUFFIX}"
```
### Gate
```bash
ssh $TARGET_HOST "ls -la ${BINARY_PATH}${REAL_SUFFIX}"
ssh $TARGET_HOST "${BINARY_PATH}${REAL_SUFFIX} --version 2>/dev/null || ${BINARY_PATH}${REAL_SUFFIX} --help 2>/dev/null | head -1"
```
The renamed binary must exist and be executable. If not, undo immediately:
```bash
ssh $TARGET_HOST "sudo mv ${BINARY_PATH}${REAL_SUFFIX} $BINARY_PATH"
```
---
## Step 3: Write the Wrapper Script
Create the wrapper at `$WRAPPER_DIR/$WRAPPER_NAME`. The wrapper must:
1. Accept all original arguments (`$@`)
2. Perform pre-flight logic (inspection, routing, logging)
3. `exec` the real binary with (possibly modified) arguments
4. Never silently swallow errors — if pre-flight fails, exit with a meaningful code
Template:
```bash
#!/bin/bash
# Wrapper for $BINARY_NAME — transparently intercepts calls
# Real binary at: ${BINARY_PATH}${REAL_SUFFIX}
LOGFILE="/tmp/${WRAPPER_NAME}.log"
# ──── Pre-flight logic ────
# Add your inspection, routing, or validation here.
# Example: inspect input files, check resource availability, choose parameters.
# Parse arguments to find relevant inputs (file paths, flags, etc.)
# This section is use-case specific.
# ──── Logging ────
echo "[WRAPPER] $(date) args: $@" >> "$LOGFILE"
# ──── Execute real binary ────
# Use exec to replace this process — caller sees the real binary's exit code,
# stdout, stderr, and signal handling as if wrapper didn't exist.
exec ${BINARY_PATH}${REAL_SUFFIX} "$@"
```
Deploy the wrapper:
```bash
ssh $TARGET_HOST "sudo tee $WRAPPER_DIR/$WRAPPER_NAME > /dev/null << 'WRAPPER'
<paste wrapper script here>
WRAPPER
sudo chmod +x $WRAPPER_DIR/$WRAPPER_NAME"
```
### Gate
```bash
ssh $TARGET_HOST "ls -la $WRAPPER_DIR/$WRAPPER_NAME && head -1 $WRAPPER_DIR/$WRAPPER_NAME"
```
Must show executable permissions and `#!/bin/bash` shebang.
---
## Step 4: Install the Symlink
Replace the original binary path with a symlink to the wrapper.
```bash
ssh $TARGET_HOST "sudo ln -sf $WRAPPER_DIR/$WRAPPER_NAME $BINARY_PATH"
```
### Gate
```bash
ssh $TARGET_HOST "ls -la $BINARY_PATH"
```
Must show: `$BINARY_PATH -> $WRAPPER_DIR/$WRAPPER_NAME`
Verify the full chain:
```bash
ssh $TARGET_HOST "ls -la $BINARY_PATH && ls -la ${BINARY_PATH}${REAL_SUFFIX}"
```
Should show:
```
BINARY_PATH -> WRAPPER_DIR/WRAPPER_NAME (wrapper)
BINARY_PATH-real -> /path/to/actual/binary (real binary)
```
---
## Step 5: Test the Interception
Run the binary as the caller would. The wrapper should intercept transparently.
```bash
# Direct invocation
ssh $TARGET_HOST "$BINARY_PATH --version"
# Check wrapper log
ssh $TARGET_HOST "tail -5 /tmp/${WRAPPER_NAME}.log"
```
The `--version` output should come from the real binary. The log should show the wrapper fired.
Test with actual workload arguments:
```bash
ssh $TARGET_HOST "$BINARY_PATH <typical args here>"
ssh $TARGET_HOST "tail -1 /tmp/${WRAPPER_NAME}.log"
```
### Gate
Both must succeed. If the binary fails or produces different output than before, the wrapper has a bug — check argument passing (quoting, `$@` vs `$*`).
---
## Step 6: Verify Service Integration
If the binary is called by a service (systemd, cron, etc.), restart that service and confirm it picks up the wrapper.
```bash
ssh $TARGET_HOST "sudo systemctl restart <service-name>"
ssh $TARGET_HOST "sleep 5 && tail -5 /tmp/${WRAPPER_NAME}.log"
```
The log should show entries from the service's invocations, not just your manual tests.
---
## Rollback
To remove the wrapper and restore the original binary:
```bash
ssh $TARGET_HOST "sudo rm $BINARY_PATH && sudo mv ${BINARY_PATH}${REAL_SUFFIX} $BINARY_PATH"
# Or if the original was a symlink:
ssh $TARGET_HOST "sudo rm $BINARY_PATH && sudo ln -s <original-target> $BINARY_PATH"
```
No service restart needed — next invocation hits the real binary directly.
---
## Key Principles
1. **`exec` is mandatory.** Without `exec`, the wrapper runs the binary as a child process, which breaks signal handling (SIGTERM won't reach the real binary) and doubles PID usage. `exec` replaces the wrapper process entirely.
2. **Use `"$@"` not `$@` or `$*`.** Quoted `"$@"` preserves argument boundaries. Unquoted `$@` splits arguments with spaces. `$*` merges all arguments into one string.
3. **Appended flags override earlier ones.** Many CLI tools (argparse, getopt) use last-value-wins for duplicate flags. The wrapper can append `--flag value` after `"$@"` to force overrides without removing the caller's original flags.
4. **Exit codes matter.** If pre-flight fails, exit with a non-zero code that the caller understands. Some callers retry on specific exit codes (e.g., PeerTube runner retries on exit 1).
5. **Log to /tmp, not to the service's log directory.** The wrapper log is a debug artifact, not part of the service's data. `/tmp` is cleaned on reboot, which is fine for wrapper logs.
---
## Troubleshooting
### Wrapper not being called
Check the symlink chain: `ls -la $BINARY_PATH`. If the service uses a hardcoded absolute path that bypasses PATH, the symlink might be in the wrong location.
### Arguments with spaces break
Use `"$@"` (quoted) in the exec line, not `$@` (unquoted).
### Service fails after wrapper install
Check the wrapper's shebang (`#!/bin/bash`), permissions (`chmod +x`), and that `exec` is present. Without exec, the wrapper may exit before the binary finishes.
### Wrapper log is empty
The service might be calling a different path than expected. Check: `which $BINARY_NAME` and compare with what the service config specifies.
---
## Usage Examples
### Whisper transcription routing (PeerTube runner on cortex)
The PeerTube remote runner calls `whisper-ctranslate2` for auto-captioning. The smart wrapper intercepts this to route short videos to GPU and long videos to CPU.
```
BINARY_NAME=whisper-ctranslate2
BINARY_PATH=/usr/local/bin/whisper-ctranslate2
WRAPPER_NAME=whisper-smart
REAL_SUFFIX=-real
Symlink chain:
/usr/local/bin/whisper-ctranslate2 → /usr/local/bin/whisper-smart
/usr/local/bin/whisper-ctranslate2-real → /home/zvx/.local/bin/whisper-ctranslate2
Wrapper logic:
- ffprobe audio duration from first non-flag argument
- < 1hr → exec with --device cuda --compute_type float16
- >= 1hr → exec with --device cpu --compute_type int8
- Appends --model medium after $@ (last-value-wins override)
```
---
*Last updated: 2026-02-17*

View file

@ -1,183 +0,0 @@
# Contabo VPS Current Configurations
**Server:** 5.189.158.149 / 100.64.0.4
**Last Updated:** 2026-02-05
---
## Caddy Configuration
**File:** `/etc/caddy/Caddyfile`
```caddyfile
# Global options
{
email admin@echo6.co
admin off
}
# Main Mailcow hostname
mail.echo6.co {
reverse_proxy https://127.0.0.1:8443 {
transport http {
tls_insecure_skip_verify
read_timeout 3600s
write_timeout 3600s
}
}
}
# Autodiscover for Outlook
autodiscover.echo6.co {
reverse_proxy https://127.0.0.1:8443 {
transport http {
tls_insecure_skip_verify
}
}
}
# Autoconfig for Thunderbird
autoconfig.echo6.co {
reverse_proxy https://127.0.0.1:8443 {
transport http {
tls_insecure_skip_verify
}
}
}
# Headscale VPN + Headplane Admin
vpn.echo6.co {
handle /admin* {
reverse_proxy 127.0.0.1:3100
}
handle {
reverse_proxy 127.0.0.1:8084
}
}
# Authentik SSO
auth.echo6.co {
reverse_proxy 127.0.0.1:9000
}
# Forgejo Git Forge
forge.echo6.co {
reverse_proxy 127.0.0.1:3001
}
# Vaultwarden Password Manager
vault.echo6.co {
reverse_proxy /notifications/hub 127.0.0.1:3012
reverse_proxy 127.0.0.1:8086
}
```
### Commands
```bash
# Validate
caddy validate --config /etc/caddy/Caddyfile
# Restart (admin off, so reload won't work)
systemctl restart caddy
# Logs
journalctl -u caddy -f
```
---
## dnsmasq Split DNS Configuration
**File:** `/etc/dnsmasq.d/tailscale-dns.conf`
```conf
# DNSmasq config for Tailscale Split DNS
# Listen only on Tailscale interface
listen-address=100.64.0.4
bind-interfaces
# Upstream DNS servers
server=1.1.1.1
server=8.8.8.8
# Local records for echo6.co services (route through Tailscale)
address=/forge.echo6.co/100.64.0.4
address=/auth.echo6.co/100.64.0.4
address=/mail.echo6.co/100.64.0.4
address=/vpn.echo6.co/100.64.0.4
address=/docs.echo6.co/100.64.0.4
address=/vault.echo6.co/100.64.0.4
address=/stream.echo6.co/100.64.0.7
address=/notes.echo6.co/100.64.0.22
# Don't read /etc/hosts
no-hosts
# Cache size
cache-size=1000
# Log queries for debugging
log-queries
```
### Commands
```bash
# Restart
systemctl restart dnsmasq
# Status
systemctl status dnsmasq
# Test resolution
dig +short vault.echo6.co @100.64.0.4
```
---
## Port Mappings Summary
| Service | Container Port | Host Binding | Caddy Proxy |
|---------|---------------|--------------|-------------|
| Authentik | 9000 | 127.0.0.1:9000 | auth.echo6.co |
| Forgejo | 3000 | 127.0.0.1:3001 | forge.echo6.co |
| Forgejo SSH | 22 | 0.0.0.0:2222 | Direct |
| Headscale | 8080 | 127.0.0.1:8084 | vpn.echo6.co |
| Headplane | 3000 | 127.0.0.1:3100 | vpn.echo6.co/admin |
| Mailcow | 8443 | 127.0.0.1:8443 | mail.echo6.co |
| Vaultwarden | 80 | 127.0.0.1:8086 | vault.echo6.co |
| Vaultwarden WS | 3012 | 127.0.0.1:3012 | vault.echo6.co/notifications/hub |
---
## DNS Records (GoDaddy → Contabo)
| Subdomain | IP | Service |
|-----------|-----|---------|
| auth | 5.189.158.149 | Authentik |
| forge | 5.189.158.149 | Forgejo |
| mail | 5.189.158.149 | Mailcow |
| vpn | 5.189.158.149 | Headscale |
| vault | 5.189.158.149 | Vaultwarden |
| autodiscover | 5.189.158.149 | Mailcow |
| autoconfig | 5.189.158.149 | Mailcow |
---
## Split DNS Mappings (Tailscale)
| Domain | Tailscale IP | Server |
|--------|-------------|--------|
| auth.echo6.co | 100.64.0.4 | Contabo |
| forge.echo6.co | 100.64.0.4 | Contabo |
| mail.echo6.co | 100.64.0.4 | Contabo |
| vpn.echo6.co | 100.64.0.4 | Contabo |
| vault.echo6.co | 100.64.0.4 | Contabo |
| docs.echo6.co | 100.64.0.4 | Contabo |
| stream.echo6.co | 100.64.0.7 | PeerTube |
| notes.echo6.co | 100.64.0.22 | Cloud |
---
*Last updated: 2026-02-05*

259
runbooks/ct-runbook.md Normal file
View file

@ -0,0 +1,259 @@
# Proxmox CT/LXC Provisioning Runbook
Every container gets the same baseline: local user, Tailscale, SSH, Docker, and common tools. No exceptions.
---
## Prerequisites
- Proxmox VE host with Ubuntu 24.04 LXC template downloaded
- Tailscale auth key (reusable, from https://login.tailscale.com/admin/settings/keys)
- SSH access to Proxmox host
If you don't have the template cached yet:
```bash
pveam update
pveam download local system ubuntu-24.04-standard_24.04-2_amd64.tar.zst
```
---
## 1. Create the Container
Pick the next available CTID. Adjust `--memory`, `--cores`, and `--rootfs` to fit the workload.
```bash
# Variables — edit these per container
CTID=110
HOSTNAME="mycontainer"
STORAGE="local-lvm" # or zfs-pool, ceph, etc.
DISK_SIZE=8 # GB
MEMORY=2048 # MB
CORES=2
BRIDGE="vmbr0"
pct create $CTID local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \
--hostname $HOSTNAME \
--storage $STORAGE \
--rootfs ${STORAGE}:${DISK_SIZE} \
--memory $MEMORY \
--cores $CORES \
--net0 name=eth0,bridge=${BRIDGE},ip=dhcp \
--unprivileged 1 \
--features nesting=1,keyctl=1 \
--onboot 1 \
--start 1
```
`nesting=1` is required for Docker. `keyctl=1` prevents keyring errors in systemd containers.
Wait a few seconds for the container to boot, then enter it:
```bash
pct enter $CTID
```
Everything from here on runs **inside the container**.
---
## 2. Base System Update
```bash
apt update && apt upgrade -y
```
---
## 3. Common Tools
```bash
apt install -y \
curl \
wget \
vim \
htop \
git \
unzip \
jq \
net-tools \
dnsutils \
ca-certificates \
gnupg \
lsb-release \
sudo \
sshpass
```
---
## 4. Create User
```bash
useradd -m -s /bin/bash -G sudo zvx
echo "zvx:7redditGold" | chpasswd
```
Verify:
```bash
su - zvx -c "whoami && sudo -l"
```
---
## 5. SSH Configuration
SSH should already be running in the Ubuntu 24.04 template, but make sure password auth is enabled for sshpass workflows:
```bash
# Ensure SSH is installed and running
apt install -y openssh-server
systemctl enable --now ssh
# Allow password auth (needed for sshpass)
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart ssh
```
Test from the Proxmox host (exit the container first):
```bash
CT_IP=$(pct exec $CTID -- hostname -I | awk '{print $1}')
sshpass -p '7redditGold' ssh -o StrictHostKeyChecking=accept-new zvx@$CT_IP "echo 'SSH OK'"
```
---
## 6. Install Docker
```bash
# Add Docker's official GPG key and repo
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Add zvx to docker group (no sudo needed for docker commands)
usermod -aG docker zvx
# Verify
docker run --rm hello-world
```
If Docker fails to start with an AppArmor or permissions error, confirm `nesting=1` is set on the container (Step 1). You can check/fix from the Proxmox host:
```bash
pct set $CTID --features nesting=1,keyctl=1
pct reboot $CTID
```
---
## 7. Install Tailscale
```bash
curl -fsSL https://tailscale.com/install.sh | sh
```
Bring it up with your auth key:
```bash
# Replace with your actual auth key
tailscale up --authkey=tskey-auth-XXXXXXXXXXXX --ssh
```
If you don't have an auth key handy, run without `--authkey` and it will print a URL to authenticate in a browser:
```bash
tailscale up --ssh
```
Verify:
```bash
tailscale status
tailscale ip -4
```
The `--ssh` flag enables Tailscale SSH, which lets you SSH into the container over Tailscale without managing keys. The container will appear in your tailnet by its hostname.
---
## 8. Verification Checklist
Run this from inside the container to confirm everything:
```bash
echo "=== CT Provisioning Check ==="
echo ""
echo "Hostname: $(hostname)"
echo "User zvx: $(id zvx 2>/dev/null && echo 'OK' || echo 'MISSING')"
echo "sudo: $(sudo -l -U zvx 2>/dev/null | grep -q ALL && echo 'OK' || echo 'MISSING')"
echo "sshpass: $(which sshpass >/dev/null 2>&1 && echo 'OK' || echo 'MISSING')"
echo "SSH: $(systemctl is-active ssh)"
echo "Docker: $(docker --version 2>/dev/null || echo 'MISSING')"
echo "Tailscale: $(tailscale status --self 2>/dev/null | head -1 || echo 'NOT CONNECTED')"
echo "Tailscale IP: $(tailscale ip -4 2>/dev/null || echo 'N/A')"
```
Expected output — everything should say OK/active with a Tailscale IP:
```
=== CT Provisioning Check ===
Hostname: mycontainer
User zvx: uid=1000(zvx) gid=1000(zvx) groups=1000(zvx),27(sudo),998(docker) OK
sudo: OK
sshpass: OK
SSH: active
Docker: Docker version 27.x.x, build xxxxxxx
Tailscale: 100.x.x.x mycontainer tagged-devices linux -
Tailscale IP: 100.x.x.x
```
---
## Quick Reference (Copy/Paste Block)
For the impatient — the whole thing end to end after `pct enter`:
```bash
# Update + tools
apt update && apt upgrade -y
apt install -y curl wget vim htop git unzip jq net-tools dnsutils \
ca-certificates gnupg lsb-release sudo sshpass openssh-server
# User
useradd -m -s /bin/bash -G sudo zvx
echo "zvx:7redditGold" | chpasswd
# SSH
systemctl enable --now ssh
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart ssh
# Docker
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
usermod -aG docker zvx
# Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --ssh
```

View file

@ -0,0 +1,333 @@
# GPU/CPU Fallback Routing
Route workloads to GPU or CPU based on pre-flight inspection of job properties (duration, file size, resolution, complexity). Small jobs go to GPU for speed; large jobs fall back to CPU to avoid VRAM exhaustion. Concurrent job control via flock prevents OOM kills — excess jobs fail fast and re-queue instead of competing for memory.
Use this when you have a GPU workload where some jobs exceed VRAM capacity, and the system needs to handle both small and large jobs without manual intervention or OOM kills.
---
## Prerequisites
- NVIDIA GPU with working drivers (`nvidia-smi` returns output)
- Both GPU and CPU execution paths available for the workload
- A probe tool to inspect job properties before execution (e.g., `ffprobe`, `mediainfo`, `file`, `wc`)
- A caller that retries on non-zero exit codes (scheduler, job queue, runner)
---
## Inputs
Prompt the user for all of these before executing:
```
TARGET_HOST= # Machine with GPU (e.g., cortex)
WORKLOAD_BINARY= # The tool that processes jobs (e.g., "whisper-ctranslate2-real")
PROBE_TOOL= # Tool to inspect job properties (e.g., "ffprobe", "mediainfo")
GPU_VRAM_MB= # Total VRAM available (e.g., 16384 for 16GB)
WORKLOAD_VRAM_MB= # VRAM used per GPU job (e.g., 3700)
WORKLOAD_RAM_MB= # RAM used per CPU job (e.g., 11000)
THRESHOLD_VALUE= # Cutoff for GPU vs CPU routing (e.g., 3600 for seconds)
THRESHOLD_UNIT= # What the threshold measures (e.g., "seconds", "bytes", "pixels")
MAX_GPU_JOBS= # Max concurrent GPU jobs (e.g., 2)
MAX_CPU_JOBS= # Max concurrent CPU jobs (e.g., 1)
GPU_ARGS= # Arguments for GPU execution (e.g., "--device cuda --compute_type float16")
CPU_ARGS= # Arguments for CPU execution (e.g., "--device cpu --compute_type int8")
```
---
## Step 1: Determine the Routing Threshold
Profile representative workloads to find the VRAM crossover point.
```bash
# Run a small job on GPU, monitor VRAM
ssh $TARGET_HOST "nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits"
# Run the workload...
# Check peak VRAM during execution
# Run a large job on GPU, watch for OOM
# If it OOM-kills or exceeds VRAM, that's your upper bound
```
The threshold should be set conservatively below the point where GPU jobs start failing. Common strategies:
| Workload Type | Probe Property | Typical Threshold |
|---------------|----------------|-------------------|
| Audio transcription | Duration (seconds) | 1-2 hours |
| Image generation | Resolution (megapixels) | Based on model VRAM curve |
| Video encoding | Duration × resolution | Derived from VRAM budget |
| LLM inference | Token count / context length | Model-specific |
### Gate
You must have a clear, measurable property that predicts VRAM usage. If the relationship between job properties and VRAM is unpredictable, this pattern won't work — use a different strategy (e.g., try GPU first, fall back on OOM).
---
## Step 2: Write the Probe Function
The probe function inspects the job input and returns the routing metric.
```bash
# Generic probe template
probe_workload() {
local INPUT="$1"
local METRIC=0
if [[ -n "$INPUT" && -f "$INPUT" ]]; then
# Example: audio/video duration via ffprobe
METRIC=$($PROBE_TOOL -v quiet -show_entries format=duration \
-of csv=p=0 "$INPUT" 2>/dev/null | cut -d. -f1)
METRIC=${METRIC:-0}
# Example: file size in bytes
# METRIC=$(stat -c%s "$INPUT" 2>/dev/null)
# Example: image resolution (width × height)
# METRIC=$($PROBE_TOOL -v quiet -show_entries stream=width,height \
# -of csv=p=0 "$INPUT" 2>/dev/null | awk -F, '{print $1*$2}')
fi
echo "$METRIC"
}
```
### Gate
Test the probe against known inputs:
```bash
# Small workload (should route to GPU)
probe_workload /path/to/small/input # Should be < THRESHOLD_VALUE
# Large workload (should route to CPU)
probe_workload /path/to/large/input # Should be >= THRESHOLD_VALUE
```
---
## Step 3: Implement the Router
The router uses the probe result to select GPU or CPU execution path.
```bash
#!/bin/bash
# GPU/CPU Fallback Router
# Routes jobs based on $THRESHOLD_UNIT inspection
THRESHOLD=$THRESHOLD_VALUE
LOGFILE="/tmp/workload-router.log"
GPU_LOCK="/tmp/gpu-workload.lock"
CPU_LOCK="/tmp/cpu-workload.lock"
# ──── Probe ────
INPUT="<extract from $@>"
METRIC=$(probe_workload "$INPUT")
# ──── Route ────
if (( METRIC < THRESHOLD )); then
MODE="GPU"
DEVICE_ARGS="$GPU_ARGS"
LOCK_FILE="$GPU_LOCK"
MAX_CONCURRENT=$MAX_GPU_JOBS
else
MODE="CPU"
DEVICE_ARGS="$CPU_ARGS"
LOCK_FILE="$CPU_LOCK"
MAX_CONCURRENT=$MAX_CPU_JOBS
fi
# ──── Concurrency control ────
if (( MAX_CONCURRENT == 1 )); then
# Single-job lock: flock with fail-fast
exec 9>"$LOCK_FILE"
if ! flock --nonblock 9; then
echo "[ROUTER] $(date) mode=${MODE}-BLOCKED metric=${METRIC} (slot full, exiting)" >> "$LOGFILE"
exit 1 # Caller should retry later
fi
fi
# For MAX_CONCURRENT > 1, use numbered lock files:
# for i in $(seq 0 $((MAX_CONCURRENT - 1))); do
# SLOT_LOCK="${LOCK_FILE}.${i}"
# exec 9>"$SLOT_LOCK"
# if flock --nonblock 9; then
# break # Got a slot
# fi
# if (( i == MAX_CONCURRENT - 1 )); then
# echo "[ROUTER] $(date) mode=${MODE}-BLOCKED metric=${METRIC} (all slots full)" >> "$LOGFILE"
# exit 1
# fi
# done
# ──── Log and execute ────
echo "[ROUTER] $(date) mode=$MODE metric=${METRIC} args: $@" >> "$LOGFILE"
exec $WORKLOAD_BINARY "$@" $DEVICE_ARGS
```
### Key design decisions
- **`flock --nonblock`**: Non-blocking lock attempt. If the slot is taken, exit immediately instead of waiting. This prevents queue starvation where all runner slots are blocked waiting for CPU jobs.
- **Exit code 1**: The caller (runner, scheduler) should interpret this as "retry later." Most job queues do this by default.
- **`exec`**: Replace the router process with the workload binary. Signals, exit codes, and resource limits pass through cleanly.
- **Lock files in `/tmp`**: Automatically cleaned on reboot. No stale locks after crashes.
---
## Step 4: Integrate with the Caller
Deploy the router using the binary wrapper interception pattern (see `binary-wrapper-interception.md`):
1. Rename the real binary: `mv $BINARY → ${BINARY}-real`
2. Write the router script
3. Symlink: `ln -sf /path/to/router $BINARY`
Or, if the caller supports configurable command paths, point it directly at the router.
---
## Step 5: Verify Both Paths
### GPU path
```bash
# Submit a small job
ssh $TARGET_HOST "$BINARY <small-input-args>"
# Verify GPU usage
ssh $TARGET_HOST "nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv,noheader"
# Check log
ssh $TARGET_HOST "tail -1 /tmp/workload-router.log"
# Should show: mode=GPU
```
### CPU path
```bash
# Submit a large job
ssh $TARGET_HOST "$BINARY <large-input-args>"
# Verify CPU usage (no GPU spike)
ssh $TARGET_HOST "nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv,noheader"
# GPU should be idle
# Check RAM
ssh $TARGET_HOST "free -h"
# Check log
ssh $TARGET_HOST "tail -1 /tmp/workload-router.log"
# Should show: mode=CPU
```
### Concurrency control
```bash
# Start a CPU job, then immediately try a second one
ssh $TARGET_HOST "$BINARY <large-input-1> &"
sleep 2
ssh $TARGET_HOST "$BINARY <large-input-2>"
# Second job should exit immediately with code 1
# Check log
ssh $TARGET_HOST "grep BLOCKED /tmp/workload-router.log"
# Should show: mode=CPU-BLOCKED
```
---
## Step 6: Tune and Monitor
After initial deployment, monitor for a day and adjust:
```bash
# Distribution of GPU vs CPU jobs
ssh $TARGET_HOST "grep -c 'mode=GPU' /tmp/workload-router.log"
ssh $TARGET_HOST "grep -c 'mode=CPU' /tmp/workload-router.log"
ssh $TARGET_HOST "grep -c 'BLOCKED' /tmp/workload-router.log"
```
If BLOCKED count is high relative to CPU count, the threshold may be too aggressive (routing too many jobs to CPU). Consider raising the threshold or increasing MAX_CPU_JOBS if RAM allows.
---
## Memory Budget Worksheet
```
GPU path:
VRAM per job: $WORKLOAD_VRAM_MB MB
Max GPU jobs: $MAX_GPU_JOBS
Total GPU VRAM: $GPU_VRAM_MB MB
Headroom: GPU_VRAM_MB - (WORKLOAD_VRAM_MB × MAX_GPU_JOBS) MB
→ Headroom must be positive
CPU path:
RAM per job: $WORKLOAD_RAM_MB MB
Max CPU jobs: $MAX_CPU_JOBS
System RAM: $(free -m | awk '/Mem:/{print $2}') MB
Other processes: ~2-4 GB (OS, services, buffers)
Headroom: SystemRAM - OtherProcs - (WORKLOAD_RAM_MB × MAX_CPU_JOBS) MB
→ Headroom must be positive
systemd MemoryMax: Should be set to MAX(GPU peak, CPU peak) + 20% buffer
```
---
## Troubleshooting
### GPU job OOM-kills despite being under threshold
The threshold is too high, or VRAM usage varies by input characteristics beyond what the probe measures. Lower the threshold or add a secondary probe (e.g., check resolution in addition to duration).
### CPU jobs pile up and exhaust RAM
`MAX_CPU_JOBS` is too high, or the `flock` mechanism isn't working. Check that lock files are being created in `/tmp/` and that the `exec 9>` file descriptor redirect is correct.
### All jobs route to CPU
The probe is returning 0 or failing silently. Test the probe manually:
```bash
$PROBE_TOOL -v quiet -show_entries format=duration -of csv=p=0 /path/to/input
```
If it returns empty, the input file may not be accessible to the probe tool (permissions, path issues).
### Blocked jobs never get retried
The caller doesn't retry on exit code 1. Check the caller's retry behavior. Some systems need specific exit codes (e.g., 75 for "temporary failure" in some mail systems). Adjust the exit code in the router to match what the caller expects.
### Lock files persist after crash
`/tmp` is cleaned on reboot, so stale locks self-heal. For immediate cleanup: `rm /tmp/cpu-workload.lock`. The next job will recreate it.
---
## Usage Examples
### Whisper auto-captioning on PeerTube runner (cortex)
```
WORKLOAD_BINARY=/usr/local/bin/whisper-ctranslate2-real
PROBE_TOOL=ffprobe
GPU_VRAM_MB=16384 # RTX A4000
WORKLOAD_VRAM_MB=3700 # Whisper medium on float16
WORKLOAD_RAM_MB=11000 # Whisper medium on CPU int8 (peak for 9.5hr video)
THRESHOLD_VALUE=3600 # 1 hour in seconds
THRESHOLD_UNIT=seconds
MAX_GPU_JOBS=2 # Runner concurrency=2, but both can be GPU
MAX_CPU_JOBS=1 # Only 1 CPU job at a time (11GB peak, 20G MemoryMax)
GPU_ARGS="--device cuda --compute_type float16"
CPU_ARGS="--device cpu --compute_type int8"
Result: 4100+ videos captioned. ~20 videos over 1 hour routed to CPU.
GPU jobs: ~3.7GB VRAM, 88-99% GPU utilization
CPU jobs: ~8-11GB RAM, serialized via flock
MemoryMax=20G on the runner service as safety net.
```
---
*Last updated: 2026-02-17*

View file

@ -1,406 +0,0 @@
# Headscale Full Deployment Runbook
## Nodes + Headplane + Authentik OIDC
**Headscale location:** `/opt/headscale-vanilla`
**Container name:** `headscale-vanilla`
**Domain:** `vpn.echo6.co`
**Auth key:** `hskey-auth-LOd5lzxvsHaP-GP9K6QkG6UW60UFeoDbKv5OxR9yJXupFvfy-Ps_SGmYu5QxG5g-I7JsVDEebZpVJ`
---
## PHASE 1: REGISTER CONTABO (must be first)
```bash
tailscale up --login-server https://vpn.echo6.co \
--auth-key hskey-auth-LOd5lzxvsHaP-GP9K6QkG6UW60UFeoDbKv5OxR9yJXupFvfy-Ps_SGmYu5QxG5g-I7JsVDEebZpVJ \
--hostname contabo --force-reauth
```
Verify:
```bash
docker exec headscale-vanilla headscale nodes list
```
**STOP if contabo doesn't appear. Do not continue.**
---
## PHASE 2: REGISTER ALL LXC/CT NODES
SSH into each container. For each one:
```bash
# Check if tailscale is installed
which tailscale || echo "NOT INSTALLED"
# Install if missing
curl -fsSL https://tailscale.com/install.sh | sh
```
Then register. **Do them in this exact order for sequential IPs:**
```bash
# utility (will get 100.64.0.2)
tailscale up --login-server https://vpn.echo6.co \
--auth-key hskey-auth-LOd5lzxvsHaP-GP9K6QkG6UW60UFeoDbKv5OxR9yJXupFvfy-Ps_SGmYu5QxG5g-I7JsVDEebZpVJ \
--hostname utility --force-reauth
# data (will get 100.64.0.3)
tailscale up --login-server https://vpn.echo6.co \
--auth-key hskey-auth-LOd5lzxvsHaP-GP9K6QkG6UW60UFeoDbKv5OxR9yJXupFvfy-Ps_SGmYu5QxG5g-I7JsVDEebZpVJ \
--hostname data --force-reauth
# cloud (will get 100.64.0.4)
tailscale up --login-server https://vpn.echo6.co \
--auth-key hskey-auth-LOd5lzxvsHaP-GP9K6QkG6UW60UFeoDbKv5OxR9yJXupFvfy-Ps_SGmYu5QxG5g-I7JsVDEebZpVJ \
--hostname cloud --force-reauth
# media (will get 100.64.0.5)
tailscale up --login-server https://vpn.echo6.co \
--auth-key hskey-auth-LOd5lzxvsHaP-GP9K6QkG6UW60UFeoDbKv5OxR9yJXupFvfy-Ps_SGmYu5QxG5g-I7JsVDEebZpVJ \
--hostname media --force-reauth
# aida-nebra (will get 100.64.0.6)
tailscale up --login-server https://vpn.echo6.co \
--auth-key hskey-auth-LOd5lzxvsHaP-GP9K6QkG6UW60UFeoDbKv5OxR9yJXupFvfy-Ps_SGmYu5QxG5g-I7JsVDEebZpVJ \
--hostname aida-nebra --force-reauth
```
After each, verify from Contabo:
```bash
docker exec headscale-vanilla headscale nodes list
```
---
## PHASE 3: REGISTER DESKTOP + PHONES
**Desktop (Windows — PowerShell as Admin):**
```powershell
tailscale up --login-server https://vpn.echo6.co `
--auth-key hskey-auth-LOd5lzxvsHaP-GP9K6QkG6UW60UFeoDbKv5OxR9yJXupFvfy-Ps_SGmYu5QxG5g-I7JsVDEebZpVJ `
--hostname desktop --force-reauth
```
**Phones:**
- Open Tailscale app → Settings → Account
- Log out if needed
- Use "Custom coordination server" or "Alternate server"
- Enter: `https://vpn.echo6.co`
- Should auto-register with the tailnet
If the app doesn't support custom servers natively, you may need the F-Droid build on Android or the CLI on a jailbroken iOS device.
---
## PHASE 4: VERIFY ALL NODES + TEST CONNECTIVITY
```bash
docker exec headscale-vanilla headscale nodes list
```
Expected output: all nodes with sequential 100.64.0.x IPs.
Test from any node:
```bash
tailscale ping contabo
tailscale ping data
tailscale ping utility
```
Test magic DNS:
```bash
ping data.echo6.mesh
ping utility.echo6.mesh
```
---
## PHASE 5: BACKUP THE DATABASE (do this NOW before anything else)
```bash
mkdir -p /opt/headscale-vanilla/backups
# Immediate backup
sqlite3 /opt/headscale-vanilla/data/db.sqlite \
".backup '/opt/headscale-vanilla/backups/db-$(date +%Y%m%d-%H%M).sqlite'"
# Set up cron for automatic backups every 6 hours, 7-day retention
crontab -e
# Add this line:
0 */6 * * * sqlite3 /opt/headscale-vanilla/data/db.sqlite ".backup '/opt/headscale-vanilla/backups/db-$(date +\%Y\%m\%d-\%H\%M).sqlite'" && find /opt/headscale-vanilla/backups -name "db-*.sqlite" -mtime +7 -delete
```
---
## PHASE 6: PERSISTENCE TEST
```bash
cd /opt/headscale-vanilla
docker compose down
sleep 5
ls -la /opt/headscale-vanilla/data/db.sqlite*
docker compose up -d
sleep 10
docker exec headscale-vanilla headscale nodes list
```
**Every node must survive. If any are missing, STOP and report.**
---
## PHASE 7: CREATE AUTHENTIK OIDC PROVIDER FOR HEADSCALE
This lets Tailscale clients authenticate via Authentik instead of preauth keys.
1. Log into Authentik admin panel
2. Go to **Applications → Applications → Create with Provider**
3. Configure:
- **Application name:** Headscale
- **Slug:** `headscale` (remember this — it's part of the issuer URL)
- **Provider type:** OAuth2/OpenID Connect
- **Authorization flow:** default-provider-authorization-implicit-consent (or explicit if you want)
- **Redirect URI (Strict):** `https://vpn.echo6.co/oidc/callback`
- **Signing key:** Select any available key
- **Scopes:** Ensure these scope mappings are selected:
- `openid`
- `profile`
- `email`
- **`offline_access`** ← CRITICAL — without this, nodes break on Headscale restart
4. Note the **Client ID** and **Client Secret**
5. Click Submit
---
## PHASE 8: CONFIGURE HEADSCALE OIDC
Edit `/opt/headscale-vanilla/config.yaml` — add this OIDC block:
```yaml
oidc:
only_start_if_oidc_is_available: true
issuer: "https://<YOUR_AUTHENTIK_DOMAIN>/application/o/headscale/"
client_id: "<Client ID from Authentik>"
client_secret: "<Client Secret from Authentik>"
scope: ["openid", "profile", "email", "offline_access"]
pkce:
enabled: true
method: S256
strip_email_domain: true
```
Replace:
- `<YOUR_AUTHENTIK_DOMAIN>` with your Authentik domain (e.g., `auth.echo6.co`)
- `<Client ID from Authentik>` with the actual client ID
- `<Client Secret from Authentik>` with the actual client secret
Restart Headscale:
```bash
cd /opt/headscale-vanilla
docker compose restart
sleep 10
docker logs headscale-vanilla 2>&1 | tail -20
```
**Check logs for OIDC errors. If it fails to start, remove the OIDC block and restart.**
Test: From any node, run:
```bash
tailscale up --login-server https://vpn.echo6.co --force-reauth
```
It should open a browser → Authentik login → back to terminal, authenticated.
**Your existing preauth-key nodes still work. OIDC is for NEW registrations and re-auths.**
---
## PHASE 9: CREATE AUTHENTIK OIDC PROVIDER FOR HEADPLANE
This is a SECOND application in Authentik for the web UI login.
1. Go to **Applications → Applications → Create with Provider**
2. Configure:
- **Application name:** Headplane
- **Slug:** `headplane`
- **Provider type:** OAuth2/OpenID Connect
- **Authorization flow:** Same as before
- **Redirect URI (Strict):** `https://vpn.echo6.co/admin/oidc/callback`
- **Signing key:** Same key
- **Scopes:** `openid`, `profile`, `email`
3. Note the **Client ID** and **Client Secret** (different from Headscale's)
4. Click Submit
---
## PHASE 10: GENERATE HEADSCALE API KEY FOR HEADPLANE
```bash
docker exec headscale-vanilla headscale apikeys create --expiration 999d
```
**Save this key — you need it for the Headplane config.**
---
## PHASE 11: CREATE HEADPLANE CONFIG
```bash
# Generate a cookie secret
openssl rand -hex 16
```
Write `/opt/headscale-vanilla/headplane-config.yaml`:
```yaml
server:
host: "0.0.0.0"
port: 3000
cookie_secret: "<OUTPUT_OF_OPENSSL_RAND_HEX_16>"
cookie_secure: true
data_path: "/var/lib/headplane"
headscale:
url: "http://headscale-vanilla:8080"
config_path: "/etc/headscale/config.yaml"
config_strict: false
oidc:
issuer: "https://<YOUR_AUTHENTIK_DOMAIN>/application/o/headplane/"
client_id: "<Headplane Client ID from Authentik>"
client_secret: "<Headplane Client Secret from Authentik>"
token_endpoint_auth_method: "client_secret_post"
headscale_api_key: "<API_KEY_FROM_PHASE_10>"
redirect_uri: "https://vpn.echo6.co/admin/oidc/callback"
disable_api_key_login: false
integration:
docker:
enabled: true
container_name: "headscale-vanilla"
socket: "/var/run/docker.sock"
```
Replace all `<PLACEHOLDERS>` with actual values.
---
## PHASE 12: ADD HEADPLANE TO DOCKER COMPOSE
Edit `/opt/headscale-vanilla/docker-compose.yml` — add the headplane service:
```yaml
services:
headscale:
# ... your existing headscale service, don't change it ...
headplane:
image: ghcr.io/tale/headplane:latest
container_name: headplane
restart: unless-stopped
depends_on:
- headscale
ports:
- "127.0.0.1:3000:3000"
volumes:
- ./headplane-config.yaml:/etc/headplane/config.yaml:ro
- ./headplane-data:/var/lib/headplane
- ./config.yaml:/etc/headscale/config.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
```
Start it:
```bash
cd /opt/headscale-vanilla
docker compose up -d
sleep 10
docker logs headplane 2>&1 | tail -20
```
Check for errors. Common issues:
- "OIDC configuration is incomplete" → double-check all OIDC values in headplane-config.yaml
- Can't connect to headscale → ensure `url` matches the container name and internal port
- Docker socket permission denied → check that the headplane container can read /var/run/docker.sock
---
## PHASE 13: UPDATE CADDY FOR HEADPLANE
Add the `/admin` route to your Caddy config for `vpn.echo6.co`:
```
vpn.echo6.co {
handle /admin* {
reverse_proxy 127.0.0.1:3000
}
handle {
reverse_proxy 127.0.0.1:8084
}
}
```
Restart Caddy:
```bash
# Wherever your Caddy lives — adjust path as needed
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
```
---
## PHASE 14: TEST HEADPLANE
1. Browse to `https://vpn.echo6.co/admin`
2. You should see the Headplane login page
3. Click "Sign in with OIDC" → redirects to Authentik → authenticate
4. **The FIRST user to log in gets Owner permissions**
5. Verify you can see all your nodes in the UI
If OIDC fails, you can still log in with the API key (that's why we set `disable_api_key_login: false`).
---
## PHASE 15: FINAL VERIFICATION
Run all of these from Contabo:
```bash
# All nodes present?
docker exec headscale-vanilla headscale nodes list
# Both containers healthy?
docker ps --format "table {{.Names}}\t{{.Status}}"
# Headplane accessible?
curl -s -o /dev/null -w "%{http_code}" https://vpn.echo6.co/admin
# Should return 200 or 302
# Database backed up?
ls -la /opt/headscale-vanilla/backups/
# Cron running?
crontab -l | grep sqlite3
```
---
## REPORT TEMPLATE
After each phase, report:
```
Phase X complete:
- Output of headscale nodes list:
- Any errors:
- Logs (last 10 lines):
```
**Do NOT skip phases. Do NOT combine phases. If something fails, stop and report.**
---
## KNOWN GOTCHAS
1. **offline_access scope** — If you forget this in Authentik, nodes lose auth after Headscale restarts
2. **config_strict: false** — Headscale 0.28.0 has config options Headplane may not recognize
3. **Headplane needs Docker socket** — For the integration that lets it restart Headscale when you change settings
4. **First OIDC login = Owner** — Don't let random people hit your Headplane URL before you log in first
5. **Phones may not support custom servers** — Android F-Droid build is more flexible; iOS is limited
6. **Two separate OIDC apps** — Headscale and Headplane each need their own application in Authentik with different redirect URIs

View file

@ -0,0 +1,300 @@
# Internet Archive CLI Reference
Quick reference for the `ia` command-line tool on pi-nas.
---
## Location & Setup
| Detail | Value |
|--------|-------|
| Host | pi-nas (192.168.1.245 / 100.64.0.21) |
| Binary | `ia` (v5.7.2, pip-installed) |
| Config | `~/.config/internetarchive/ia.ini` |
---
## 1. Configure / Authenticate
Required for uploads, metadata edits, and accessing restricted items. Not required for public downloads or searches.
```bash
ia configure
# Prompts for archive.org email + password
# Stores credentials in ~/.config/internetarchive/ia.ini
```
Verify:
```bash
ia configure --help # Should show options without errors
```
---
## 2. Search
Search the archive.org catalog. Returns JSON by default.
### Basic syntax
```bash
ia search '<query>'
```
### Query syntax
Queries use Lucene syntax. Combine fields with AND/OR, quote phrases.
| Field | Example | Notes |
|-------|---------|-------|
| `collection` | `collection:prelinger` | Items in a specific collection |
| `subject` | `subject:"ham radio"` | Subject/tag match |
| `mediatype` | `mediatype:texts` | texts, movies, audio, software, image, data, web, collection |
| `creator` | `creator:"ARRL"` | Author/creator |
| `title` | `title:"emergency"` | Item title |
| `date` | `date:[2020-01-01 TO 2024-12-31]` | Date range (YYYY-MM-DD) |
| `year` | `year:2023` | Shorthand for year |
| `language` | `language:eng` | ISO language code |
| `licenseurl` | `licenseurl:*creativecommons*` | License filter |
### Combined queries
```bash
# PDFs about ham radio published after 2020
ia search 'subject:"ham radio" mediatype:texts date:[2020-01-01 TO 2099-12-31]'
# All items in a specific collection
ia search 'collection:prelinger'
# Creator + mediatype
ia search 'creator:"ARRL" AND mediatype:texts'
```
### Output options
```bash
# Default: JSON objects, one per line
ia search 'collection:prelinger'
# Itemlist mode — outputs only identifiers, one per line
# Pipe this to ia download --itemlist
ia search 'collection:prelinger' --itemlist
# Save itemlist to file
ia search 'collection:prelinger' --itemlist > prelinger-items.txt
# Limit results with parameters
ia search 'subject:radio' --parameters='rows=50'
# Count results without downloading them all
ia search 'collection:prelinger' --num-found
```
### Practical examples
```bash
# Find all items in a collection and count them
ia search 'collection:arrl_qst' --num-found
# Get identifiers for bulk download
ia search 'collection:arrl_qst' --itemlist > arrl-items.txt
# Search within a collection for specific subjects
ia search 'collection:prelinger subject:"san francisco"' --itemlist
# Find audio recordings by a specific creator
ia search 'creator:"Grateful Dead" mediatype:audio' --itemlist
# Search for items with specific file formats available
ia search 'collection:librivoxaudio format:"64Kbps MP3"' --itemlist
```
---
## 3. List Item Contents
View files within an item without downloading.
```bash
# List all files in an item
ia list <identifier>
# Example
ia list prelinger_films
```
Output shows filenames, sizes, and formats.
---
## 4. Metadata
View and modify item metadata.
### Read metadata
```bash
# Full metadata as JSON
ia metadata <identifier>
# Pretty-print with jq
ia metadata <identifier> | jq .
# Get specific fields
ia metadata <identifier> | jq '.metadata.title'
ia metadata <identifier> | jq '.metadata.subject'
ia metadata <identifier> | jq '.metadata.collection'
# List available formats for an item
ia metadata <identifier> --formats
```
### Modify metadata (requires authentication)
```bash
# Set a field
ia metadata <identifier> --modify="description:Updated description"
# Remove a field
ia metadata <identifier> --modify="subject:REMOVE_TAG"
# Append to existing value
ia metadata <identifier> --append="subject:new-tag"
# Add to array field
ia metadata <identifier> --append-list="collection:another-collection"
# Bulk modify from CSV (must have 'identifier' column)
ia metadata --spreadsheet=metadata.csv
```
---
## 5. Upload (requires authentication)
```bash
# Upload files to a new or existing item
ia upload <identifier> file1.pdf file2.pdf \
--metadata="mediatype:texts" \
--metadata="title:My Upload" \
--metadata="subject:test"
# Upload from stdin
curl -sL https://example.com/file.pdf | \
ia upload <identifier> - --remote-name=file.pdf
# Retry on failure
ia upload <identifier> largefile.zip --retries 10
# Bulk upload from CSV (requires 'identifier' and 'file' columns)
ia upload --spreadsheet=uploads.csv
```
**Important:** `mediatype` cannot be changed after initial upload.
---
## 6. Delete (requires authentication)
```bash
# Delete a specific file
ia delete <identifier> filename.pdf
# Delete file and all its derivatives
ia delete <identifier> filename.pdf --cascade
# Delete all files in an item
ia delete <identifier> --all
```
Deleted files are backed up to `history/files/` automatically.
---
## 7. Copy / Move
```bash
# Copy a file between items
ia copy source-item/file.pdf dest-item/file.pdf
# Copy with metadata for new items
ia copy source/file.pdf new-item/file.pdf --metadata="title:Copied Item"
# Move (copy + delete source)
ia move source-item/file.pdf dest-item/file.pdf
```
---
## 8. Tasks
View catalog processing tasks (derive jobs, uploads in progress, etc.).
```bash
# Tasks for a specific item
ia tasks <identifier>
# All your queued/running tasks
ia tasks
```
---
## Command Quick Reference
| Command | Alias | Purpose |
|---------|-------|---------|
| `ia configure` | `ia co` | Set up credentials |
| `ia search` | `ia se` | Search catalog |
| `ia download` | `ia do` | Download files |
| `ia list` | `ia ls` | List item files |
| `ia metadata` | `ia md` | View/edit metadata |
| `ia upload` | `ia up` | Upload files |
| `ia delete` | `ia rm` | Delete files |
| `ia copy` | `ia cp` | Copy between items |
| `ia move` | `ia mv` | Move between items |
| `ia tasks` | `ia ta` | View task queue |
---
## Global Flags
| Flag | Short | Purpose |
|------|-------|---------|
| `--help` | `-h` | Show help |
| `--version` | `-v` | Show version |
| `--config-file FILE` | `-c` | Use alternate config |
| `--log` | `-l` | Enable logging |
| `--debug` | `-d` | Verbose debug output |
| `--insecure` | `-i` | Use HTTP instead of HTTPS |
---
## Troubleshooting
### "You need to be logged in"
Run `ia configure` and enter your archive.org credentials. Verify with:
```bash
cat ~/.config/internetarchive/ia.ini
```
### Search returns no results
- Check query syntax — field names are case-sensitive
- Use quotes around multi-word values: `subject:"ham radio"` not `subject:ham radio`
- Verify the collection/identifier exists: `ia metadata <identifier>`
### Slow searches
Large collections can take minutes to enumerate. Use `--parameters='rows=100'` to limit during testing, or `--num-found` to just get the count first.
### Rate limiting
Archive.org may throttle aggressive requests. Space out bulk operations and use `--retries` on downloads.
---
*Last updated: 2026-02-14*

View file

@ -0,0 +1,429 @@
# Download & Mirror from Internet Archive
Procedures for downloading items, filtering by format/pattern, bulk downloading from collections, and mirroring entire collections via the `ia` CLI on pi-nas.
---
## Prerequisites
- `ia` CLI installed on pi-nas (192.168.1.245) — v5.7.2
- Authenticated if downloading restricted items: `ia configure`
- Sufficient storage on pi-nas (check with `df -h`)
- Reference: `ia-cli-reference.md` for search/query syntax
---
## 1. Download a Single Item
An "item" is a logical unit on archive.org identified by its identifier (visible in the URL: `archive.org/details/<identifier>`).
```bash
# Download all files in an item to ./<identifier>/
ia download <identifier>
# Example
ia download prelinger_films
```
The default creates a directory named after the identifier containing all files (originals + derivatives).
### Gate
Verify the download directory exists and has files:
```bash
ls -la <identifier>/
```
---
## 2. Filtered Downloads
### By glob pattern
Download only files matching a shell glob pattern.
```bash
# Only PDFs
ia download <identifier> --glob="*.pdf"
# Only MP4 video files
ia download <identifier> --glob="*.mp4"
# Multiple patterns (pipe-separated)
ia download <identifier> --glob="*.pdf|*.epub"
```
### With exclusions
Exclude patterns require `--glob` to also be set.
```bash
# All MP4s except low-quality variants
ia download <identifier> --glob="*.mp4" --exclude="*512kb*"
# All files except metadata/review XMLs
ia download <identifier> --glob="*" --exclude="*_meta.xml|*_reviews.xml|*_files.xml"
# Multiple exclusions
ia download <identifier> --glob="*.mp4" --exclude="*512kb*|*_thumb*"
```
### By format name
Download files of a specific archive.org format (as shown by `ia metadata --formats`).
```bash
# Check available formats first
ia metadata <identifier> --formats
# Download only a specific format
ia download <identifier> --format="512Kb MPEG4"
ia download <identifier> --format="PDF"
ia download <identifier> --format="EPUB"
```
**Note:** `--format` is incompatible with `--glob` and `--exclude`. Use one approach or the other.
### On-the-fly formats
Some formats (EPUB, MOBI, DAISY, MARCXML) are generated on demand.
```bash
ia download <identifier> --on-the-fly --format="EPUB"
```
---
## 3. Download Options
### Control output location
```bash
# Download to a specific directory
ia download <identifier> --destdir=/mnt/archive/downloads/
# Flatten directory structure (no subdirectory per item)
ia download <identifier> --no-directories
```
### Resume interrupted downloads
```bash
# Resume — skips files that already exist and match checksum
ia download <identifier> --checksum
# Checksum mode compares MD5 hashes — safe to re-run
```
### Preserve timestamps
```bash
# Keep original timestamps from archive.org
ia download <identifier> --no-change-timestamp
```
### Dry run
```bash
# See what would be downloaded without actually downloading
ia download <identifier> --dry-run
```
---
## 4. Bulk Download from Search Results
Pipe search results directly into download. This is the primary method for downloading multiple items.
### Basic pattern
```bash
# Search → itemlist → download
ia search 'collection:prelinger mediatype:movies' --itemlist | \
ia download --itemlist -
# The - tells ia download to read identifiers from stdin
```
### With filters
```bash
# Download only PDFs from all items in a collection
ia search 'collection:arrl_qst' --itemlist | \
ia download --itemlist - --glob="*.pdf"
# Download only MP3s from an audio collection
ia search 'collection:librivoxaudio' --itemlist | \
ia download --itemlist - --glob="*.mp3"
```
### With destination directory
```bash
# Download to a specific location
ia search 'collection:prelinger' --itemlist | \
ia download --itemlist - --destdir=/mnt/archive/prelinger/
```
### Save itemlist for reuse
When a search is large, save the itemlist first so you can resume without re-searching.
```bash
# Step 1: Save itemlist
ia search 'collection:prelinger mediatype:movies' --itemlist > prelinger-items.txt
# Step 2: Check count
wc -l prelinger-items.txt
# Step 3: Download from file
ia download --itemlist prelinger-items.txt --glob="*.mp4"
# Step 4: Resume if interrupted (just re-run with --checksum)
ia download --itemlist prelinger-items.txt --glob="*.mp4" --checksum
```
---
## 5. Bulk Download with GNU Parallel
For faster bulk downloads, use GNU Parallel for concurrent item downloads.
```bash
# Install parallel if not present
sudo apt install -y parallel
# Download 5 items concurrently
ia search 'collection:prelinger' --itemlist | \
parallel -j5 'ia download {} --glob="*.mp4"'
# With destination directory
ia search 'collection:prelinger' --itemlist | \
parallel -j5 'ia download {} --glob="*.mp4" --destdir=/mnt/archive/prelinger/'
# From saved itemlist
parallel -j5 'ia download {} --glob="*.pdf"' < items.txt
```
**Caution:** Be respectful of archive.org bandwidth. 3-5 concurrent downloads is reasonable. Higher parallelism may trigger rate limiting.
---
## 6. Mirror an Entire Collection
Mirroring means downloading everything and being able to re-run to pick up new additions.
### Initial mirror
```bash
# Step 1: Create working directory
mkdir -p /mnt/archive/<collection-name>
cd /mnt/archive/<collection-name>
# Step 2: Generate itemlist
ia search 'collection:<collection-name>' --itemlist > itemlist.txt
echo "Found $(wc -l < itemlist.txt) items"
# Step 3: Download all items (adjust --glob as needed)
ia download --itemlist itemlist.txt --destdir=/mnt/archive/<collection-name>/
# Or with format filter
ia download --itemlist itemlist.txt --glob="*.pdf" --destdir=/mnt/archive/<collection-name>/
```
### Update an existing mirror
Re-run the same commands. Use `--checksum` to skip already-downloaded files.
```bash
cd /mnt/archive/<collection-name>
# Refresh itemlist (new items since last run)
ia search 'collection:<collection-name>' --itemlist > itemlist-new.txt
# Download only new/changed files
ia download --itemlist itemlist-new.txt --checksum --destdir=/mnt/archive/<collection-name>/
```
### Mirror with a script
For recurring mirrors, create a simple script:
```bash
#!/bin/bash
# mirror-collection.sh <collection-name> [glob-pattern]
COLLECTION="$1"
GLOB="${2:-*}"
DEST="/mnt/archive/$COLLECTION"
mkdir -p "$DEST"
echo "Refreshing itemlist for $COLLECTION..."
ia search "collection:$COLLECTION" --itemlist > "$DEST/itemlist.txt"
COUNT=$(wc -l < "$DEST/itemlist.txt")
echo "Found $COUNT items"
echo "Downloading (glob: $GLOB)..."
ia download --itemlist "$DEST/itemlist.txt" --glob="$GLOB" --checksum --destdir="$DEST/"
echo "Mirror complete: $DEST"
```
Usage:
```bash
chmod +x mirror-collection.sh
./mirror-collection.sh arrl_qst "*.pdf"
./mirror-collection.sh prelinger "*.mp4"
```
---
## 7. Practical Patterns
### Download all PDFs from a collection
```bash
ia search 'collection:arrl_qst' --itemlist | \
ia download --itemlist - --glob="*.pdf" --destdir=/mnt/archive/arrl-qst/
```
### Download specific media types from a collection
```bash
# High-quality video only
ia search 'collection:prelinger' --itemlist | \
ia download --itemlist - --format="MPEG4" --destdir=/mnt/archive/prelinger-video/
# Audio in MP3 format
ia search 'collection:librivoxaudio creator:"Mark Twain"' --itemlist | \
ia download --itemlist - --glob="*64kb*.mp3" --destdir=/mnt/archive/twain-audio/
```
### Download items matching a date range
```bash
ia search 'collection:arrl_qst date:[1950-01-01 TO 1959-12-31]' --itemlist | \
ia download --itemlist - --glob="*.pdf" --destdir=/mnt/archive/arrl-1950s/
```
### Download a single specific file from an item
```bash
# List files first
ia list <identifier>
# Download just one file
ia download <identifier> specific-file.pdf
```
### Download and preserve directory structure
```bash
# Default behavior — each item gets its own subdirectory
ia download --itemlist items.txt --destdir=/mnt/archive/output/
# Result: /mnt/archive/output/<identifier1>/files...
# /mnt/archive/output/<identifier2>/files...
```
### Pipe a single file to stdout
```bash
# Stream a file without saving to disk
ia download <identifier> specific-file.pdf --stdout | less
ia download <identifier> data.json --stdout | jq .
```
---
## 8. Storage Planning
Before large downloads, estimate storage requirements.
```bash
# Count items in collection
ia search 'collection:<name>' --num-found
# Check a sample item's size
ia metadata <sample-identifier> | jq '[.files[].size | tonumber] | add / 1048576 | floor'
# Output in MB
# Check available storage on pi-nas
df -h /mnt/
```
### Rule of thumb
- Text collections (PDFs, EPUBs): ~10-100 MB per item
- Audio collections: ~100 MB - 1 GB per item
- Video collections: ~1-10 GB per item
- Software archives: highly variable
---
## Troubleshooting
### Download hangs or stalls
```bash
# Kill and resume with checksum verification
# Ctrl+C to stop, then re-run with --checksum
ia download --itemlist items.txt --glob="*.pdf" --checksum
```
### "Item not found" errors in bulk download
Some items in a collection may be restricted or taken down. These will fail individually but the batch continues. Check errors in output.
### Disk full during bulk download
```bash
# Check what's using space
du -sh /mnt/archive/*/ | sort -rh | head -20
# Resume after freeing space — checksum mode skips completed files
ia download --itemlist items.txt --checksum
```
### Rate limiting / 429 errors
Archive.org may throttle aggressive downloads.
- Reduce parallel jobs (if using GNU Parallel)
- Add delays between items: `parallel -j2 --delay 5 'ia download {}' < items.txt`
- Wait and retry later
### Corrupt downloads
```bash
# Re-download with checksum verification — replaces corrupt files
ia download <identifier> --checksum
```
### Permission denied on destination
```bash
# Ensure the download user owns the target directory
sudo chown -R $(whoami):$(whoami) /mnt/archive/
```
---
## Checklist: Collection Mirror
```
[ ] Identify collection identifier on archive.org
[ ] Check available storage (df -h)
[ ] Estimate collection size (--num-found + sample item size)
[ ] Generate itemlist (ia search --itemlist > itemlist.txt)
[ ] Review itemlist count (wc -l itemlist.txt)
[ ] Start download with appropriate filters (--glob, --format)
[ ] Verify downloaded files exist and are non-zero
[ ] If interrupted, resume with --checksum
[ ] Record collection details in project notes
```
---
*Last updated: 2026-02-14*

View file

@ -0,0 +1,337 @@
# IdahoMesh Bridge Setup
Build a one-way bridge between your tailnet and the IdahoMesh Meshtastic network. This lets your devices reach Nebra gateways through IdahoMesh, while preventing IdahoMesh from reaching back into your network.
---
## Architecture
```
Your Tailnet (your Headscale/Tailscale)
↓ (one-way only)
[Bridge Machine] ← dual tailscaled, NAT + firewall
IdahoMesh Tailnet (100.100.0.0/16)
Nebra CM3 Gateways (Meshtastic nodes)
```
**Security model:** Your devices can reach Nebras. Nothing on IdahoMesh can initiate connections back into your network. NAT masquerades your source IPs so IdahoMesh only sees the bridge's IP.
---
## Prerequisites
| Item | Value |
|------|-------|
| IdahoMesh Headscale URL | `https://vpn.idahomesh.com` |
| IdahoMesh prefix | `100.100.0.0/16` |
| Your preauthkey | Provided by IdahoMesh admin |
You also need:
1. A Linux machine on your network (VM, Pi, bare metal — anything running systemd)
2. Root access on that machine
3. Internet access (to reach vpn.idahomesh.com)
4. Your own tailnet's connection details (Headscale URL, or stock Tailscale if using tailscale.com)
---
## Step 1: Install Tailscale
```bash
curl -fsSL https://tailscale.com/install.sh | sh
```
This installs both `tailscale` and `tailscaled`. The default service (`tailscaled.service`) will handle your primary tailnet.
---
## Step 2: Set Up Dual tailscaled
You need two Tailscale daemon instances — one for your tailnet, one for IdahoMesh. The default `tailscaled` service handles your tailnet. Create a second service for IdahoMesh.
### Create directories
```bash
mkdir -p /var/lib/tailscale-meshtastic /var/run/tailscale-meshtastic
```
### Create the second tailscaled service
Write `/etc/systemd/system/tailscaled-meshtastic.service`:
```ini
[Unit]
Description=Tailscale daemon (IdahoMesh tailnet)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/sbin/tailscaled \
--state=/var/lib/tailscale-meshtastic/tailscaled.state \
--socket=/var/run/tailscale-meshtastic/tailscaled.sock \
--port=41642 \
--tun=tailscale1
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
> **Critical:** The `--tun=tailscale1` flag is required. Both instances cannot use the default `tailscale0` TUN device — the second one will fail with "TUN device tailscale0 is busy" if you omit this.
Enable and start it:
```bash
systemctl daemon-reload
systemctl enable --now tailscaled-meshtastic
```
---
## Step 3: Enable IP Forwarding
```bash
cat > /etc/sysctl.d/99-bridge.conf << 'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sysctl -p /etc/sysctl.d/99-bridge.conf
```
---
## Step 4: Join Both Tailnets
### Join your tailnet (default tailscaled)
Advertise the IdahoMesh range so your other devices can route to Meshtastic nodes through this bridge:
```bash
# If you use your own Headscale:
tailscale up \
--login-server=https://YOUR_HEADSCALE_URL \
--advertise-routes=100.100.0.0/16 \
--accept-routes
# If you use stock Tailscale (tailscale.com):
tailscale up \
--advertise-routes=100.100.0.0/16 \
--accept-routes
```
After joining, you need to **approve the advertised route** on your tailnet's admin:
- **Headscale:** `headscale routes list` then `headscale routes enable -r <route-id>`
- **Stock Tailscale:** Go to admin console → Machines → your bridge → approve the `100.100.0.0/16` subnet route
### Join IdahoMesh (second tailscaled)
```bash
tailscale --socket=/var/run/tailscale-meshtastic/tailscaled.sock up \
--login-server=https://vpn.idahomesh.com \
--authkey=YOUR_IDAHOMESH_PREAUTHKEY \
--accept-routes
```
> **Important:** Do NOT advertise your tailnet's routes on IdahoMesh. The bridge is one-way — IdahoMesh should have no route back into your network.
---
## Step 5: Configure One-Way Firewall and NAT
This is the critical security step. Your tailnet can reach IdahoMesh, but nothing on IdahoMesh can reach back into your network.
Replace `YOUR_TAILNET_PREFIX` below with your tailnet's IP range. Common values:
| Tailnet type | Prefix |
|-------------|--------|
| Stock Tailscale | `100.64.0.0/10` |
| Custom Headscale | Check your `config.yaml``prefixes.v4` |
### Apply iptables rules
```bash
# NAT: Masquerade your source IPs when going to IdahoMesh
# Nebras see the bridge's IdahoMesh IP, not your real tailnet IPs
iptables -t nat -A POSTROUTING -s YOUR_TAILNET_PREFIX -d 100.100.0.0/16 -j MASQUERADE
# Allow your tailnet → IdahoMesh (outbound)
iptables -A FORWARD -s YOUR_TAILNET_PREFIX -d 100.100.0.0/16 -j ACCEPT
# Allow established/related return traffic only (responses to connections you initiated)
iptables -A FORWARD -s 100.100.0.0/16 -d YOUR_TAILNET_PREFIX -m state --state ESTABLISHED,RELATED -j ACCEPT
# DROP all new connections from IdahoMesh → your tailnet
iptables -A FORWARD -s 100.100.0.0/16 -d YOUR_TAILNET_PREFIX -j DROP
```
### Persist rules across reboots
Do **not** use `iptables-persistent` — it hangs on install even with noninteractive mode. Use manual persistence instead:
```bash
# Save current rules
mkdir -p /etc/iptables
iptables-save > /etc/iptables/rules.v4
```
Create `/etc/systemd/system/iptables-restore.service`:
```ini
[Unit]
Description=Restore iptables rules
Before=network-pre.target
Wants=network-pre.target
[Service]
Type=oneshot
ExecStart=/sbin/iptables-restore /etc/iptables/rules.v4
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
```
Enable it:
```bash
systemctl daemon-reload
systemctl enable iptables-restore
```
### Verify rules
```bash
iptables -L FORWARD -v -n
iptables -t nat -L POSTROUTING -v -n
```
You should see:
- MASQUERADE rule on POSTROUTING
- ACCEPT for your prefix → 100.100.0.0/16
- ACCEPT ESTABLISHED,RELATED for 100.100.0.0/16 → your prefix
- DROP for 100.100.0.0/16 → your prefix
---
## Step 6: Enable Routes on Your Other Devices
Any device on your tailnet that wants to reach IdahoMesh Nebras through the bridge needs to accept subnet routes:
```bash
# On each device that needs access
tailscale set --accept-routes
```
Without this, traffic to `100.100.0.x` goes to the default gateway instead of through the Tailscale tunnel.
---
## Step 7: Verify
### Check both tailscaled instances
```bash
# Your tailnet
tailscale status
# IdahoMesh
tailscale --socket=/var/run/tailscale-meshtastic/tailscaled.sock status
```
Both should show "online" with peers listed.
### Ping a Nebra gateway
```bash
# From the bridge itself, via IdahoMesh socket
tailscale --socket=/var/run/tailscale-meshtastic/tailscaled.sock ping burley-butte
```
### Ping from another device on your tailnet
```bash
# From any device on your tailnet (with --accept-routes enabled)
# Use the Nebra's IdahoMesh IP
ping 100.100.0.3 # Burley Butte
```
### Verify isolation
From an IdahoMesh device or ask the admin to test — pinging your tailnet IPs from IdahoMesh should time out / be unreachable.
---
## Troubleshooting
### Second tailscaled won't start
- Check `journalctl -u tailscaled-meshtastic -f`
- Most common: forgot `--tun=tailscale1` — both instances fighting over `tailscale0`
- Verify the port isn't in conflict: default uses 41641, second uses 41642
### Can't reach Nebras from other devices on your tailnet
- Verify the bridge advertises `100.100.0.0/16`: `tailscale status` should show it as a subnet router
- Approve the route on your tailnet admin (Headscale or Tailscale admin console)
- Enable `--accept-routes` on the client device trying to reach Nebras
### IdahoMesh preauthkey expired
- Contact the IdahoMesh admin for a new key
- Re-join: `tailscale --socket=/var/run/tailscale-meshtastic/tailscaled.sock up --login-server=https://vpn.idahomesh.com --authkey=NEW_KEY --accept-routes`
### After reboot, only one tailscaled reconnects
- Check both services: `systemctl status tailscaled` and `systemctl status tailscaled-meshtastic`
- Verify iptables rules survived: `iptables -L FORWARD -v -n`
- If the second instance lost state, re-join IdahoMesh with a new preauthkey
### Default tailscaled pointed at wrong server after force-reauth
- If you run `tailscale up --force-reauth` without specifying `--login-server`, it may reconnect to the wrong Headscale
- Always specify `--login-server` explicitly when re-authing:
- Default instance: `tailscale up --login-server=https://YOUR_HEADSCALE_URL --force-reauth`
- IdahoMesh instance: `tailscale --socket=/var/run/tailscale-meshtastic/tailscaled.sock up --login-server=https://vpn.idahomesh.com --force-reauth`
---
## Quick Reference
| Item | Value |
|------|-------|
| IdahoMesh URL | `https://vpn.idahomesh.com` |
| IdahoMesh prefix | `100.100.0.0/16` |
| IdahoMesh socket | `/var/run/tailscale-meshtastic/tailscaled.sock` |
| IdahoMesh TUN device | `tailscale1` |
| IdahoMesh port | `41642` |
| Default Tailscale socket | `/var/run/tailscale/tailscaled.sock` |
| Default TUN device | `tailscale0` |
| Default port | `41641` |
### Useful commands
```bash
# IdahoMesh status
tailscale --socket=/var/run/tailscale-meshtastic/tailscaled.sock status
# IdahoMesh ping
tailscale --socket=/var/run/tailscale-meshtastic/tailscaled.sock ping <hostname>
# Check firewall rules
iptables -L FORWARD -v -n
iptables -t nat -L POSTROUTING -v -n
# Restart services
systemctl restart tailscaled # Your tailnet
systemctl restart tailscaled-meshtastic # IdahoMesh
```
---
*Last updated: 2026-02-11*

View file

@ -0,0 +1,299 @@
# IdahoMesh VPN — Device Setup
Join a device to the IdahoMesh tailnet (Meshtastic mesh network VPN).
---
## Overview
| Item | Value |
|------|-------|
| Headscale URL | `https://vpn.idahomesh.com` |
| Tailnet prefix | 100.100.0.0/16 |
| MagicDNS domain | mesh.local |
| Supported platforms | Linux (x86/ARM), Windows, macOS, Android, iOS |
### Users
| User | Purpose | Key type |
|------|---------|----------|
| malice | Echo6 infrastructure (bridge LXC) | Short-lived, single-use |
| sidpatchy | Sidpatchy's devices | Short-lived, single-use |
| nebra | Nebra CM3 gateways (field devices) | Long-lived, reusable |
---
## Prerequisites
Before starting, you need:
1. **A preauthkey** — generated by the IdahoMesh Headscale admin (see [Generating Keys](#generating-preauthkeys) below)
2. **Internet access** on the device (to reach vpn.idahomesh.com)
3. **Root/admin access** on the device
---
## Linux (Debian/Ubuntu/Raspberry Pi OS)
### Install Tailscale
```bash
curl -fsSL https://tailscale.com/install.sh | sh
```
### Join the IdahoMesh tailnet
```bash
tailscale up \
--login-server=https://vpn.idahomesh.com \
--authkey=<YOUR_PREAUTHKEY> \
--hostname=<DEVICE_NAME>
```
Replace:
- `<YOUR_PREAUTHKEY>` — the key provided to you
- `<DEVICE_NAME>` — short, lowercase hostname (e.g., `burley-butte`, `sid-laptop`)
### Verify
```bash
tailscale status
tailscale ip -4
ping -c 3 100.100.0.1 # Ping another node on the tailnet
```
You should see a `100.100.x.x` IP assigned.
---
## Nebra CM3 Gateway (ARM/Raspberry Pi)
Same as Linux above. The install script auto-detects ARM.
```bash
# Install
curl -fsSL https://tailscale.com/install.sh | sh
# Join — use the reusable nebra preauthkey
tailscale up \
--login-server=https://vpn.idahomesh.com \
--authkey=<NEBRA_PREAUTHKEY> \
--hostname=<GATEWAY_NAME>
```
**Naming convention for Nebra gateways:**
| Gateway | Hostname |
|---------|----------|
| Burley Butte | `burley-butte` |
| Picabo | `picabo` |
| AIDA-NEBRA | `aida-nebra` |
### Enable Tailscale on boot
Tailscale installs as a systemd service and starts on boot automatically. Confirm:
```bash
systemctl is-enabled tailscaled
# Should output: enabled
```
---
## Windows
### Install
Download and install Tailscale from: https://tailscale.com/download/windows
### Join
Open **PowerShell as Administrator:**
```powershell
tailscale up --login-server=https://vpn.idahomesh.com `
--authkey=<YOUR_PREAUTHKEY> `
--hostname=<DEVICE_NAME>
```
### Verify
```powershell
tailscale status
tailscale ip -4
```
---
## macOS
### Install
Download and install Tailscale from: https://tailscale.com/download/mac
Or via Homebrew:
```bash
brew install tailscale
```
### Join
```bash
tailscale up \
--login-server=https://vpn.idahomesh.com \
--authkey=<YOUR_PREAUTHKEY> \
--hostname=<DEVICE_NAME>
```
### Verify
```bash
tailscale status
tailscale ip -4
```
---
## Android
1. Install Tailscale from **F-Droid** (recommended — supports custom servers) or Google Play
2. Open Tailscale → tap the three-dot menu → **Use custom coordination server**
3. Enter: `https://vpn.idahomesh.com`
4. Authenticate (it will open a browser if no authkey is used)
> **Note:** The Google Play version may not support custom coordination servers. Use the F-Droid build if the option is missing.
---
## iOS
1. Install Tailscale from the App Store
2. Open Tailscale → Settings → **Use Alternate Server**
3. Enter: `https://vpn.idahomesh.com`
4. Authenticate via browser
> **Note:** iOS support for custom Headscale servers can be limited. If the option is unavailable, use the CLI on another device and share connectivity via subnet routing.
---
## Verification Checklist
Run from the newly joined device:
```bash
echo "=== IdahoMesh VPN Check ==="
echo "Hostname: $(hostname)"
echo "Tailscale IP: $(tailscale ip -4 2>/dev/null || echo 'N/A')"
echo "Status: $(tailscale status --self 2>/dev/null | head -1 || echo 'NOT CONNECTED')"
echo "Login server: $(tailscale debug prefs 2>/dev/null | grep -o 'ControlURL:[^ ]*' || echo 'unknown')"
```
Expected: a `100.100.x.x` IP and connected status.
### Test connectivity to other nodes
```bash
# List all visible peers
tailscale status
# Ping another node by MagicDNS name
tailscale ping <OTHER_HOSTNAME>
# Or by IP
ping 100.100.0.x
```
---
## Generating Preauthkeys
**Admin only** — run on the IdahoMesh Headscale server (CT 106, 192.168.1.106):
```bash
# For a human user (single-use, expires in 24h)
headscale preauthkeys create --user <USERNAME> --expiration 24h
# For a human user (single-use, longer window)
headscale preauthkeys create --user <USERNAME> --expiration 72h
# For Nebra gateways (reusable, long-lived)
headscale preauthkeys create --user nebra --reusable --expiration 8760h
# List existing keys
headscale preauthkeys list --user <USERNAME>
```
Users: `malice` (ID 4), `sidpatchy` (ID 2), `nebra` (ID 3)
> **Note:** Headscale v0.28.0 `--user` flag requires user IDs (integers), not names.
---
## Removing a Device
**Admin only:**
```bash
# List all nodes
headscale nodes list
# Delete a node by ID
headscale nodes delete -i <NODE_ID>
```
On the device itself:
```bash
tailscale logout
```
---
## Troubleshooting
### "connection refused" or timeout on join
- Confirm the device has internet access: `curl -I https://vpn.idahomesh.com`
- Check DNS resolution: `dig vpn.idahomesh.com`
- Verify the preauthkey hasn't expired
### "key expired" or "invalid key"
- Preauthkeys are time-limited. Ask the admin for a new one
- Nebra keys are reusable but still expire — check with `headscale preauthkeys list --user nebra`
### Device shows "offline" in node list
- Check if tailscaled is running: `systemctl status tailscaled`
- Restart: `systemctl restart tailscaled`
- Force re-auth: `tailscale up --login-server=https://vpn.idahomesh.com --force-reauth`
### Can't reach other nodes
- Confirm both devices show as "online" in `tailscale status`
- Check ACL policy — the admin may need to add rules for your user group
- Try direct IP ping before MagicDNS names
### MagicDNS not resolving
- Confirm `--accept-dns=true` (this is the default)
- Check: `tailscale debug prefs | grep CorpDNS`
- Restart tailscaled
---
## Quick Reference
| Item | Value |
|------|-------|
| Headscale URL | `https://vpn.idahomesh.com` |
| Tailnet prefix | 100.100.0.0/16 |
| MagicDNS domain | mesh.local |
| Headscale server | CT 106 on utility (192.168.1.106) |
| Admin access | SSH to 192.168.1.106 or via utility Proxmox |
| ACL policy | `/etc/headscale/acl.json` on CT 106 |
---
*Last updated: 2026-02-11*

View file

@ -0,0 +1,259 @@
# Mailcow: Create Mailbox
Create a new mailbox in Mailcow on the Contabo VPS. Covers both interactive (UI) and API-driven creation, with the critical authsource fix for service accounts.
---
## When to Use This
Any time a new mailbox is created in Mailcow, but **especially** for service/system accounts that authenticate via SMTP to send mail programmatically (e.g., `no-reply@echo6.co` used by Authentik, `recon@echo6.co` used by the RECON pipeline). These accounts don't log in through the Mailcow web UI or SSO — they pass credentials directly to Postfix over SMTP, so they **must** use local password authentication.
---
## Prerequisites
- SSH access to Contabo (`ssh root@100.64.0.1`)
- Mailcow API key (stored in Mailcow admin UI under System → Configuration → API)
- Mailcow DB password: source from `/opt/mailcow-dockerized/.env` (`DBPASS`)
---
## Inputs
```
LOCAL_PART=no-reply # Left side of the @ sign
DOMAIN=echo6.co # Must already exist in Mailcow
DISPLAY_NAME="Echo6 No Reply" # Friendly name
PASSWORD=<strong-password> # Generate with: openssl rand -base64 24 | tr -d '/+='
QUOTA_MB=256 # Mailbox quota in MB
IS_SERVICE_ACCOUNT=true # true = SMTP sender, false = regular user
MAILCOW_API_KEY=<api-key> # From Mailcow admin UI
```
---
## Step 1: Create the Mailbox
### Option A: Via Mailcow API
```bash
ssh root@100.64.0.1
curl -sk -X POST "https://127.0.0.1:8443/api/v1/add/mailbox" \
-H "X-API-Key: ${MAILCOW_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"local_part": "'${LOCAL_PART}'",
"domain": "'${DOMAIN}'",
"name": "'${DISPLAY_NAME}'",
"password": "'${PASSWORD}'",
"password2": "'${PASSWORD}'",
"quota": '${QUOTA_MB}',
"active": 1,
"force_pw_update": 0,
"tls_enforce_in": 1,
"tls_enforce_out": 1
}'
```
Expected response:
```json
[{"type":"success","msg":["mailbox_added","no-reply@echo6.co"]}]
```
### Option B: Via Mailcow Admin UI
1. Open https://mail.echo6.co (log in as admin)
2. Navigate to **Email → Mailboxes → Add mailbox**
3. Fill in local part, domain, display name, password, quota
4. Click **Add**
### Access Flags
For service accounts (send-only), disable unnecessary access after creation:
```bash
curl -sk -X POST "https://127.0.0.1:8443/api/v1/edit/mailbox" \
-H "X-API-Key: ${MAILCOW_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"items": ["'${LOCAL_PART}@${DOMAIN}'"],
"attr": {
"sogo_access": "0",
"imap_access": "0",
"pop3_access": "0",
"smtp_access": "1"
}
}'
```
Regular user accounts can leave all access flags at their defaults (all enabled).
---
## Step 2: Fix authsource (CRITICAL for Service Accounts)
### The Problem
Mailcow domains configured with OIDC authentication (like `echo6.co` with Authentik SSO) set `authsource=generic-oidc` on **every new mailbox by default**. This tells Dovecot to authenticate the account through the OIDC provider instead of the local password hash.
For service accounts that log in via SMTP with a username and password, this means:
1. Postfix receives the SMTP AUTH credentials
2. Postfix hands them to Dovecot for verification
3. Dovecot's Lua passdb sees `authsource=generic-oidc`
4. Dovecot tries to authenticate via Authentik SSO
5. The service account doesn't exist in Authentik → **auth fails silently**
6. SMTP returns `535 5.7.8 Error: authentication failed: (reason unavailable)`
The failure message gives no indication that OIDC is the cause. The password is correct, the mailbox exists, and the Mailcow API reports success on password changes — but SMTP auth never works.
### The Fix
Change the authsource from `generic-oidc` to `mailcow` in the database:
```bash
ssh root@100.64.0.1
# Source the DB password
DBPASS=$(grep ^DBPASS /opt/mailcow-dockerized/.env | cut -d= -f2)
# Check current authsource
docker exec mailcowdockerized-mysql-mailcow-1 \
mysql -u mailcow -p${DBPASS} mailcow -N \
-e "SELECT username, authsource FROM mailbox WHERE username='${LOCAL_PART}@${DOMAIN}'"
# Fix: set authsource to local password auth
docker exec mailcowdockerized-mysql-mailcow-1 \
mysql -u mailcow -p${DBPASS} mailcow \
-e "UPDATE mailbox SET authsource='mailcow' WHERE username='${LOCAL_PART}@${DOMAIN}'"
```
**When to apply:**
| Account Type | authsource | Reason |
|-------------|-----------|--------|
| Service account (SMTP sender) | `mailcow` | Authenticates with local password via SMTP |
| Regular user (SSO login) | `generic-oidc` | Authenticates through Authentik web SSO |
| Regular user (IMAP/SMTP client) | `mailcow` | Authenticates with local password from mail client |
Rule of thumb: if the account will ever authenticate with a username + password (SMTP, IMAP, POP3), set `authsource=mailcow`. Only leave `generic-oidc` for accounts that exclusively use SSO web login.
---
## Step 3: Verify SMTP Authentication
Wait a few seconds after the authsource fix, then test:
```bash
# From the Contabo host
python3 -c "
import smtplib
s = smtplib.SMTP('mail.echo6.co', 587, timeout=10)
s.starttls()
s.login('${LOCAL_PART}@${DOMAIN}', '${PASSWORD}')
print('SMTP auth: OK')
s.quit()
"
```
If SMTP auth is being used from inside a Docker container (like Authentik), also test from there:
```bash
docker exec authentik-worker python3 -c "
import smtplib
s = smtplib.SMTP('mail.echo6.co', 587, timeout=10)
s.starttls()
s.login('${LOCAL_PART}@${DOMAIN}', '${PASSWORD}')
print('Container SMTP auth: OK')
s.quit()
"
```
Both should print `OK`. If either fails with `535 5.7.8 Error: authentication failed`, re-check the authsource (Step 2).
---
## Step 4: Store Credentials
Add the new mailbox credentials to `/home/zvx/projects/.ref/credentials`:
```
# Mailcow: ${LOCAL_PART}@${DOMAIN}
MAILCOW_${LOCAL_PART^^}_USER=${LOCAL_PART}@${DOMAIN}
MAILCOW_${LOCAL_PART^^}_PASS=${PASSWORD}
```
---
## Troubleshooting
### SMTP auth fails immediately after mailbox creation
**Cause:** `authsource=generic-oidc` (see Step 2).
### SMTP auth fails after it was previously working
**Cause 1:** Mailcow may have reset the authsource during a stack restart or update. Re-apply Step 2.
**Cause 2:** The password hash may have been corrupted by a failed API password reset. Mailcow's password edit API (`/api/v1/edit/mailbox`) sometimes reports success but doesn't actually update the hash. **Workaround:** Delete the mailbox and recreate it from scratch (Step 1), then re-apply the authsource fix (Step 2). Do not attempt to reset the password via API.
### "Unknown user" in Dovecot logs
Check that the mailbox exists and is active:
```bash
curl -sk "https://127.0.0.1:8443/api/v1/get/mailbox/${LOCAL_PART}@${DOMAIN}" \
-H "X-API-Key: ${MAILCOW_API_KEY}" | python3 -m json.tool
```
### Checking Dovecot auth logs
```bash
docker logs mailcowdockerized-dovecot-mailcow-1 --since 5m 2>&1 | grep -i auth
```
### Checking netfilter/fail2ban
Too many failed SMTP login attempts can trigger Mailcow's brute-force protection:
```bash
docker logs mailcowdockerized-netfilter-mailcow-1 --since 10m 2>&1 | grep -i ban
```
If the Contabo IP (5.189.158.149) is banned, restart the netfilter container:
```bash
cd /opt/mailcow-dockerized && docker compose restart netfilter-mailcow
```
---
## Checklist
```
[ ] Mailbox created (API or UI)
[ ] Access flags set appropriately for account type
[ ] authsource checked — set to 'mailcow' if service account
[ ] SMTP auth verified from host
[ ] SMTP auth verified from consuming container (if applicable)
[ ] Credentials stored in /home/zvx/projects/.ref/credentials
```
---
## Reference: Current Service Accounts
| Mailbox | Used By | authsource | Purpose |
|---------|---------|-----------|---------|
| no-reply@echo6.co | Authentik | mailcow | SSO invitation emails, notifications |
| cipher@echo6.co | CIPHER | generic-oidc | Daily intelligence briefs |
| recon@echo6.co | RECON | generic-oidc | Pipeline notifications |
| fulcrum@echo6.co | Fulcrum | generic-oidc | Hub notifications |
**Note:** cipher, recon, and fulcrum currently use `generic-oidc`. If any of these need to send mail via SMTP (not through the SSO web UI), their authsource must be changed to `mailcow` per Step 2.
---
*Created: 2026-02-16*

View file

@ -0,0 +1,734 @@
# Meshtastic Sidecar Node — Modular Deployment Runbook
Deploy a Raspberry Pi node with a real Meshtastic radio and an operator-selected combination of software modules. Each module is self-contained — install only what the site needs.
---
## Input Variables
Fill these in before running any module:
| Variable | Value | Description |
|----------|-------|-------------|
| `HOSTNAME` | | Node hostname (e.g., `mt-isr`) |
| `NODE_IP` | | Local IP address (e.g., `192.168.1.112`) |
| `SSH_USER` | | SSH username (e.g., `isr`) |
| `SSH_PASS` | | SSH password (from `.ref/credentials`) |
| `RADIO_DEVICE` | | Serial device for radio (e.g., `/dev/ttyACM0`) |
| `OS_VERSION` | | `debian-12` (bookworm) or `debian-13` (trixie) |
---
## Module Menu
Select which modules to deploy. Check off as completed:
```
Meshtastic Sidecar Node Deployment: $HOSTNAME ($NODE_IP)
Select modules to install:
[ ] Module 1: IP Settings — Static IP, hostname, DNS
[ ] Module 2: Tailscale — Headscale VPN registration
[ ] Module 3: meshtasticd — Meshtastic radio daemon
[ ] Module 4: Meshtastic Python — meshtastic CLI + Python API
[ ] Module 5: advBBS — Bulletin board system
[ ] Module 6: Meshing-Around — Mesh bot + WebGUI
[ ] Module 7: MeshMonitor — Feed mesh data to monitoring
```
### Dependencies
```
Module 1 ─── standalone
Module 2 ─── standalone
Module 3 ─── standalone (but needed by 4, 5, 6)
Module 4 ─── requires Module 3
Module 5 ─── requires Module 3
Module 6 ─── requires Module 3
Module 7 ─── requires Module 2
```
---
## Module 1: IP Settings
### Set hostname
```bash
sshpass -p '$SSH_PASS' ssh $SSH_USER@$NODE_IP
sudo hostnamectl set-hostname $HOSTNAME
echo "127.0.1.1 $HOSTNAME" | sudo tee -a /etc/hosts
```
### Configure static IP (if not using DHCP reservation)
For NetworkManager-managed systems (Raspberry Pi OS with desktop):
```bash
sudo nmcli con mod "preconfigured" \
ipv4.method manual \
ipv4.addresses "$NODE_IP/24" \
ipv4.gateway "192.168.1.1" \
ipv4.dns "1.1.1.1,8.8.8.8"
sudo nmcli con up "preconfigured"
```
For headless systems with `/etc/network/interfaces`:
```bash
sudo tee /etc/network/interfaces.d/eth0 << EOF
auto eth0
iface eth0 inet static
address $NODE_IP/24
gateway 192.168.1.1
dns-nameservers 1.1.1.1 8.8.8.8
EOF
sudo systemctl restart networking
```
For WiFi-only Pis, use `wlan0` instead of `eth0` and configure via `wpa_supplicant` or NetworkManager.
### Configure DNS fallback
```bash
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
echo "nameserver 8.8.8.8" | sudo tee -a /etc/resolv.conf
```
### Verify
```bash
hostname # Should show $HOSTNAME
ip addr show # Should show $NODE_IP
ping -c 3 1.1.1.1 # Internet connectivity
ping -c 3 192.168.1.1 # Gateway reachable
```
---
## Module 2: Tailscale
### Choose tailnet
| Tailnet | Headscale URL | Prefix | Key generation |
|---------|---------------|--------|----------------|
| Echo6 | `https://vpn.echo6.co` | 100.64.0.0/10 | On Contabo |
| IdahoMesh | `https://vpn.idahomesh.com` | 100.100.0.0/16 | On CT 106 |
### Install Tailscale
```bash
curl -fsSL https://tailscale.com/install.sh | sh
```
### Generate preauthkey
**Echo6** (from cortex or any machine with Tailscale access to Contabo):
```bash
ssh root@100.64.0.1 'docker exec headscale headscale preauthkeys create --user 1 --reusable --expiration 1h'
```
**IdahoMesh** (from utility Proxmox host):
```bash
ssh root@192.168.1.241 'pct exec 106 -- headscale preauthkeys create --user <USER_ID> --expiration 24h'
```
Users: malice (4), sidpatchy (2), nebra (3)
### Register with Headscale
```bash
sudo tailscale up \
--login-server=$HEADSCALE_URL \
--authkey=$PREAUTH_KEY \
--hostname=$HOSTNAME
```
### Install DNS bootstrap drop-in (reboot-safe)
Prevents chicken-and-egg DNS failure where tailscaled can't resolve the coordination server after reboot:
```bash
sudo mkdir -p /etc/systemd/system/tailscaled.service.d
sudo tee /etc/systemd/system/tailscaled.service.d/dns-bootstrap.conf << 'EOF'
[Service]
ExecStartPre=/bin/sh -c "grep -q nameserver /etc/resolv.conf || echo nameserver 1.1.1.1 > /etc/resolv.conf"
EOF
sudo systemctl daemon-reload
```
### Verify
```bash
tailscale status # Should show connected
tailscale ip -4 # Should show 100.64.x.x or 100.100.x.x
ping -c 3 100.64.0.1 # Echo6: ping Contabo
ping -c 3 100.100.0.1 # IdahoMesh: ping Headscale
```
### Enable accept-routes (if needed)
Required to reach subnets advertised by the mesh-bridge (CT 107):
```bash
sudo tailscale up --login-server=$HEADSCALE_URL --accept-routes
```
---
## Module 3: meshtasticd
### Detect OS and add OBS repo
**Debian 12 (bookworm):**
```bash
curl -fsSL https://download.opensuse.org/repositories/network:/Meshtastic:/beta/Debian_12/Release.key | \
sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/network_Meshtastic_beta.gpg
echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/beta/Debian_12/ /' | \
sudo tee /etc/apt/sources.list.d/meshtasticd.list
```
**Debian 13 (trixie) / Ubuntu 24.04:**
```bash
curl -fsSL https://download.opensuse.org/repositories/network:/Meshtastic:/beta/Debian_13/Release.key | \
sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/network_Meshtastic_beta.gpg
echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/beta/Debian_13/ /' | \
sudo tee /etc/apt/sources.list.d/meshtasticd.list
```
### Install
```bash
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y meshtasticd
```
### Configure — choose hardware type
#### Option A: USB radio (`/dev/ttyACM0` or `/dev/ttyUSB0`)
```bash
sudo tee /etc/meshtasticd/config.yaml << EOF
Lora:
Module: sx1262
DIO2_AS_RF_SWITCH: true
CS: 8
IRQ: 22
Busy: 27
Reset: 17
Serial:
Enabled: true
Device: $RADIO_DEVICE
Networking:
EnableUDP: true
EOF
```
> **Note:** If using a USB-connected Meshtastic device (RAK, T-Beam, etc.), the `Lora` section should be removed entirely and the device is managed via the serial interface. The config becomes:
```bash
sudo tee /etc/meshtasticd/config.yaml << EOF
Serial:
Enabled: true
Device: $RADIO_DEVICE
Networking:
EnableUDP: true
EOF
```
#### Option B: Nebra SX1262 Pi Hat (e.g., aida-nebra)
```bash
sudo tee /etc/meshtasticd/config.yaml << EOF
Lora:
Module: sx1262
DIO2_AS_RF_SWITCH: true
CS: 24
IRQ: 22
Busy: 27
Reset: 17
Networking:
EnableUDP: true
EOF
```
#### Option C: SIM mode (no physical radio, virtual mesh node)
```bash
sudo tee /etc/meshtasticd/config.yaml << EOF
Lora:
Module: sim
General:
MACAddress: "DE:AD:00:XX:XX:XX"
Networking:
EnableUDP: true
EOF
```
### Fix permissions
```bash
sudo mkdir -p /var/lib/meshtasticd/vfs
sudo chown -R meshtasticd:meshtasticd /var/lib/meshtasticd
sudo chown -R meshtasticd:meshtasticd /etc/meshtasticd
```
### Add user to dialout group (for serial access)
```bash
sudo usermod -a -G dialout meshtasticd
```
### Enable and start
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now meshtasticd
```
### Verify
```bash
sudo systemctl status meshtasticd # Should be active (running)
sudo journalctl -u meshtasticd -n 30 # Check for radio detection
ss -tlnp | grep 4403 # API port listening
```
Look for lines like `Connected to radio` or `Detected module: sx1262` in the journal output.
---
## Module 4: Meshtastic Python
**Requires:** Module 3 (meshtasticd must be running)
### Install
```bash
sudo apt-get install -y python3-pip python3-venv
pip3 install --break-system-packages meshtastic
```
Or in a venv if the system enforces PEP 668:
```bash
python3 -m venv /opt/meshtastic-cli
/opt/meshtastic-cli/bin/pip install meshtastic
sudo ln -sf /opt/meshtastic-cli/bin/meshtastic /usr/local/bin/meshtastic
```
### Set node identity
```bash
# Set long name (visible in client apps and mesh maps)
meshtastic --host localhost --set-owner "$HOSTNAME"
# Set short name (4 chars max, shown in compact views)
meshtastic --host localhost --set-owner-short "${HOSTNAME:0:4}"
```
### Set device role
| Role | Use case |
|------|----------|
| CLIENT | Default, standard mesh participant |
| CLIENT_MUTE | Receives but doesn't rebroadcast |
| ROUTER | Prioritizes forwarding, minimal local traffic |
| ROUTER_CLIENT | Router + normal client features |
```bash
meshtastic --host localhost --set device.role CLIENT
```
### Set position (optional, for mesh maps)
```bash
meshtastic --host localhost --setlat XX.XXXX --setlon -XXX.XXXX --setalt XXXX
```
### Verify
```bash
meshtastic --host localhost --info
```
Should show node name, ID, role, and radio parameters. If it says "Error connecting," verify meshtasticd is running and no other client is connected to port 4403.
> **Important:** Only ONE client can connect to the meshtasticd API at a time. If a service (advBBS, Meshing-Around) is already connected, disconnect it first before using the CLI.
---
## Module 5: advBBS
**Requires:** Module 3 (meshtasticd)
### Install prerequisites
```bash
sudo apt-get install -y python3-pip python3-venv git docker.io docker-compose
sudo systemctl enable --now docker
sudo usermod -a -G docker $SSH_USER
```
### Clone and configure
```bash
sudo mkdir -p /opt/advbbs
cd /opt/advbbs
sudo git clone https://forge.echo6.co/advbbs/advbbs.git .
sudo cp config.example.toml config.toml
```
Edit `config.toml`:
```toml
[bbs]
name = "$HOSTNAME BBS"
callsign = "${HOSTNAME^^}"
admin_password = "CHANGE_THIS"
[meshtastic]
connection_type = "tcp"
tcp_host = "localhost"
tcp_port = 4403
[database]
backup_path = "/data/backups"
backup_interval_hours = 24
[sync]
enabled = true
# Add federation peers as needed:
# [[sync.peers]]
# node_id = "!abc12345"
# name = "REMOTE-BBS"
# protocol = "advbbs"
# enabled = true
```
### Deploy with Docker
Standard (x86):
```bash
cd /opt/advbbs
sudo docker-compose up -d
```
Raspberry Pi (memory-optimized):
```bash
cd /opt/advbbs
sudo docker-compose -f docker-compose.rpi.yml build
sudo docker-compose -f docker-compose.rpi.yml up -d
```
### Deploy without Docker (native, for minimal Pi setups)
```bash
cd /opt/advbbs
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Create systemd service
sudo tee /etc/systemd/system/advbbs.service << EOF
[Unit]
Description=advBBS Meshtastic BBS
After=network.target meshtasticd.service
Requires=meshtasticd.service
[Service]
Type=simple
User=$SSH_USER
WorkingDirectory=/opt/advbbs
ExecStart=/opt/advbbs/venv/bin/python -m advbbs
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now advbbs
```
### Verify
```bash
sudo docker-compose logs -f # Docker
sudo journalctl -u advbbs -f # Native
# From another mesh node, DM the BBS node:
# !help
# Should get a response listing commands
```
---
## Module 6: Meshing-Around
**Requires:** Module 3 (meshtasticd)
### Option A: Docker (recommended for nodes with >1GB RAM)
```bash
sudo apt-get install -y docker.io docker-compose
sudo systemctl enable --now docker
sudo mkdir -p /opt/meshing-around
cd /opt/meshing-around
# Clone from upstream
sudo git clone https://github.com/SpudGunMan/meshing-around.git .
# Create config — point at local meshtasticd
# Edit mesh.ini or config file as needed:
# interface_type = tcp
# hostname = localhost
# port = 4403
sudo docker-compose up -d
```
WebGUI accessible at `http://$NODE_IP:8085`
### Option B: Native Python (for RPi Zero 2 W or low-memory nodes)
```bash
sudo mkdir -p /opt/meshing-around
cd /opt/meshing-around
sudo git clone https://github.com/SpudGunMan/meshing-around.git .
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Create systemd service
sudo tee /etc/systemd/system/meshing-around.service << EOF
[Unit]
Description=Meshing-Around Mesh Bot + WebGUI
After=network.target meshtasticd.service
Requires=meshtasticd.service
[Service]
Type=simple
User=$SSH_USER
WorkingDirectory=/opt/meshing-around
ExecStart=/opt/meshing-around/venv/bin/python mesh_bot.py
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now meshing-around
```
### Verify
```bash
sudo docker-compose logs -f # Docker
sudo journalctl -u meshing-around -f # Native
curl -s http://localhost:8085 | head -5 # WebGUI responds
```
---
## Module 7: MeshMonitor
**Requires:** Module 2 (Tailscale, to reach MeshMonitor at 100.64.0.7)
MeshMonitor runs on CT 100 (192.168.1.100 / 100.64.0.7:8080). This module configures the sidecar node to report mesh data to the MeshMonitor instance.
### Configure MeshMonitor connection
MeshMonitor connects to sidecar nodes via the meshtasticd TCP API. The sidecar node needs to be reachable from MeshMonitor over Tailscale.
1. Ensure meshtasticd is running and listening on port 4403 (Module 3)
2. Ensure Tailscale is connected (Module 2) so MeshMonitor can reach this node
3. Register the node in MeshMonitor's web UI:
```bash
# MeshMonitor admin credentials (from .ref/credentials)
# URL: http://100.64.0.7:8080
# User: admin
# Pass: 7redditGold
# Open MeshMonitor web UI and add this node:
# - Node address: $TAILSCALE_IP:4403
# - Node name: $HOSTNAME
```
### Verify
```bash
# Confirm Tailscale can reach MeshMonitor
ping -c 3 100.64.0.7
curl -s http://100.64.0.7:8080 | head -5
# Confirm meshtasticd API is accessible from the network
ss -tlnp | grep 4403
```
---
## Post-Deploy Checklist
```
[ ] IP / hostname configured (Module 1)
[ ] Tailscale connected to correct tailnet (Module 2, if selected)
[ ] meshtasticd running, radio detected (Module 3, if selected)
[ ] Node visible on mesh (check from another node)
[ ] Selected service modules running (advBBS, Meshing-Around, etc.)
[ ] All services survive reboot: sudo reboot && verify after
[ ] Credentials logged in .ref/credentials
[ ] Node added to environment.md (IP, Tailscale IP, purpose)
[ ] Node added to services.md (if running services)
```
---
## Troubleshooting
### meshtasticd won't start
```bash
sudo journalctl -u meshtasticd -n 50 --no-pager
# Common issues:
# - Serial device not found: check ls -la /dev/ttyACM0 (or ttyUSB0)
# - Permission denied: sudo chown -R meshtasticd:meshtasticd /var/lib/meshtasticd /etc/meshtasticd
# - Wrong OBS repo for OS version: Debian 12 vs 13 repo URL mismatch
```
### Radio not detected
```bash
# List serial devices
ls -la /dev/ttyACM* /dev/ttyUSB*
# Check kernel messages for USB events
dmesg | tail -20
# If device was unplugged/replugged, restart meshtasticd
sudo systemctl restart meshtasticd
```
### Meshtastic CLI can't connect
```bash
# Only ONE client can connect at a time
# Stop any running services first:
sudo systemctl stop advbbs meshing-around 2>/dev/null
# Then try CLI
meshtastic --host localhost --info
# Restart services when done
sudo systemctl start advbbs meshing-around 2>/dev/null
```
### Tailscale won't connect after reboot
```bash
# Check if DNS bootstrap drop-in is installed
cat /etc/systemd/system/tailscaled.service.d/dns-bootstrap.conf
# If missing, install it (Module 2)
# If present, check resolv.conf
cat /etc/resolv.conf
# Force fallback DNS and restart
echo "nameserver 1.1.1.1" | sudo tee /etc/resolv.conf
sudo systemctl restart tailscaled
tailscale status
```
### advBBS won't connect to meshtasticd
```bash
# Verify meshtasticd is running and API port is open
ss -tlnp | grep 4403
# Check advBBS config points to localhost:4403
grep -A 3 '\[meshtastic\]' /opt/advbbs/config.toml
# Verify no other client is holding the connection
# meshtasticd only allows ONE concurrent API client
```
### Node not visible on mesh
```bash
# Check meshtasticd logs for radio status
sudo journalctl -u meshtasticd | grep -i -E "radio|connect|error"
# Verify UDP is enabled (for SIM nodes or multi-daemon setups)
grep -i udp /etc/meshtasticd/config.yaml
# Verify node identity is set
meshtastic --host localhost --info 2>/dev/null || echo "Another client connected — stop services first"
```
### Low memory (RPi Zero 2 W)
The RPi Zero 2 W has ~416MB RAM. Monitor usage:
```bash
free -h
# If memory is tight:
# - Use native Python installs instead of Docker
# - Only run ONE service alongside meshtasticd
# - Use advBBS's docker-compose.rpi.yml if using Docker
# - Disable web-reader profile to save memory
```
---
## Quick Reference
```bash
# Service management
sudo systemctl status meshtasticd
sudo systemctl status advbbs
sudo systemctl status meshing-around
sudo journalctl -u meshtasticd -f
# Mesh CLI (stop services first!)
meshtastic --host localhost --info
meshtastic --host localhost --set-owner "NodeName"
meshtastic --host localhost --set device.role CLIENT
# Tailscale
tailscale status
tailscale ip -4
tailscale ping <other-node>
```
---
## Existing Nodes Reference
| Node | IP | User | Radio | OS | Modules |
|------|-----|------|-------|-----|---------|
| aida-nebra | 192.168.1.253 | zvx | Nebra SX1262 Hat | RPi OS | 2 (Echo6), 3, 4 |
| mt-burleybutte | 192.168.1.185 | bb | Nebra SX1262 Hat | RPi OS | 2 (IdahoMesh), 3, 4 |
| mt-isr | 192.168.1.112 | isr | USB `/dev/ttyACM0` | Debian 13 | 3 (installed, inactive) |
---
*Last updated: 2026-02-21*

View file

@ -0,0 +1,417 @@
# Meshtasticd SIM Node Runbook — LXC Deployment
## Overview
This runbook covers deploying meshtasticd SIM (virtual) nodes inside LXC containers on Proxmox, each paired with a dedicated service (BBS, MeshSense, etc.). SIM nodes communicate with your real radio node over UDP and appear as normal nodes on the mesh — clients, maps, and other services can't tell the difference.
**Design principle:** One container = one SIM daemon + one service. Clean isolation, easy to snapshot, migrate, or tear down without affecting anything else.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Proxmox Host │
│ │
│ ┌───────────────────┐ │
│ │ Real Radio Node │ (LXC, bare metal, Pi, or USB device) │
│ │ meshtasticd │ │
│ │ Port 4403 │ │
│ │ /dev/ttyUSB0 │ │
│ │ UDP enabled │ │
│ └────────┬──────────┘ │
│ │ UDP (vmbr0 or dedicated bridge) │
│ │ │
│ ┌─────┴─────┬───────────────┐ │
│ │ │ │ │
│ ┌──▼────────┐ ┌▼────────────┐ ┌▼────────────┐ │
│ │ LXC: BBS │ │ LXC: Sense │ │ LXC: Bot │ ...more as │
│ │ │ │ │ │ │ needed │
│ │ mesht. sim│ │ mesht. sim │ │ mesht. sim │ │
│ │ port 4403 │ │ port 4403 │ │ port 4403 │ │
│ │ + BBS svc │ │ + MeshSense │ │ + bot svc │ │
│ └───────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
Since each SIM daemon is alone in its container, they can all use the default port (4403) internally. No port juggling needed.
---
## Prerequisites
- Proxmox host with LXC support
- A working real radio meshtasticd instance somewhere on the network with UDP enabled
- An LXC template (Ubuntu 22.04/24.04 or Debian 12 recommended)
- Network bridge accessible to both the real radio node and LXC containers
---
## Step 1: Create the LXC Container
From the Proxmox host CLI:
```bash
# Create an unprivileged container with static IP and TUN device for Tailscale
pct create <CTID> local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst \
--hostname mesh-<service> \
--memory 512 \
--cores 1 \
--rootfs local-lvm:4 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.1.<CTID>/24,gw=192.168.1.1 \
--unprivileged 1 \
--features nesting=1 \
--start 0 \
--password <from .ref/credentials>
# Add TUN device for Tailscale (must be done before first start)
cat >> /etc/pve/lxc/<CTID>.conf << EOF
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
EOF
pct start <CTID>
```
Adjust memory/cores/storage to taste. SIM daemons are lightweight — 512MB RAM and 1 core is plenty for the daemon plus most services.
### Bootstrap standard packages
```bash
echo6-bootstrap-ct.sh <CTID>
```
If the script isn't on the Proxmox host, run `echo6-onboard-node.sh` first. See `runbooks/proxmox-onboard-node.md`.
---
## Step 2: Install meshtasticd Inside the Container
Install from the Meshtastic OBS (OpenSUSE Build Service) repository:
```bash
pct exec <CTID> -- bash -c "
# Add the Meshtastic beta repo (Debian 12)
curl -fsSL https://download.opensuse.org/repositories/network:/Meshtastic:/beta/Debian_12/Release.key | \
gpg --dearmor -o /etc/apt/trusted.gpg.d/network_Meshtastic_beta.gpg
echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/beta/Debian_12/ /' > \
/etc/apt/sources.list.d/meshtasticd.list
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y meshtasticd
"
```
For Ubuntu 24.04 containers, replace `Debian_12` with `Debian_13` in both URLs above.
Verify it's installed:
```bash
pct exec <CTID> -- meshtasticd --version
```
---
## Step 3: Configure SIM Mode
Create or edit the config file. Since this is a dedicated container, you can use the default paths.
### /etc/meshtasticd/config.yaml
```yaml
Lora:
Module: sim
General:
# Prevent loading any default config.d overrides
# ConfigDirectory: /etc/meshtasticd/config.d/
# REQUIRED: Set a unique MAC address for this SIM node
# Last 3 hex pairs = node color in client apps
MACAddress: "DE:AD:00:FF:00:01"
```
### MAC Address Guidelines
- **Every SIM node must have a unique MAC.** If two nodes share a MAC, you'll get node ID collisions and unpredictable behavior.
- The last 3 byte pairs map to a hex color code displayed in client apps.
- Pick a scheme that makes sense for your deployment, e.g.:
- `DE:AD:00:FF:00:01` — SIM node 1 (BBS)
- `DE:AD:00:00:FF:02` — SIM node 2 (MeshSense)
- `DE:AD:00:FF:FF:03` — SIM node 3 (bot)
---
## Step 4: Enable UDP Bridging
UDP is what connects SIM nodes to the rest of your mesh. Every meshtasticd instance — real radio and all SIM nodes — needs UDP enabled and must be able to reach each other on the network.
Add to your SIM node's config:
```yaml
Networking:
EnableUDP: true
```
**Also ensure your real radio node has UDP enabled** in its own config.
### Network Considerations
For UDP mesh traffic to flow between LXC containers and your real radio node:
- All containers and the real radio host must be on the **same Layer 2 network** (same bridge, same subnet) — UDP broadcast/multicast needs to reach all instances.
- If your real radio runs on a different host (e.g., a Raspberry Pi), make sure it's on the same VLAN/subnet as the LXC bridge.
- Proxmox's default `vmbr0` bridge works fine if everything is on the same network.
- If you're running the real radio in its own LXC and passing through USB, the same bridge rules apply.
**Firewall note:** If you have Proxmox firewall or iptables rules on the host, ensure UDP traffic between containers is not blocked. Meshtasticd uses UDP broadcast by default — verify your bridge allows broadcast forwarding.
---
## Step 5: Configure the systemd Service
The meshtasticd package likely installs a default unit file. If you need to customize it:
```bash
sudo systemctl edit meshtasticd --full
```
Or create/verify `/etc/systemd/system/meshtasticd.service`:
```ini
[Unit]
Description=Meshtastic Daemon - SIM Node
After=network.target
[Service]
Type=simple
User=meshtasticd
Group=meshtasticd
ExecStart=/usr/bin/meshtasticd -d /var/lib/meshtasticd/vfs -c /etc/meshtasticd/config.yaml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Since this is the only meshtasticd instance in the container, you don't need custom port flags — the default port is fine.
Ensure directory ownership:
```bash
sudo mkdir -p /var/lib/meshtasticd/vfs
sudo chown -R meshtasticd:meshtasticd /var/lib/meshtasticd
sudo chown -R meshtasticd:meshtasticd /etc/meshtasticd
```
Start and enable:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now meshtasticd
sudo systemctl status meshtasticd
```
---
## Step 5b: Install Tailscale and Register with Headscale
Install Tailscale inside the container:
```bash
pct exec <CTID> -- bash -c "
echo nameserver 1.1.1.1 > /etc/resolv.conf
echo nameserver 8.8.8.8 >> /etc/resolv.conf
curl -fsSL https://tailscale.com/install.sh | sh
"
```
Generate a preauth key on Contabo (user ID 1 = echo6):
```bash
ssh root@100.64.0.1 'docker exec headscale headscale preauthkeys create --user 1 --reusable --expiration 1h'
```
Register the node:
```bash
pct exec <CTID> -- tailscale up --login-server https://vpn.echo6.co --authkey <PREAUTH_KEY> --hostname mesh-<service>
# Verify
pct exec <CTID> -- tailscale status
```
**Note:** The TUN device must already be configured in the container config (done in Step 1). If Tailscale fails to start, verify `/dev/net/tun` exists inside the container.
---
## Step 6: Configure the SIM Node
With the daemon running and no service connected yet, configure it via CLI:
```bash
# Install the Meshtastic Python CLI
pip install meshtastic
# Check that the node is up
meshtastic --host localhost --info
# Set a descriptive name
meshtastic --host localhost --set-owner "BBS Node"
meshtastic --host localhost --set-owner-short "BBS"
# Optionally set a position (makes it appear on mesh maps)
meshtastic --host localhost --setlat XX.XXXX --setlon -XXX.XXXX --setalt XXXX
# Set the node role as appropriate
meshtastic --host localhost --set device.role CLIENT
```
If you don't set a position, the node still functions on the mesh but won't appear on maps.
---
## Step 7: Install and Connect Your Service
Now install whichever service this container is dedicated to and point it at `localhost:4403` (or whatever the default meshtasticd API port is).
### Example: BBS
```bash
# Install your BBS software of choice
# Point it at the local meshtasticd instance
# BBS_CONFIG: host=localhost, port=4403
```
### Example: MeshSense
```bash
# MeshSense supports non-default ports
# Configure it to connect to localhost:4403
```
**Reminder:** One client API connection per daemon. Once the service is connected, don't also try to connect a client app to the same instance. If you need to reconfigure the node, stop the service first.
---
## Provisioning Additional Containers
For each new service, repeat steps 17 with:
1. A new container ID and hostname (e.g., `mesh-sense`, `mesh-bot`)
2. A **unique MAC address** in config.yaml
3. The specific service installed alongside meshtasticd
### Quick Clone Approach
Once you have one container fully set up, you can clone it in Proxmox and just change:
- Container hostname
- MAC address in `/etc/meshtasticd/config.yaml`
- The service installed/configured
- Node owner name via `meshtastic --host localhost --set-owner "NewName"`
```bash
# Clone from Proxmox CLI
pct clone 201 202 --hostname mesh-sense --full
pct start 202
pct enter 202
# Update the MAC address
nano /etc/meshtasticd/config.yaml # change MACAddress
# Restart meshtasticd to pick up new MAC
systemctl restart meshtasticd
# Reconfigure node identity
meshtastic --host localhost --set-owner "MeshSense"
```
---
## USB Passthrough for Real Radio Container
If you want your real radio node in an LXC too (rather than bare metal), you need to pass the USB device through to the container.
On the Proxmox host, find the device:
```bash
ls -la /dev/serial/by-id/
# or
lsusb
```
Add to the container config (`/etc/pve/lxc/<CTID>.conf`):
```
lxc.cgroup2.devices.allow: c 188:* rwm
lxc.mount.entry: /dev/ttyUSB0 dev/ttyUSB0 none bind,optional,create=file
```
Adjust the device path and cgroup major number as needed for your hardware. The container will also need a `config.yaml` with the real LoRa module config instead of `Module: sim`.
---
## Troubleshooting
### SIM nodes not seeing the real radio (or each other)
- Verify UDP is enabled on **all** instances
- Confirm all containers are on the same bridge/subnet
- Check for firewall rules blocking UDP broadcast between containers
- Test basic connectivity: `ping` between containers
### Node ID collisions / "things get weird"
- Every SIM node must have a **unique MAC address** — check each container's config.yaml
- After changing a MAC, restart meshtasticd and verify with `meshtastic --host localhost --info`
### Service can't connect to meshtasticd
- Is meshtasticd actually running? `systemctl status meshtasticd`
- Is another client already connected? Only one API connection per instance.
- Check the port: `ss -tlnp | grep 4403`
### Config not taking effect
- Make sure `ConfigDirectory` is commented out or pointed somewhere empty so default config.d files don't override your settings
- Restart after config changes: `systemctl restart meshtasticd`
### Permission errors
- VFS and config directories must be owned by the `meshtasticd` user
```bash
chown -R meshtasticd:meshtasticd /var/lib/meshtasticd
chown -R meshtasticd:meshtasticd /etc/meshtasticd
```
---
## Quick Reference
```bash
# Inside any SIM container:
systemctl status meshtasticd # Check daemon status
journalctl -u meshtasticd -f # Follow logs
meshtastic --host localhost --info # Node info (only if no service is connected)
# From Proxmox host:
pct list # List all containers
pct enter <CTID> # Shell into container
pct exec <CTID> -- systemctl status meshtasticd # Check without entering
```
---
## Container Inventory Template
Track your deployment:
| CTID | Hostname | MAC Address | Service | Port | Notes |
|------|-------------|---------------------|------------|------|-----------------|
| 200 | mesh-radio | (hardware) | Real radio | 4403 | USB passthrough |
| 201 | mesh-bbs | DE:AD:00:FF:00:01 | BBS | 4403 | — |
| 202 | mesh-sense | DE:AD:00:00:FF:02 | MeshSense | 4403 | — |
| 203 | mesh-bot | DE:AD:00:FF:FF:03 | Bot | 4403 | — |
---
## Credits
Procedure sourced from a community discussion between pdxlocs, tedward, and wehooper4 regarding multi-daemon meshtasticd deployments with SIM mode. Adapted for LXC/Proxmox deployment.

343
runbooks/nordvpn-lxc.md Normal file
View file

@ -0,0 +1,343 @@
# NordVPN / WireGuard in LXC
Set up VPN with IP rotation inside an LXC container. Handles the LXC-specific gotchas: TUN device, systemd compatibility, split tunneling so local services stay reachable.
---
## Prerequisites
- LXC container provisioned and running (see `ct-runbook.md`)
- SSH access to both the Proxmox host and the container
- NordVPN account with a service token (from https://my.nordaccount.com/dashboard/nordvpn/access-tokens/)
---
## Inputs
Prompt the user for all of these before executing:
```
CTID= # Container ID on Proxmox host
CT_HOST= # SSH alias or IP for the container
PVE_HOST= # SSH alias or IP for the Proxmox host
NORDVPN_TOKEN= # NordVPN service token
VPN_COUNTRIES= # Comma-separated rotation list (e.g., "United_States,Canada,United_Kingdom,Germany,Netherlands,Sweden")
VPN_CONFIG_DIR= # Where to store WireGuard configs inside CT (e.g., /opt/vpn)
```
---
## Step 1: Enable TUN Device on Container
**Run on Proxmox host.** LXC containers don't have `/dev/net/tun` by default — VPN won't work without it.
```bash
ssh $PVE_HOST "grep -q 'dev/net/tun' /etc/pve/lxc/${CTID}.conf 2>/dev/null || {
echo 'lxc.cgroup2.devices.allow: c 10:200 rwm' >> /etc/pve/lxc/${CTID}.conf
echo 'lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file' >> /etc/pve/lxc/${CTID}.conf
echo 'TUN device added — container restart required'
}"
```
If lines were added, restart the container:
```bash
ssh $PVE_HOST "pct reboot $CTID"
```
### Gate
```bash
ssh $CT_HOST 'ls -la /dev/net/tun'
```
Must show the device. If missing, the cgroup/mount entries didn't take — check `/etc/pve/lxc/${CTID}.conf`.
---
## Step 2: Try NordVPN CLI (Option A)
The CLI is the simplest path but requires working systemd in the container (which most LXCs have, but some stripped-down templates don't).
```bash
ssh $CT_HOST 'sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh)'
```
If the installer completes without errors:
```bash
ssh $CT_HOST "nordvpn login --token $NORDVPN_TOKEN"
ssh $CT_HOST 'nordvpn set technology nordlynx' # WireGuard-based, faster
ssh $CT_HOST 'nordvpn set killswitch off' # Don't kill local services
ssh $CT_HOST 'nordvpn set autoconnect off' # We control rotation
ssh $CT_HOST 'nordvpn set dns off' # Keep container's DNS
```
### Test
```bash
ssh $CT_HOST 'nordvpn connect United_States && sleep 3 && curl -s https://ifconfig.me && nordvpn disconnect'
```
Must show a non-local IP. If it does, **skip to Step 4** (rotation script).
### Common failures
- **"Whoops! /run/nordvpn/nordvpnd.sock not found"** — nordvpnd service didn't start. Check `systemctl status nordvpnd`. If systemd is broken in this LXC, fall through to Option B.
- **"Permission denied creating /dev/net/tun"** — Step 1 TUN device not configured. Go back.
- **Installer hangs on "Starting NordVPN daemon"** — systemd issue. Kill it, fall through to Option B.
---
## Step 3: WireGuard Manual Configs (Option B — Fallback)
Use this if NordVPN CLI doesn't work in the LXC.
### Install WireGuard
```bash
ssh $CT_HOST 'apt install -y wireguard-tools curl jq'
```
### Generate NordVPN WireGuard configs
NordVPN provides WireGuard configs via their API. Generate one per country:
```bash
ssh $CT_HOST "mkdir -p $VPN_CONFIG_DIR"
# Get NordVPN WireGuard private key
# Method: Use the NordVPN API with your token to get credentials
# This requires the nordvpn CLI to extract the private key, OR manual setup:
#
# 1. Go to https://my.nordaccount.com/dashboard/nordvpn/manual-configuration/
# 2. Generate WireGuard credentials
# 3. Download configs for each country
# 4. SCP them to the container
# Place configs as: $VPN_CONFIG_DIR/us.conf, ca.conf, uk.conf, de.conf, nl.conf, se.conf
```
**⚠️ Manual step required:** NordVPN's WireGuard config generation requires either the CLI (which didn't work) or manual download from the NordVPN dashboard. Download `.conf` files for each country in the rotation list and SCP them to the container.
### Config format
Each `.conf` file should look like:
```ini
[Interface]
PrivateKey = <your-wireguard-private-key>
Address = 10.5.0.2/16
DNS = 103.86.96.100
[Peer]
PublicKey = <server-public-key>
AllowedIPs = 0.0.0.0/0
Endpoint = <server-ip>:51820
PersistentKeepalive = 25
```
**Critical for LXC:** If the container runs services that must stay reachable on the local network (e.g., PeerTube on port 9000), you need split tunneling. Replace `AllowedIPs = 0.0.0.0/0` with specific routes that exclude your LAN:
```ini
# Route everything EXCEPT local network through VPN
AllowedIPs = 0.0.0.0/1, 128.0.0.0/1
# This covers all IPs but lets 192.168.x.x and 100.64.x.x traffic stay local
```
Or more precisely, exclude your subnets:
```bash
# Generate AllowedIPs that exclude local networks
# This sends all traffic through VPN except 192.168.1.0/24 and 100.64.0.0/10
AllowedIPs = 0.0.0.0/5, 8.0.0.0/7, 11.0.0.0/8, 12.0.0.0/6, 16.0.0.0/4, 32.0.0.0/3, 64.0.0.0/3, 96.0.0.0/6, 100.0.0.0/10, 100.128.0.0/9, 101.0.0.0/8, 102.0.0.0/7, 104.0.0.0/5, 112.0.0.0/4, 128.0.0.0/3, 160.0.0.0/5, 168.0.0.0/6, 172.0.0.0/8, 173.0.0.0/8, 174.0.0.0/7, 176.0.0.0/4, 192.0.0.0/9, 192.128.0.0/11, 192.160.0.0/13, 192.169.0.0/16, 192.170.0.0/15, 192.172.0.0/14, 192.176.0.0/12, 192.192.0.0/10, 193.0.0.0/8, 194.0.0.0/7, 196.0.0.0/6, 200.0.0.0/5, 208.0.0.0/4, 224.0.0.0/3
```
**Simpler alternative:** Use `wg-quick` post-up/down scripts to manage routes:
```ini
[Interface]
PrivateKey = <key>
Address = 10.5.0.2/16
PostUp = ip route add 192.168.1.0/24 via $(ip route show default | awk '{print $3}') dev eth0
PostUp = ip route add 100.64.0.0/10 via $(ip route show default | awk '{print $3}') dev eth0
PreDown = ip route del 192.168.1.0/24 via $(ip route show default | awk '{print $3}') dev eth0 2>/dev/null; true
PreDown = ip route del 100.64.0.0/10 via $(ip route show default | awk '{print $3}') dev eth0 2>/dev/null; true
[Peer]
PublicKey = <key>
AllowedIPs = 0.0.0.0/0
Endpoint = <server>:51820
```
### Test
```bash
ssh $CT_HOST "wg-quick up $VPN_CONFIG_DIR/us.conf && sleep 2 && curl -s https://ifconfig.me && echo && wg-quick down $VPN_CONFIG_DIR/us.conf"
```
Must show a NordVPN IP. Verify local services still reachable:
```bash
# From another machine on the LAN, while VPN is up:
curl -s http://<CT_LOCAL_IP>:<SERVICE_PORT>/ # Must still respond
```
---
## Step 4: VPN Rotation Helper Script
Regardless of Option A or B, create a rotation script that other services can call.
```bash
ssh $CT_HOST "cat > $VPN_CONFIG_DIR/vpn-rotate.sh << 'SCRIPT'
#!/bin/bash
# VPN Rotation Script
# Usage: vpn-rotate.sh [connect|disconnect|rotate|status]
CONFIG_DIR=\"$VPN_CONFIG_DIR\"
STATE_FILE=\"$VPN_CONFIG_DIR/vpn-state.json\"
COUNTRIES=($VPN_COUNTRIES)
# Detect VPN method
if command -v nordvpn &>/dev/null && systemctl is-active --quiet nordvpnd 2>/dev/null; then
VPN_METHOD=nordvpn
else
VPN_METHOD=wireguard
fi
get_current() {
if [ \"\$VPN_METHOD\" = \"nordvpn\" ]; then
nordvpn status 2>/dev/null | grep -i country | awk '{print \$NF}'
else
wg show 2>/dev/null | head -1 | awk '{print \$2}' | sed 's/.conf//'
fi
}
get_public_ip() {
curl -s --connect-timeout 5 https://ifconfig.me 2>/dev/null
}
vpn_connect() {
local country=\${1:-\${COUNTRIES[0]}}
echo \"Connecting to \$country...\"
if [ \"\$VPN_METHOD\" = \"nordvpn\" ]; then
nordvpn connect \"\$country\"
else
# Disconnect any existing
for conf in \$CONFIG_DIR/*.conf; do
wg-quick down \"\$conf\" 2>/dev/null
done
local conf_file=\"\$CONFIG_DIR/\$(echo \$country | tr '[:upper:]' '[:lower:]' | cut -c1-2).conf\"
if [ -f \"\$conf_file\" ]; then
wg-quick up \"\$conf_file\"
else
echo \"ERROR: No config for \$country (\$conf_file)\"
return 1
fi
fi
sleep 3
echo \"Public IP: \$(get_public_ip)\"
}
vpn_disconnect() {
if [ \"\$VPN_METHOD\" = \"nordvpn\" ]; then
nordvpn disconnect
else
for conf in \$CONFIG_DIR/*.conf; do
wg-quick down \"\$conf\" 2>/dev/null
done
fi
}
vpn_rotate() {
local current=\$(get_current)
local next_idx=0
for i in \"\${!COUNTRIES[@]}\"; do
if echo \"\${COUNTRIES[\$i]}\" | grep -qi \"\$current\"; then
next_idx=$(( (i + 1) % \${#COUNTRIES[@]} ))
break
fi
done
vpn_disconnect
sleep 2
vpn_connect \"\${COUNTRIES[\$next_idx]}\"
}
vpn_status() {
echo \"Method: \$VPN_METHOD\"
echo \"Country: \$(get_current || echo 'disconnected')\"
echo \"IP: \$(get_public_ip || echo 'unknown')\"
}
case \"\${1:-status}\" in
connect) vpn_connect \"\$2\" ;;
disconnect) vpn_disconnect ;;
rotate) vpn_rotate ;;
status) vpn_status ;;
*) echo \"Usage: \$0 {connect [country]|disconnect|rotate|status}\" ;;
esac
SCRIPT
chmod +x $VPN_CONFIG_DIR/vpn-rotate.sh"
```
### Test rotation
```bash
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh connect"
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh status"
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh rotate"
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh status"
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh disconnect"
```
Each `rotate` should switch countries and show a different IP.
---
## Verification Checklist
```bash
echo "=== VPN Setup Check ==="
echo ""
echo "TUN device: $(ls /dev/net/tun 2>/dev/null && echo 'OK' || echo 'MISSING')"
echo "VPN method: $(command -v nordvpn >/dev/null && echo 'NordVPN CLI' || echo 'WireGuard')"
echo "Configs: $(ls $VPN_CONFIG_DIR/*.conf 2>/dev/null | wc -l) country configs"
echo "Rotation: $(ls $VPN_CONFIG_DIR/vpn-rotate.sh 2>/dev/null && echo 'OK' || echo 'MISSING')"
echo ""
echo "Quick connect test..."
$VPN_CONFIG_DIR/vpn-rotate.sh connect
echo "VPN IP: $(curl -s https://ifconfig.me)"
echo "Local access: $(curl -s -o /dev/null -w '%{http_code}' http://localhost:9000/ 2>/dev/null || echo 'N/A')"
$VPN_CONFIG_DIR/vpn-rotate.sh disconnect
echo "Home IP: $(curl -s https://ifconfig.me)"
```
---
## Troubleshooting
### "RTNETLINK answers: Operation not permitted" on wg-quick up
TUN device not available. Go back to Step 1. Container may need restart after adding cgroup entries.
### VPN connects but local services unreachable
Split tunneling not configured. The VPN is routing ALL traffic including LAN. Fix the `AllowedIPs` or add PostUp routes per Step 3.
### DNS stops working when VPN is up
NordVPN CLI: `nordvpn set dns off` (use container's DNS, not NordVPN's).
WireGuard: Remove the `DNS =` line from the `.conf` file.
### "Cannot open TUN/TAP dev /dev/net/tun: No such file or directory"
Container config missing TUN mount entry. Check `/etc/pve/lxc/${CTID}.conf` for both the cgroup allow and mount entry lines.
### NordVPN CLI installed but nordvpnd won't start
Common in LXC. `systemctl status nordvpnd` will usually show a cgroup or namespace error. Fall through to WireGuard (Step 3).
---
*Last updated: 2026-02-13*

View file

@ -0,0 +1,458 @@
# PeerTube Remote Runner — GPU Transcoding
Deploy a PeerTube remote runner with NVENC GPU transcoding. The runner pulls jobs from PeerTube over WebSocket, transcodes with the GPU, and uploads HLS streams back.
Use this when adding a new runner node, rebuilding an existing one, or re-registering after a PeerTube rebuild.
---
## Prerequisites
- PeerTube instance running and accessible (HTTP, not necessarily HTTPS)
- PeerTube admin credentials or API access to generate runner registration tokens
- Target machine with:
- NVIDIA GPU with NVENC support (Maxwell gen 2+ / GTX 950+)
- NVIDIA drivers installed and working (`nvidia-smi` returns output)
- Node.js 18+ installed
- SSH access from CC host
If the target machine needs NVIDIA drivers or Node.js, see `proxmox-create-ubuntu-vm.md` Steps 9 and 11.
---
## Inputs
Prompt the user for all of these before executing:
```
RUNNER_HOST= # SSH alias or IP for the runner machine (e.g., cortex)
RUNNER_NAME= # Human-readable runner name (e.g., "cortex-nvenc")
RUNNER_USER= # User to run the service as (e.g., "zvx")
PT_URL= # PeerTube instance URL reachable from runner (e.g., "http://100.64.0.23:9000")
PT_HOST_HEADER= # PeerTube's public hostname for Host header (e.g., "stream.echo6.co")
PT_ADMIN_USER= # PeerTube admin username (e.g., "root")
PT_ADMIN_PASS= # PeerTube admin password
INSTALL_DIR= # Where to put runner config (default: /opt/peertube-runner)
```
---
## Step 1: Verify GPU
```bash
ssh $RUNNER_HOST 'nvidia-smi --query-gpu=name,driver_version,memory.total,encoder.stats.sessionCount --format=csv,noheader'
```
### Gate
Must return GPU name, driver version, and VRAM. If it fails:
- No output → NVIDIA drivers not installed. See `proxmox-create-ubuntu-vm.md` Step 9.
- "NVML: Driver/library version mismatch" → reboot the machine.
- "No devices found" → GPU passthrough not configured (VMs) or hardware issue.
Check NVENC specifically:
```bash
ssh $RUNNER_HOST 'nvidia-smi -q | grep -A 5 "Encoder"'
```
Must show encoder session info. If "N/A", the GPU doesn't support NVENC or drivers are too old.
---
## Step 2: Install peertube-runner
PeerTube runner is distributed via npm.
```bash
ssh $RUNNER_HOST 'which node && node --version' # Must be 18+
```
Install the runner:
```bash
ssh $RUNNER_HOST 'sudo npm install -g @peertube/peertube-runner'
ssh $RUNNER_HOST 'which peertube-runner && peertube-runner --version'
```
### Gate
`peertube-runner --version` must return a version number. If npm install fails, check Node.js version (must be 18+).
Create config directory:
```bash
ssh $RUNNER_HOST "sudo mkdir -p $INSTALL_DIR && sudo chown $RUNNER_USER:$RUNNER_USER $INSTALL_DIR"
```
---
## Step 3: Generate Registration Token on PeerTube
Get a registration token from the PeerTube instance. This requires admin access.
### Option A: Via API
```bash
# Get OAuth client credentials
CLIENT_CREDS=$(ssh $RUNNER_HOST "curl -s $PT_URL/api/v1/oauth-clients/local -H 'Host: $PT_HOST_HEADER'")
CLIENT_ID=$(echo "$CLIENT_CREDS" | jq -r '.client_id')
CLIENT_SECRET=$(echo "$CLIENT_CREDS" | jq -r '.client_secret')
# Get admin token
TOKEN_RESP=$(ssh $RUNNER_HOST "curl -s $PT_URL/api/v1/users/token \
-H 'Host: $PT_HOST_HEADER' \
--data 'client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&grant_type=password&username=$PT_ADMIN_USER&password=$PT_ADMIN_PASS'")
ACCESS_TOKEN=$(echo "$TOKEN_RESP" | jq -r '.access_token')
# Generate runner registration token
REG_TOKEN=$(ssh $RUNNER_HOST "curl -s -X POST $PT_URL/api/v1/runners/registration-tokens/generate \
-H 'Host: $PT_HOST_HEADER' \
-H 'Authorization: Bearer $ACCESS_TOKEN' | jq -r '.registrationToken'")
echo "Registration token: $REG_TOKEN"
```
### Option B: Via PeerTube Admin UI
1. Log into PeerTube as admin
2. Administration → System → Runners
3. Click "Generate registration token"
4. Copy the token
### Gate
Must have a registration token string. If the API returns errors:
- 401 → wrong admin credentials
- 404 → PeerTube version too old (runners require v5.2+)
- Connection refused → PeerTube not reachable from runner. Check URL and network.
---
## Step 4: Register Runner
```bash
ssh $RUNNER_HOST "peertube-runner register \
--url $PT_URL \
--registration-token $REG_TOKEN \
--runner-name $RUNNER_NAME"
```
### Gate
Must complete without errors. Verify registration:
```bash
ssh $RUNNER_HOST 'peertube-runner list-registered'
```
Must show the PeerTube instance URL. If registration fails:
- "Invalid registration token" → token already used or expired. Generate a new one.
- "ECONNREFUSED" → runner can't reach PeerTube. Test: `curl -s $PT_URL/api/v1/config`
- "self-signed certificate" → if PeerTube uses HTTPS with self-signed cert, use `NODE_TLS_REJECT_UNAUTHORIZED=0` (not recommended) or fix the cert.
---
## Step 5: Configure NVENC
The runner auto-detects ffmpeg capabilities, but verify NVENC is available to ffmpeg:
```bash
ssh $RUNNER_HOST 'ffmpeg -encoders 2>/dev/null | grep nvenc'
```
Must show `h264_nvenc` and `hevc_nvenc`. If missing, install ffmpeg with NVENC support:
```bash
# Ubuntu/Debian — the default ffmpeg usually includes NVENC if drivers are installed
ssh $RUNNER_HOST 'sudo apt install -y ffmpeg'
# Re-check
ssh $RUNNER_HOST 'ffmpeg -encoders 2>/dev/null | grep nvenc'
```
If still missing, the NVIDIA drivers may not include the encoding libraries. Install:
```bash
ssh $RUNNER_HOST 'sudo apt install -y libnvidia-encode-550' # Match your driver version
```
---
## Step 6: Create systemd Service
```bash
ssh $RUNNER_HOST "sudo tee /etc/systemd/system/peertube-runner.service > /dev/null << 'EOF'
[Unit]
Description=PeerTube Remote Runner (NVENC)
After=network-online.target nvidia-persistenced.service
Wants=network-online.target
Requires=nvidia-persistenced.service
[Service]
Type=simple
User=$RUNNER_USER
Group=$RUNNER_USER
Environment=NODE_ENV=production
ExecStart=/usr/bin/peertube-runner server \
--enable-job vod-hls-transcoding \
--enable-job vod-audio-merge-transcoding \
--enable-job live-rtmp-hls-transcoding \
--enable-job video-studio-transcoding \
--enable-job video-transcription
WorkingDirectory=/home/$RUNNER_USER
Restart=always
RestartSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=peertube-runner
MemoryMax=20G
[Install]
WantedBy=multi-user.target
EOF"
ssh $RUNNER_HOST 'sudo systemctl daemon-reload'
ssh $RUNNER_HOST 'sudo systemctl enable peertube-runner'
ssh $RUNNER_HOST 'sudo systemctl start peertube-runner'
```
### Gate
```bash
ssh $RUNNER_HOST 'systemctl is-active peertube-runner'
```
Must return `active`. If it fails, check logs:
```bash
ssh $RUNNER_HOST 'journalctl -u peertube-runner -n 50 --no-pager'
```
Common failures:
- "Cannot find module" → peertube-runner not installed globally, or PATH issue. Check `which peertube-runner`.
- "nvidia-persistenced.service not found" → remove the `Requires=` line if nvidia-persistenced isn't set up (it's optional but recommended).
---
## Step 7: Install Health Check
Cron script that auto-restarts the runner if it crashes or the GPU becomes inaccessible.
```bash
ssh $RUNNER_HOST "sudo tee $INSTALL_DIR/health.sh > /dev/null << 'HEALTH'
#!/bin/bash
LOG_TAG=\"peertube-runner-health\"
if ! systemctl is-active --quiet peertube-runner; then
logger -t \$LOG_TAG \"Runner not active, restarting...\"
systemctl restart peertube-runner
sleep 10
fi
if ! pgrep -f \"peertube-runner server\" > /dev/null; then
logger -t \$LOG_TAG \"Runner process not found, restarting service...\"
systemctl restart peertube-runner
fi
if ! nvidia-smi > /dev/null 2>&1; then
logger -t \$LOG_TAG \"GPU not accessible, restarting nvidia-persistenced and runner...\"
systemctl restart nvidia-persistenced 2>/dev/null
sleep 5
systemctl restart peertube-runner
fi
HEALTH
chmod +x $INSTALL_DIR/health.sh"
# Add cron job (every 5 minutes)
ssh $RUNNER_HOST "(crontab -l 2>/dev/null | grep -v peertube-runner-health; echo '*/5 * * * * $INSTALL_DIR/health.sh') | crontab -"
```
Verify:
```bash
ssh $RUNNER_HOST 'crontab -l | grep peertube'
```
---
## Step 8: Test Transcoding
Upload a test video and verify the full pipeline works.
### Quick test via API
```bash
# Download a short test video
ssh $RUNNER_HOST 'curl -L -o /tmp/test-video.mp4 "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4" 2>/dev/null'
# Upload to PeerTube (reuse ACCESS_TOKEN from Step 3)
ssh $RUNNER_HOST "curl -s -X POST $PT_URL/api/v1/videos/upload \
-H 'Host: $PT_HOST_HEADER' \
-H 'Authorization: Bearer $ACCESS_TOKEN' \
-F 'videofile=@/tmp/test-video.mp4' \
-F 'name=Runner Test Video' \
-F 'channelId=1' \
-F 'privacy=1' \
-F 'waitTranscoding=true' | jq '{uuid, name, state}'"
```
### Verify GPU is processing
Within 30 seconds of upload:
```bash
ssh $RUNNER_HOST 'nvidia-smi --query-gpu=utilization.gpu,utilization.encoder,temperature.gpu,power.draw --format=csv,noheader'
```
GPU utilization and encoder utilization should be non-zero. If encoder shows 0% but GPU shows activity, NVENC isn't being used — check ffmpeg encoder detection (Step 5).
### Check runner logs
```bash
ssh $RUNNER_HOST 'journalctl -u peertube-runner -n 20 --no-pager | grep -i "transcod\|job\|error"'
```
Should show job pickup, transcoding progress, and completion.
### Clean up
```bash
# Delete test video via API (optional)
ssh $RUNNER_HOST "curl -s -X DELETE $PT_URL/api/v1/videos/<VIDEO_UUID> \
-H 'Host: $PT_HOST_HEADER' \
-H 'Authorization: Bearer $ACCESS_TOKEN'"
ssh $RUNNER_HOST 'rm -f /tmp/test-video.mp4'
```
---
## Verification Checklist
```bash
echo "=== PeerTube Runner Check ==="
echo ""
echo "GPU: $(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || echo 'MISSING')"
echo "NVENC: $(ffmpeg -encoders 2>/dev/null | grep -c h264_nvenc) encoders"
echo "Runner ver: $(peertube-runner --version 2>/dev/null || echo 'NOT INSTALLED')"
echo "Registered: $(peertube-runner list-registered 2>/dev/null | grep -c 'http' || echo '0') instance(s)"
echo "Service: $(systemctl is-active peertube-runner 2>/dev/null || echo 'NOT RUNNING')"
echo "Health cron: $(crontab -l 2>/dev/null | grep -c peertube-runner || echo '0') entries"
echo "ffmpeg procs: $(pgrep -c ffmpeg 2>/dev/null || echo '0') active"
echo "GPU util: $(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader 2>/dev/null || echo 'N/A')"
```
---
## Adding a Second PeerTube Instance
To register the same runner with another PeerTube instance:
```bash
peertube-runner register \
--url <SECOND_PT_URL> \
--registration-token <TOKEN> \
--runner-name $RUNNER_NAME
```
The runner handles multiple registrations automatically — it polls all registered instances for jobs.
## Unregistering
```bash
# List registrations
peertube-runner list-registered
# Unregister from a specific instance
peertube-runner unregister --url $PT_URL
```
---
## Troubleshooting
### Runner picks up jobs but transcoding fails immediately
Check ffmpeg NVENC access:
```bash
ffmpeg -y -f lavfi -i testsrc=duration=5:size=1280x720:rate=30 -c:v h264_nvenc /tmp/nvenc-test.mp4
```
If this fails, NVENC isn't accessible to ffmpeg. Common causes: wrong driver version, missing libnvidia-encode, or GPU in use by another process that's holding all NVENC sessions.
### Runner shows 0 active jobs despite pending queue
- WebSocket connection issue. Check: `journalctl -u peertube-runner | grep -i websocket`
- Runner registered with wrong URL. Verify: `peertube-runner list-registered`
- PeerTube remote runners not enabled. Check PeerTube config: `transcoding.remote_runners.enabled` must be `true`
### GPU utilization stuck at 100% / NVENC sessions maxed
The RTX A4000 supports ~3 simultaneous NVENC sessions (consumer cards are limited to 3, pro cards vary). If all sessions are in use, new jobs queue on the runner side. This is normal — throughput is limited by NVENC session count, not GPU compute.
To increase throughput: patch the NVENC session limit (search "nvidia nvenc patch") or add a second runner node.
### Runner keeps disconnecting / restarting
- Check memory: `free -h`. The 20GB MemoryMax in the service file may be too low if processing many concurrent jobs. Increase if needed.
- Check disk space: transcoding uses temp space. Ensure `/tmp` or the runner's working directory has sufficient free space (10GB+ recommended).
- Network instability between runner and PeerTube. Use Tailscale IP instead of public URL for reliability.
### After PeerTube rebuild, runner can't connect
Registration is tied to the PeerTube instance. After a rebuild:
1. Unregister: `peertube-runner unregister --url $PT_URL`
2. Generate new registration token on the new PeerTube
3. Re-register: `peertube-runner register --url $PT_URL --registration-token <NEW_TOKEN> --runner-name $RUNNER_NAME`
4. Restart: `sudo systemctl restart peertube-runner`
---
## Quick Reference: Current Runners
| Runner | Host | GPU | PeerTube Instance | Status |
|--------|------|-----|-------------------|--------|
| cortex-nvenc | cortex (VM 150 on TOC) | RTX A4000 16GB | stream.echo6.co (CT 110) | Active |
---
## Whisper Transcription Setup
The runner also handles `video-transcription` jobs (auto-captioning via Whisper). A smart wrapper routes jobs based on audio duration:
### Smart Wrapper (`/usr/local/bin/whisper-smart` on cortex)
- **Model:** `medium` (good accuracy, fits in VRAM on float16)
- **GPU path (< 1 hour):** `--device cuda --compute_type float16` — ~3.7GB VRAM, fast
- **CPU path (>= 1 hour):** `--device cpu --compute_type int8` — ~8-11GB RAM, slow but avoids VRAM exhaustion
- **CPU serialization:** `flock --nonblock /tmp/whisper-cpu.lock` — only one CPU transcription at a time. If lock is held, wrapper exits 1 and the runner retries the job later.
- **Concurrency:** Runner config set to `concurrency = 2` — allows one GPU + one CPU job in parallel
### Symlink chain
```
/usr/local/bin/whisper-ctranslate2 → /usr/local/bin/whisper-smart (the smart wrapper)
/usr/local/bin/whisper-ctranslate2-real → /home/zvx/.local/bin/whisper-ctranslate2 (actual Python binary)
```
### Key config
- **Runner config:** `~/.config/peertube-runner-nodejs/default/config.toml``model = "medium"`, `concurrency = 2`
- **PeerTube config:** `/var/www/peertube/config/production.yaml` on CT 110 — `model-name: medium`
- **systemd MemoryMax:** `20G` (CPU int8 medium model peaks at ~11GB)
### Wrapper log
```bash
tail -f /tmp/whisper-wrapper.log # Shows mode (GPU/CPU/CPU-BLOCKED), duration, args
```
---
*Last updated: 2026-02-17 — Added Whisper smart transcription setup, MemoryMax 12G→20G, concurrency 1→2*

177
runbooks/pg-backup.md Normal file
View file

@ -0,0 +1,177 @@
# PostgreSQL Backup (Docker)
Automated pg_dump backups for any Docker-hosted PostgreSQL instance. Retention, integrity check, and restore testing included.
---
## Inputs
```
CONTAINER_NAME= # Docker container name (e.g., "matrix-postgres")
DB_NAME= # Database to back up (e.g., "synapse")
DB_USER= # Database user (e.g., "synapse")
BACKUP_DIR= # Host directory for backups (e.g., "/opt/matrix/backups")
RETENTION_DAYS=14 # Days to keep backups
CRON_SCHEDULE="0 3 * * *" # Daily at 3AM
```
---
## Step 1: Create Backup Directory
```bash
mkdir -p ${BACKUP_DIR}
chmod 700 ${BACKUP_DIR}
mkdir -p $(dirname ${BACKUP_DIR})/scripts
```
---
## Step 2: Create Backup Script
Create `$(dirname ${BACKUP_DIR})/scripts/pg_backup.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
# --- Configuration (edit per service) ---
CONTAINER_NAME="${CONTAINER_NAME}"
DB_NAME="${DB_NAME}"
DB_USER="${DB_USER}"
BACKUP_DIR="${BACKUP_DIR}"
RETENTION_DAYS=${RETENTION_DAYS}
# --- Derived ---
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"
LOG_FILE="${BACKUP_DIR}/backup.log"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "${LOG_FILE}"
}
# --- Backup ---
log "Starting backup of ${DB_NAME} from ${CONTAINER_NAME}"
docker exec ${CONTAINER_NAME} pg_dump \
-U ${DB_USER} \
-d ${DB_NAME} \
--format=plain \
--no-owner \
--no-privileges \
| gzip > "${BACKUP_FILE}"
if [ $? -eq 0 ] && [ -s "${BACKUP_FILE}" ]; then
SIZE=$(du -h "${BACKUP_FILE}" | cut -f1)
log "Backup successful: ${BACKUP_FILE} (${SIZE})"
else
log "ERROR: Backup failed or produced empty file"
rm -f "${BACKUP_FILE}"
exit 1
fi
# --- Retention ---
DELETED=$(find ${BACKUP_DIR} -name "${DB_NAME}_*.sql.gz" -mtime +${RETENTION_DAYS} -print -delete | wc -l)
log "Retention cleanup: removed ${DELETED} backups older than ${RETENTION_DAYS} days"
# --- Integrity ---
if gzip -t "${BACKUP_FILE}" 2>/dev/null; then
log "Integrity check: PASS"
else
log "ERROR: Integrity check FAILED — backup is corrupt"
exit 1
fi
log "Backup complete."
```
Make executable:
```bash
chmod +x $(dirname ${BACKUP_DIR})/scripts/pg_backup.sh
```
---
## Step 3: Test Manually
```bash
$(dirname ${BACKUP_DIR})/scripts/pg_backup.sh
ls -lh ${BACKUP_DIR}/*.sql.gz
cat ${BACKUP_DIR}/backup.log
```
---
## Step 4: Schedule via Cron
```bash
(crontab -l 2>/dev/null; echo "${CRON_SCHEDULE} $(dirname ${BACKUP_DIR})/scripts/pg_backup.sh >> ${BACKUP_DIR}/cron.log 2>&1") | crontab -
crontab -l | grep pg_backup
```
---
## Step 5: Test Restore (Non-Destructive)
```bash
# Create throwaway test database
docker exec ${CONTAINER_NAME} psql -U ${DB_USER} -c "CREATE DATABASE ${DB_NAME}_restore_test;"
# Restore latest backup into it
LATEST=$(ls -t ${BACKUP_DIR}/${DB_NAME}_*.sql.gz | head -1)
gunzip -c "${LATEST}" | docker exec -i ${CONTAINER_NAME} psql -U ${DB_USER} -d ${DB_NAME}_restore_test
# Spot-check (adjust table names per service)
docker exec ${CONTAINER_NAME} psql -U ${DB_USER} -d ${DB_NAME}_restore_test -c "\dt" | head -20
# Cleanup
docker exec ${CONTAINER_NAME} psql -U ${DB_USER} -c "DROP DATABASE ${DB_NAME}_restore_test;"
```
---
## Emergency Restore
```bash
# 1. Stop the application container (not postgres)
docker stop <app-container>
# 2. Drop and recreate
docker exec ${CONTAINER_NAME} psql -U ${DB_USER} -c "DROP DATABASE ${DB_NAME};"
docker exec ${CONTAINER_NAME} psql -U ${DB_USER} -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};"
# 3. Restore
LATEST=$(ls -t ${BACKUP_DIR}/${DB_NAME}_*.sql.gz | head -1)
gunzip -c "${LATEST}" | docker exec -i ${CONTAINER_NAME} psql -U ${DB_USER} -d ${DB_NAME}
# 4. Restart app
docker start <app-container>
```
---
## Monitoring Hook (Optional)
Add to your monitoring stack:
```bash
LATEST_AGE=$(( $(date +%s) - $(stat -c %Y $(ls -t ${BACKUP_DIR}/${DB_NAME}_*.sql.gz | head -1)) ))
if [ ${LATEST_AGE} -gt 90000 ]; then
echo "WARNING: Latest ${DB_NAME} backup is more than 25 hours old"
fi
```
---
## Checklist
```
□ Manual backup produces valid .sql.gz
□ Backup log shows success
□ Gzip integrity check passes
□ Cron job installed
□ Test restore succeeds
□ Test database cleaned up
```

View file

@ -0,0 +1,201 @@
# Pi 5 NAS — OMV Provisioning Runbook
SSH into the Pi. The Pi should already be booted with Raspberry Pi OS Lite, Ethernet connected, Radxa Penta SATA Hat installed.
---
## 1. Update + Install OMV
```bash
sudo apt update
sudo apt upgrade -y
wget -O - https://github.com/OpenMediaVault-Plugin-Developers/installScript/raw/master/install | sudo bash
```
This takes ~5 minutes. Reboot when done:
```bash
sudo reboot
```
---
## 2. Enable PCIe Port
SSH back in after reboot. Drives won't show up until the PCIe port is enabled.
```bash
sudo tee -a /boot/firmware/config.txt > /dev/null << 'EOF'
# Radxa Penta SATA Hat — enable PCIe Gen 3
dtparam=pciex1
dtparam=pciex1_gen=3
EOF
sudo reboot
```
---
## 3. Verify Drives
SSH back in and confirm all four drives are visible:
```bash
lsblk
```
Expected output (one per drive bay):
```
sda
sdb
sdc
sdd
```
If any are missing, check SATA cable seating on the Radxa hat and verify the PCIe lines were added to config.txt.
---
## 4. Standard Baseline (zvx user, sshpass, Tailscale)
If the Pi wasn't flashed with the zvx user via Raspberry Pi Imager, create it now:
```bash
sudo useradd -m -s /bin/bash -G sudo zvx
echo "zvx:7redditGold" | sudo chpasswd
```
Install sshpass and Tailscale:
```bash
sudo apt install -y sshpass
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --ssh
```
---
## 5. Configure OMV via Web UI
Open a browser and go to the Pi's IP address. Default login:
- **Username:** `admin`
- **Password:** `openmediavault`
Change the admin password immediately.
### Format Drives (ext4, no RAID)
Each drive is used individually — no RAID array.
1. **Storage → Disks** — confirm all 4 drives appear
2. **Storage → File Systems** — for each drive:
- Click **Create**
- Select the drive (sda, sdb, sdc, sdd)
- Type: **ext4**
- Label them something useful (e.g., `bay1`, `bay2`, `bay3`, `bay4`)
- Click **Save**, then **Mount** each one
3. **Apply** pending changes when prompted
### Create Shared Folders
1. **Storage → Shared Folders** — create a folder on each drive as needed, e.g.:
- `share1` on `bay1`
- `media` on `bay2`
- `proxmox-storage` on `bay3`
- `backup` on `bay4`
- (adjust names/layout to your needs)
2. Set permissions: **Administrator: read/write, Users: read/write, Others: read-only** (or as desired)
### Enable SMB (Windows Shares)
1. **Services → SMB/CIFS → Settings** — toggle **Enabled**, click **Save**
2. **Services → SMB/CIFS → Shares** — click **Create** for each shared folder you want accessible from Windows:
- Select the shared folder
- **Public:** No
- **Browseable:** Yes
- Click **Save**
3. **Apply** pending changes
### Enable NFS (Proxmox CT Storage)
1. **Services → NFS → Settings** — toggle **Enabled**, click **Save**
2. **Services → NFS → Shares** — click **Create** for each folder Proxmox needs:
- Select the shared folder (e.g., `proxmox-storage`)
- **Client:** your Proxmox subnet, e.g., `192.168.1.0/24`
- **Privilege:** Read/Write
- **Extra options:** `subtree_check,insecure,no_root_squash`
- Click **Save**
3. **Apply** pending changes
`no_root_squash` is needed because Proxmox CTs write as root. `insecure` allows connections from ports >1024 which some NFS clients use.
### Create OMV User (for SMB access)
1. **Users → Users** — click **Create**
- **Name:** `zvx`
- **Password:** `7redditGold`
- **Groups:** add to `users`
2. **Save** and **Apply**
This user is for SMB authentication. The Linux `zvx` user created earlier is separate from the OMV web UI user system.
---
## 6. Connect from Windows
From a Windows machine on the same network:
```
\\<NAS-IP>\share1
```
Or map as a network drive. Authenticate with `zvx` / `7redditGold`.
---
## 7. Connect from Proxmox
On the Proxmox host, add the NFS share as storage:
**Datacenter → Storage → Add → NFS:**
- **ID:** `nas-storage` (or whatever)
- **Server:** NAS IP address (or Tailscale IP)
- **Export:** `/export/proxmox-storage` (check exact path with `showmount -e <NAS-IP>` from Proxmox)
- **Content:** select what you'll store (Disk image, Container, ISO image, Snippets, Backups, etc.)
Or via CLI on the Proxmox host:
```bash
# Verify the NFS export is visible
showmount -e <NAS-IP>
# Add to Proxmox storage config
pvesm add nfs nas-storage \
--server <NAS-IP> \
--export /export/proxmox-storage \
--content images,rootdir,vztmpl,backup,iso \
--options vers=4
```
---
## 8. Verification Checklist
```bash
echo "=== Pi NAS Provisioning Check ==="
echo ""
echo "Hostname: $(hostname)"
echo "User zvx: $(id zvx 2>/dev/null && echo 'OK' || echo 'MISSING')"
echo "sshpass: $(which sshpass >/dev/null 2>&1 && echo 'OK' || echo 'MISSING')"
echo "Tailscale: $(tailscale status --self 2>/dev/null | head -1 || echo 'NOT CONNECTED')"
echo "Tailscale IP: $(tailscale ip -4 2>/dev/null || echo 'N/A')"
echo "OMV: $(systemctl is-active openmediavault-engined 2>/dev/null || echo 'NOT RUNNING')"
echo "PCIe: $(grep -q 'dtparam=pciex1' /boot/firmware/config.txt && echo 'ENABLED' || echo 'DISABLED')"
echo "Drives: $(lsblk -d -n -o NAME | grep '^sd' | wc -l) detected"
echo "SMB: $(systemctl is-active smbd 2>/dev/null || echo 'NOT RUNNING')"
echo "NFS: $(systemctl is-active nfs-server 2>/dev/null || echo 'NOT RUNNING')"
```

View file

@ -0,0 +1,324 @@
# Pre-Flight Probe Gate for Pipeline Efficiency
Insert a cheap inspection step before expensive processing in a pipeline. Probe the input (ffprobe, mediainfo, file headers, checksums) to skip work that will be wasted — wrong format, already optimized, below quality threshold, or too large to process safely. Log every decision for an audit trail. Keep a post-processing safety net as backup.
Use this when your pipeline processes files in bulk and a significant percentage of inputs don't need the expensive step, or when processing the wrong input would waste time, storage, or GPU cycles.
---
## Prerequisites
- A pipeline with at least one expensive processing step (transcoding, inference, embedding, etc.)
- A probe tool that can inspect inputs cheaply (< 1 second per file)
- Clear criteria for what constitutes a "skip" vs "process" decision
---
## Inputs
Prompt the user for all of these before executing:
```
PIPELINE_NAME= # Human-readable name (e.g., "video-transcoder", "pdf-extractor")
PROBE_TOOL= # Inspection tool (e.g., "ffprobe", "mediainfo", "file", "pdfinfo")
INPUT_DIR= # Where the pipeline reads inputs (e.g., "/opt/pipeline/incoming")
OUTPUT_DIR= # Where processed outputs go (e.g., "/opt/pipeline/processed")
SKIP_DIR= # Where skipped inputs go (e.g., "/opt/pipeline/skipped")
FAIL_DIR= # Where failed inputs go (e.g., "/opt/pipeline/failed")
LOG_FILE= # Decision log path (e.g., "/opt/pipeline/logs/probe-gate.log")
```
---
## Step 1: Define Skip Criteria
Enumerate the conditions under which a file should skip the expensive step. Be specific — vague criteria lead to false positives.
### Common probe checks
| Check | Probe Command | Skip When |
|-------|---------------|-----------|
| Video codec | `ffprobe -show_entries stream=codec_name` | Already target codec (e.g., already HEVC) |
| Audio bitrate | `ffprobe -show_entries stream=bit_rate` | Below minimum quality threshold |
| Resolution | `ffprobe -show_entries stream=width,height` | Below minimum (e.g., < 360p) |
| Duration | `ffprobe -show_entries format=duration` | Exceeds safe processing limit |
| File size | `stat -c%s` | Zero bytes, or exceeds storage budget |
| PDF pages | `pdfinfo file.pdf \| grep Pages` | Too many pages for OCR budget |
| Image format | `file --mime-type` | Already target format |
| Container format | `ffprobe -show_entries format=format_name` | Unsupported container |
| Corruption | `ffprobe -v error` exit code | Non-zero = corrupt file |
| Existing output | `test -f $OUTPUT_DIR/$(basename)` | Output already exists (dedup) |
### Gate
Write your criteria as a decision table:
```
Criterion 1: <property> <operator> <value> → SKIP (reason: "<why>")
Criterion 2: <property> <operator> <value> → SKIP (reason: "<why>")
Criterion 3: <property> not available → SKIP (reason: "probe failed")
Default: → PROCESS
```
---
## Step 2: Write the Probe Gate Function
```bash
#!/bin/bash
# Pre-flight probe gate for $PIPELINE_NAME
# Returns: 0 = process, 1 = skip, 2 = fail (corrupt/unreadable)
LOGFILE="$LOG_FILE"
probe_gate() {
local INPUT="$1"
local BASENAME=$(basename "$INPUT")
local TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
# ──── Existence check ────
if [[ ! -f "$INPUT" ]]; then
echo "[$TIMESTAMP] FAIL $BASENAME reason=file_not_found" >> "$LOGFILE"
return 2
fi
# ──── Size check ────
local SIZE=$(stat -c%s "$INPUT" 2>/dev/null)
if (( SIZE == 0 )); then
echo "[$TIMESTAMP] SKIP $BASENAME reason=zero_bytes size=0" >> "$LOGFILE"
return 1
fi
# ──── Probe the input ────
# Adapt this section to your probe tool and criteria
local PROBE_OUTPUT
PROBE_OUTPUT=$($PROBE_TOOL <probe-specific-flags> "$INPUT" 2>/dev/null)
local PROBE_EXIT=$?
if (( PROBE_EXIT != 0 )); then
echo "[$TIMESTAMP] FAIL $BASENAME reason=probe_failed exit=$PROBE_EXIT" >> "$LOGFILE"
return 2
fi
# ──── Apply skip criteria ────
# Example: check if already target codec
local CODEC=$(echo "$PROBE_OUTPUT" | grep codec_name | head -1 | cut -d= -f2)
if [[ "$CODEC" == "hevc" ]]; then
echo "[$TIMESTAMP] SKIP $BASENAME reason=already_hevc codec=$CODEC" >> "$LOGFILE"
return 1
fi
# Example: check if below minimum resolution
local HEIGHT=$(echo "$PROBE_OUTPUT" | grep '^height=' | head -1 | cut -d= -f2)
if (( HEIGHT < 240 )); then
echo "[$TIMESTAMP] SKIP $BASENAME reason=below_min_resolution height=$HEIGHT" >> "$LOGFILE"
return 1
fi
# ──── Passed all checks ────
echo "[$TIMESTAMP] PASS $BASENAME codec=$CODEC height=${HEIGHT} size=$SIZE" >> "$LOGFILE"
return 0
}
```
### Key design decisions
- **Return codes**: 0 = process (matches shell "success" convention), 1 = skip, 2 = fail. Callers use `$?` to branch.
- **Structured log lines**: Every decision logged with timestamp, verdict, filename, and reason. Parseable by grep/awk for reporting.
- **Probe errors = FAIL, not SKIP**: If the probe itself fails, the file might be corrupt — route to fail directory for manual inspection rather than silently skipping.
---
## Step 3: Integrate into the Pipeline
### Option A: Inline in processing loop
```bash
for INPUT in "$INPUT_DIR"/*; do
probe_gate "$INPUT"
case $? in
0) process_file "$INPUT" # Expensive step
mv "$INPUT" "$OUTPUT_DIR/"
;;
1) mv "$INPUT" "$SKIP_DIR/" # Skipped — preserve for audit
;;
2) mv "$INPUT" "$FAIL_DIR/" # Failed probe — needs investigation
;;
esac
done
```
### Option B: As a pre-filter in a wrapper script
If the expensive step is a binary called by a service (see `binary-wrapper-interception.md`), add the probe gate to the wrapper:
```bash
# In the wrapper script, before exec:
probe_gate "$INPUT_FILE"
GATE_RESULT=$?
if (( GATE_RESULT == 1 )); then
echo "[WRAPPER] $(date) SKIPPED: $INPUT_FILE" >> "$LOGFILE"
exit 0 # Success — nothing to do
fi
if (( GATE_RESULT == 2 )); then
echo "[WRAPPER] $(date) PROBE FAILED: $INPUT_FILE" >> "$LOGFILE"
exit 1 # Error — caller should retry or alert
fi
# Gate passed — proceed with expensive processing
exec $REAL_BINARY "$@"
```
### Option C: In a Python pipeline script
```python
import subprocess, shutil, os
def probe_gate(input_path: str) -> tuple[str, dict]:
"""Returns (verdict, metadata) where verdict is 'process', 'skip', or 'fail'."""
if not os.path.exists(input_path):
return 'fail', {'reason': 'file_not_found'}
size = os.path.getsize(input_path)
if size == 0:
return 'skip', {'reason': 'zero_bytes', 'size': 0}
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-show_entries', 'stream=codec_name,height',
'-of', 'flat', input_path],
capture_output=True, text=True
)
if result.returncode != 0:
return 'fail', {'reason': 'probe_failed', 'exit': result.returncode}
# Parse and apply criteria...
return 'process', {'codec': codec, 'height': height, 'size': size}
```
---
## Step 4: Add Post-Processing Safety Net
The probe gate is the primary filter, but add a post-processing check as backup. This catches cases where the probe was wrong (e.g., file reported as H.264 but was actually corrupt).
```bash
post_process_check() {
local OUTPUT="$1"
local TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
# Size gate: output should be at least 10% of input size
local INPUT_SIZE=$2
local OUTPUT_SIZE=$(stat -c%s "$OUTPUT" 2>/dev/null)
if (( OUTPUT_SIZE < INPUT_SIZE / 10 )); then
echo "[$TIMESTAMP] POST-FAIL $OUTPUT reason=output_too_small input=${INPUT_SIZE} output=${OUTPUT_SIZE}" >> "$LOGFILE"
return 1
fi
# Integrity check: verify output is valid
$PROBE_TOOL -v error "$OUTPUT" 2>/dev/null
if (( $? != 0 )); then
echo "[$TIMESTAMP] POST-FAIL $OUTPUT reason=output_corrupt" >> "$LOGFILE"
return 1
fi
echo "[$TIMESTAMP] POST-PASS $OUTPUT size=$OUTPUT_SIZE" >> "$LOGFILE"
return 0
}
```
---
## Step 5: Reporting
Use the structured log to generate reports:
```bash
# Decision breakdown
echo "=== Probe Gate Report ==="
echo "Processed: $(grep -c ' PASS ' $LOG_FILE)"
echo "Skipped: $(grep -c ' SKIP ' $LOG_FILE)"
echo "Failed: $(grep -c ' FAIL ' $LOG_FILE)"
echo ""
# Top skip reasons
echo "Skip reasons:"
grep ' SKIP ' $LOG_FILE | grep -oP 'reason=\S+' | sort | uniq -c | sort -rn
# Failed files needing attention
echo ""
echo "Failed files:"
grep ' FAIL ' $LOG_FILE | tail -10
```
---
## Troubleshooting
### Probe is slow (> 1 second per file)
Some probe tools read more of the file than necessary. For ffprobe, use `-analyzeduration 1000000 -probesize 1000000` to limit how much of the file it reads. For large PDFs, `pdfinfo` is faster than opening the file in Python.
### Probe reports wrong codec/format
Some files have mismatched container and stream codecs. Probe the stream level, not the container:
```bash
ffprobe -v quiet -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 "$INPUT"
```
### Skipped files that should have been processed
Review the skip log. Lower the threshold or add exceptions for edge cases. The skip directory preserves files for re-processing if criteria change.
### Post-processing catches failures the probe missed
This is the safety net working as intended. Investigate why the probe didn't catch it — the input may have unusual characteristics. Add a new probe criterion if the pattern is common.
---
## Usage Examples
### PeerTube H.265 transcoding pipeline (cortex)
```
PIPELINE_NAME=video-transcoder
PROBE_TOOL=ffprobe
INPUT_DIR=/opt/bulk-import/completed
OUTPUT_DIR=/opt/bulk-import/transcoded
SKIP_DIR=/opt/bulk-import/skipped
FAIL_DIR=/opt/bulk-import/failed
Probe criteria:
- codec_name == "hevc" → SKIP (already H.265)
- height < 240 → SKIP (too low quality to bother)
- duration == 0 → FAIL (corrupt or audio-only)
- probe exit != 0 → FAIL (unreadable)
Post-processing safety net:
- output size < 10% of input → FAIL (transcode produced garbage)
- ffprobe on output fails → FAIL (corrupt output)
Result: Saved ~15% of GPU cycles by skipping already-optimized files.
```
### PDF extraction pipeline (RECON on CT 130)
```
PIPELINE_NAME=pdf-extractor
PROBE_TOOL=pdfinfo
INPUT_DIR=/mnt/library/incoming
OUTPUT_DIR=/opt/recon/extracted
Probe criteria:
- Pages > 500 → route to Gemini Vision (OCR too slow)
- Pages == 0 → FAIL (corrupt PDF)
- File size < 1KB → SKIP (empty/placeholder)
- Encrypted: yes → SKIP (can't extract without password)
- Already in SQLite status table → SKIP (dedup)
```
---
*Last updated: 2026-02-17*

View file

@ -101,6 +101,16 @@ ssh root@$PVE_HOST "qm set $VMID \
--boot order=scsi0"
```
### Alternative: Use standard Echo6 cloud-init snippet
If the snippet is already on the node (deployed by `echo6-onboard-node.sh`), you can use it instead of the manual `--ciuser`/`--cipassword` config above. This pre-installs sshpass, curl, git, htop, and other standard packages via cloud-init:
```bash
ssh root@$PVE_HOST "qm set $VMID --cicustom \"user=local:snippets/echo6-base-userdata.yml\""
```
**Note:** You still need `--ipconfig0`, `--nameserver`, `--searchdomain`, `--sshkeys`, and `--boot` from the block above. The snippet only covers packages and `manage_etc_hosts`.
## Step 5 — GPU Passthrough (if enabled)
Skip if `GPU_PASSTHROUGH=no`.
@ -144,7 +154,7 @@ ssh root@$PVE_HOST "qm terminal $VMID"
```bash
ssh zvx@$VM_IP 'sudo apt-get update && sudo apt-get install -y \
curl wget git htop iotop tmux vim \
sshpass curl wget git htop iotop tmux vim \
rsync tree jq unzip \
net-tools dnsutils \
python3 python3-pip python3-venv \

View file

@ -0,0 +1,215 @@
# Runbook: Onboard a Proxmox Node
You install Proxmox. You give CC an IP and a root password. CC does the rest.
---
## Current Cluster
| Alias | Local IP | Tailscale IP |
|----------|-----------------|-----------------|
| data | 192.168.1.240 | 100.64.0.20 |
| utility | 192.168.1.241 | 100.64.0.19 |
| cloud | 192.168.1.242 | 100.64.0.22 |
| media | 192.168.1.243 | 100.64.0.21 |
Management host: **cortex**
---
## Inputs
```
NODE_IP= # e.g. 192.168.1.244
NODE_ALIAS= # e.g. storage (lowercase, no dots)
ROOT_PASS= # root password for initial key copy
```
---
## Phase 1: SSH Access
Nothing works without this.
```bash
# Ensure sshpass is installed
which sshpass || sudo apt install -y sshpass
# Test access immediately
sshpass -p "$ROOT_PASS" ssh \
-o StrictHostKeyChecking=accept-new \
-o IdentitiesOnly=yes \
-o PreferredAuthentications=password \
root@$NODE_IP 'hostname'
```
### Gate
Must return the hostname. **Stop if this fails.**
### Add host alias
```bash
# Ensure ~/.ssh/config has global defaults (idempotent)
grep -q "IdentitiesOnly yes" ~/.ssh/config 2>/dev/null || cat >> ~/.ssh/config << 'EOF'
Host *
IdentitiesOnly yes
StrictHostKeyChecking accept-new
ConnectTimeout 10
ServerAliveInterval 30
ServerAliveCountMax 3
EOF
# Add alias (idempotent)
grep -q "Host $NODE_ALIAS$" ~/.ssh/config 2>/dev/null || cat >> ~/.ssh/config << EOF
Host $NODE_ALIAS
HostName $NODE_IP
User root
EOF
```
### Optional: Set up key auth
Eliminates the need for sshpass on every command to this node.
```bash
ls ~/.ssh/id_ed25519 || ssh-keygen -t ed25519 -C "cortex" -N "" -f ~/.ssh/id_ed25519
sshpass -p "$ROOT_PASS" ssh-copy-id \
-o StrictHostKeyChecking=accept-new \
-o IdentitiesOnly=yes \
-o PreferredAuthentications=password \
root@$NODE_IP
# Verify key auth works (no password)
ssh $NODE_ALIAS 'hostname'
```
### How CC connects for the rest of this runbook
If key auth is set up:
```bash
ssh $NODE_ALIAS '<command>'
```
If not:
```bash
sshpass -p "$ROOT_PASS" ssh $NODE_ALIAS '<command>'
```
---
## Phase 2: Base Configuration
```bash
ssh $NODE_ALIAS 'apt update && apt dist-upgrade -y'
ssh $NODE_ALIAS 'timedatectl set-timezone America/Boise'
ssh $NODE_ALIAS 'timedatectl status | grep -i sync'
# Disable enterprise repo
ssh $NODE_ALIAS 'sed -i "s/^deb/# deb/" /etc/apt/sources.list.d/pve-enterprise.list 2>/dev/null; true'
# Add no-subscription repo
ssh $NODE_ALIAS 'grep -q "pve-no-subscription" /etc/apt/sources.list.d/pve-no-subscription.list 2>/dev/null || \
echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" > /etc/apt/sources.list.d/pve-no-subscription.list'
```
---
## Phase 3: Tailscale
```bash
ssh $NODE_ALIAS 'curl -fsSL https://tailscale.com/install.sh | sh'
ssh $NODE_ALIAS 'tailscale up --login-server=https://<HEADSCALE_URL> --auth-key=<PREAUTH_KEY>'
# Get Tailscale IP and add alias
TSIP=$(ssh $NODE_ALIAS 'tailscale ip -4')
echo "Tailscale IP: $TSIP"
grep -q "Host ts-$NODE_ALIAS$" ~/.ssh/config 2>/dev/null || cat >> ~/.ssh/config << EOF
Host ts-$NODE_ALIAS
HostName $TSIP
User root
EOF
ssh ts-$NODE_ALIAS 'hostname'
```
---
## Phase 4: Verify Cluster Membership
You join the node to the cluster. CC verifies it's there.
```bash
ssh $NODE_ALIAS 'pvecm status 2>/dev/null | grep "Cluster Member"'
ssh data 'pvecm nodes'
```
If not in the cluster yet, **stop and tell the user**. Do not run `pvecm add`.
---
## Phase 5: Verify
```bash
# Authentik SSO (syncs via cluster)
ssh $NODE_ALIAS 'pveum realm list | grep authentik'
# Storage
ssh $NODE_ALIAS 'pvesm status'
ssh $NODE_ALIAS 'lsblk && echo "---" && vgs && lvs'
```
---
## Phase 6: Update Inventory
Add to CLAUDE.md cluster table:
```
| <NODE_ALIAS> | <NODE_IP> | <TSIP> |
```
Update any hardcoded node lists:
- proxmox-audit.sh (NODES array)
- Monitoring/backup targets
---
## Final Verification
Every line must say OK.
```bash
echo "=== $NODE_ALIAS ==="
echo -n "SSH (local): "; ssh $NODE_ALIAS 'echo OK' 2>&1
echo -n "SSH (tailscale): "; ssh ts-$NODE_ALIAS 'echo OK' 2>&1
echo -n "Cluster: "; ssh $NODE_ALIAS 'pvecm status 2>/dev/null | grep -q "Cluster Member: Yes" && echo OK || echo FAIL'
echo -n "Tailscale: "; ssh $NODE_ALIAS 'tailscale status --self >/dev/null 2>&1 && echo OK || echo FAIL'
echo -n "OIDC realm: "; ssh $NODE_ALIAS 'pveum realm list 2>/dev/null | grep -q authentik && echo OK || echo FAIL'
echo -n "Storage: "; ssh $NODE_ALIAS 'pvesm status >/dev/null 2>&1 && echo OK || echo FAIL'
echo -n "PVE version: "; ssh $NODE_ALIAS 'pveversion'
echo -n "Time sync: "; ssh $NODE_ALIAS 'timedatectl show -p NTPSynchronized --value'
```
---
## Troubleshooting
**"Too many authentication failures"**
`IdentitiesOnly yes` missing from `Host *` in `~/.ssh/config`.
**sshpass "Permission denied"**
Add `-o PreferredAuthentications=password -o IdentitiesOnly=yes`.
**Cluster join corosync errors**
Check `/etc/hosts` on all nodes includes the new hostname and IP.
**Authentik realm missing**
Check `systemctl status pve-cluster`. Realm syncs via pmxcfs in `/etc/pve/domains.cfg`.
**Can't migrate VMs to node**
Storage mismatch. Compare `pvesm status` on both nodes.

View file

@ -0,0 +1,189 @@
# RECON Operations Runbook
## Service Info
- **Host:** recon LXC (CT 130 on data node)
- **IP:** 192.168.1.130 / 100.64.0.24
- **Install:** /opt/recon/
- **User:** zvx
- **Service:** `recon.service` (systemd)
## Service Management
```bash
ssh zvx@100.64.0.24
sudo systemctl start|stop|restart|status recon
journalctl -u recon -f
```
## Health Check
```bash
curl -s http://100.64.0.24:8420/api/health | python3 -m json.tool
# Returns: healthy (200), degraded/unhealthy (503)
# Checks: Qdrant, TEI, NFS, Gemini keys, pipeline counts
```
## Pipeline Status
```bash
ssh zvx@100.64.0.24
cd /opt/recon && source venv/bin/activate
python3 recon.py status # Summary counts
python3 recon.py failures # Failed documents
python3 recon.py search "query" # Test search
```
## Dashboard
- **URL:** http://100.64.0.24:8420
- Shows: pipeline progress, per-source breakdown, Qdrant stats
- Auto-refreshes every 30s
## Common Operations
```bash
cd /opt/recon && source venv/bin/activate
# Add a PDF
python3 recon.py upload --file /path/to.pdf --category "Reference"
# Add web content
python3 recon.py ingest-url "https://example.com/article" --process
# Crawl a website
python3 recon.py crawl "https://docs.example.com" --process
# Manual pipeline run (normally automatic via service)
python3 recon.py extract
python3 recon.py enrich
python3 recon.py embed
# Scan library for new PDFs (normally hourly via service)
python3 recon.py scan
python3 recon.py queue
```
## Dependencies
| Service | Host | Port | Purpose |
|---------|------|------|---------|
| Qdrant | cortex | 6333 | Vector DB (recon_knowledge collection) |
| TEI | cortex | 8090 | Text embeddings (bge-m3, 1024-dim) |
| Ollama | cortex | 11434 | Chat model for Aurora RAG |
| NFS | pi-nas | — | /mnt/library (PDF source) |
| Gemini API | Google | — | Enrichment + vision OCR (4 keys in .env) |
| Contabo VPS | 100.64.0.1 | — | Backup destination |
## Backups
- **Destination:** `root@100.64.0.1:/opt/backups/recon/`
- **Full sync (concepts, text, DB, config):** every 6 hours via cron
- **DB snapshot only:** every 2 hours via cron
- **Script:** `/opt/recon/scripts/backup.sh`
### Verify backups
```bash
ssh root@100.64.0.1 'ls -lh /opt/backups/recon/recon_*.db && du -sh /opt/backups/recon/'
```
## Troubleshooting
### Pipeline stalled (no progress)
```bash
journalctl -u recon -n 50 # Check errors
curl -s http://100.64.0.24:8420/api/health # Check dependencies
sudo systemctl restart recon # Restart
```
### Gemini rate limits (429 errors)
Built-in: exponential backoff 5s→10s→20s→40s→80s with jitter. Window failures skip that window and continue — partial enrichment beats zero.
If sustained: reduce `enrich_workers` in config.yaml, restart.
### Qdrant down
```bash
ssh zvx@cortex
docker ps | grep qdrant
docker restart qdrant
# If data lost: ssh zvx@100.64.0.24 'cd /opt/recon && source venv/bin/activate && python3 recon.py rebuild'
```
### TEI down
```bash
ssh zvx@cortex
docker ps | grep tei
docker restart tei
```
### NFS mount lost
```bash
ssh zvx@100.64.0.24
mount | grep library
sudo mount -a
sudo systemctl restart recon
```
### Reset stuck documents
```bash
cd /opt/recon && source venv/bin/activate
# Find stuck transitional states
sqlite3 data/recon.db "SELECT status, COUNT(*) FROM documents WHERE status IN ('extracting','enriching','embedding') GROUP BY status;"
# Reset them
sqlite3 data/recon.db "UPDATE documents SET status='queued' WHERE status='extracting';"
sqlite3 data/recon.db "UPDATE documents SET status='extracted' WHERE status='enriching';"
sqlite3 data/recon.db "UPDATE documents SET status='enriched' WHERE status='embedding';"
```
### Full recovery from Contabo backup
```bash
ssh zvx@100.64.0.24
sudo systemctl stop recon
rsync -av root@100.64.0.1:/opt/backups/recon/concepts/ /opt/recon/data/concepts/
rsync -av root@100.64.0.1:/opt/backups/recon/text/ /opt/recon/data/text/
# Pick the latest DB backup
rsync -av root@100.64.0.1:/opt/backups/recon/recon_latest.db /opt/recon/data/recon.db
cd /opt/recon && source venv/bin/activate
python3 recon.py rebuild # Rebuilds Qdrant from concept JSONs
sudo systemctl start recon
```
## Key Files
| Path | Purpose |
|------|---------|
| `/opt/recon/config.yaml` | All configuration |
| `/opt/recon/.env` | Gemini API keys (GEMINI_KEY_1 through GEMINI_KEY_4) |
| `/opt/recon/data/recon.db` | SQLite status DB |
| `/opt/recon/data/concepts/` | Gemini extraction results (CRITICAL — costs $ to regenerate) |
| `/opt/recon/data/text/` | Extracted page text (regenerable from PDFs) |
| `/opt/recon/PROJECT-BIBLE.md` | Full system documentation |
| `/opt/recon/scripts/backup.sh` | Backup script |
| `/opt/recon/scripts/validate.py` | Pipeline consistency checker |
| `/opt/recon/scripts/rebuild_qdrant.py` | Nuclear Qdrant rebuild |
## Pipeline Architecture
```
/mnt/library/ (NFS)
▼ hourly scan
[Catalogue] → [Queue] → [Extract] → [Enrich] → [Embed] → [Complete]
4 workers 16 workers 4 workers
PyPDF2 Gemini TEI+Qdrant
pdftotext 2.0 Flash bge-m3
Tesseract 1024-dim
Gemini Vision
```
---
*Last updated: 2026-02-16 — Initial creation*

View file

@ -0,0 +1,464 @@
# RECON Dashboard Service Integration
Add a management UI for a remote service to a Flask/FastAPI dashboard. The pattern: SSH key trust between the dashboard host and the target, scoped sudoers for specific commands, a REST API layer (`GET /api/{service}/status` + `POST /api/{service}/{action}`), and a frontend panel with status indicator, action buttons, and live feedback.
Use this when you have a service running on a remote LXC/VM that needs a web management interface — start/stop/restart, status checks, log tailing, or config hot-reload — without SSH-ing into the box manually.
---
## Prerequisites
- A running Flask or FastAPI dashboard (e.g., RECON on CT 130, WATCHTOWER on Contabo)
- The target service running on a reachable host (LXC, VM, or bare metal)
- SSH access from the dashboard host to the target host
- The dashboard runs as a known user (e.g., `zvx`, `recon`, `watchtower`)
---
## Inputs
Prompt the user for all of these before executing:
```
DASHBOARD_HOST= # Host running the dashboard (e.g., "192.168.1.130", "CT 130")
DASHBOARD_USER= # User the dashboard runs as (e.g., "zvx")
DASHBOARD_APP_PATH= # Path to the dashboard app (e.g., "/opt/recon/lib/api.py")
DASHBOARD_STATIC_PATH= # Path to frontend files (e.g., "/opt/recon/lib/static/")
TARGET_HOST= # Host running the service to manage (e.g., "192.168.1.170")
TARGET_USER= # User to SSH as on the target (e.g., "zvx")
SERVICE_NAME= # systemd service name (e.g., "peertube", "pt-downloader")
SERVICE_DISPLAY_NAME= # Human-readable name for the UI (e.g., "PeerTube", "Downloader")
SERVICE_SLUG= # URL-safe slug (e.g., "peertube", "downloader")
ALLOWED_ACTIONS= # Comma-separated actions (e.g., "start,stop,restart,status,logs")
```
---
## Step 1: Set Up SSH Key Trust
The dashboard host must be able to SSH to the target without a password prompt.
### Generate key (if not already present)
```bash
ssh $DASHBOARD_HOST "test -f /home/$DASHBOARD_USER/.ssh/id_ed25519 || \
ssh-keygen -t ed25519 -N '' -f /home/$DASHBOARD_USER/.ssh/id_ed25519"
```
### Copy public key to target
```bash
# Get the public key
PUBKEY=$(ssh $DASHBOARD_HOST "cat /home/$DASHBOARD_USER/.ssh/id_ed25519.pub")
# Add to target's authorized_keys
ssh $TARGET_HOST "mkdir -p /home/$TARGET_USER/.ssh && \
echo '$PUBKEY' >> /home/$TARGET_USER/.ssh/authorized_keys && \
chmod 700 /home/$TARGET_USER/.ssh && \
chmod 600 /home/$TARGET_USER/.ssh/authorized_keys && \
chown -R $TARGET_USER:$TARGET_USER /home/$TARGET_USER/.ssh"
```
### Gate
```bash
ssh $DASHBOARD_HOST "ssh -o BatchMode=yes -o ConnectTimeout=5 $TARGET_USER@$TARGET_HOST 'hostname'"
```
Must return the target hostname without prompting for a password. If it fails:
- "Permission denied (publickey)" → key not in authorized_keys, or wrong user
- "Host key verification failed" → add `-o StrictHostKeyChecking=accept-new` for first connection
- Timeout → network issue, firewall, or wrong IP
---
## Step 2: Configure Scoped Sudoers on Target
Grant the target user passwordless sudo for **only** the specific commands the dashboard needs. Never use `NOPASSWD: ALL` for service integrations.
```bash
ssh root@$TARGET_HOST "cat > /etc/sudoers.d/${SERVICE_SLUG}-mgmt << 'SUDOERS'
# Allow $TARGET_USER to manage $SERVICE_NAME via dashboard
$TARGET_USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl start $SERVICE_NAME
$TARGET_USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop $SERVICE_NAME
$TARGET_USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart $SERVICE_NAME
$TARGET_USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl status $SERVICE_NAME
$TARGET_USER ALL=(ALL) NOPASSWD: /usr/bin/journalctl -u $SERVICE_NAME *
SUDOERS
chmod 440 /etc/sudoers.d/${SERVICE_SLUG}-mgmt"
```
### Gate
```bash
ssh $DASHBOARD_HOST "ssh $TARGET_USER@$TARGET_HOST 'sudo systemctl status $SERVICE_NAME'"
```
Must return service status without a password prompt. If "sudo: a password is required", the sudoers file has a syntax error or isn't being loaded — check `visudo -cf /etc/sudoers.d/${SERVICE_SLUG}-mgmt`.
---
## Step 3: Add API Endpoints
Add REST endpoints to the dashboard app for status checks and actions.
### Flask pattern
```python
import subprocess
import shlex
SERVICE_INTEGRATIONS = {
'$SERVICE_SLUG': {
'display_name': '$SERVICE_DISPLAY_NAME',
'target_host': '$TARGET_USER@$TARGET_HOST',
'service_name': '$SERVICE_NAME',
'allowed_actions': ['start', 'stop', 'restart', 'status', 'logs'],
},
}
def ssh_cmd(host: str, cmd: str, timeout: int = 10) -> dict:
"""Execute a command on a remote host via SSH."""
full_cmd = f"ssh -o BatchMode=yes -o ConnectTimeout=5 {host} {shlex.quote(cmd)}"
try:
result = subprocess.run(
full_cmd, shell=True, capture_output=True, text=True, timeout=timeout
)
return {
'success': result.returncode == 0,
'stdout': result.stdout.strip(),
'stderr': result.stderr.strip(),
'exit_code': result.returncode,
}
except subprocess.TimeoutExpired:
return {'success': False, 'stdout': '', 'stderr': 'SSH command timed out', 'exit_code': -1}
@app.route('/api/services/<slug>/status')
def api_service_status(slug):
svc = SERVICE_INTEGRATIONS.get(slug)
if not svc:
return jsonify({'error': 'Unknown service'}), 404
result = ssh_cmd(svc['target_host'], f"sudo systemctl status {svc['service_name']}")
# Parse systemctl status output
active = 'active (running)' in result.get('stdout', '')
return jsonify({
'service': svc['display_name'],
'active': active,
'raw': result['stdout'],
})
@app.route('/api/services/<slug>/<action>', methods=['POST'])
def api_service_action(slug, action):
svc = SERVICE_INTEGRATIONS.get(slug)
if not svc:
return jsonify({'error': 'Unknown service'}), 404
if action not in svc['allowed_actions']:
return jsonify({'error': f'Action {action} not allowed'}), 403
if action == 'logs':
result = ssh_cmd(
svc['target_host'],
f"sudo journalctl -u {svc['service_name']} -n 50 --no-pager",
timeout=15,
)
elif action in ('start', 'stop', 'restart'):
result = ssh_cmd(svc['target_host'], f"sudo systemctl {action} {svc['service_name']}")
elif action == 'status':
result = ssh_cmd(svc['target_host'], f"sudo systemctl status {svc['service_name']}")
else:
return jsonify({'error': 'Unknown action'}), 400
return jsonify(result)
```
### FastAPI pattern
Same logic, different decorators:
```python
@app.get('/api/services/{slug}/status')
async def api_service_status(slug: str):
# Same implementation, wrapped in run_in_executor for async
@app.post('/api/services/{slug}/{action}')
async def api_service_action(slug: str, action: str):
# Same implementation
```
### Gate
Restart the dashboard and test:
```bash
curl -s http://$DASHBOARD_HOST:8420/api/services/$SERVICE_SLUG/status | python3 -m json.tool
```
Must return JSON with `active: true/false` and service details.
```bash
curl -s -X POST http://$DASHBOARD_HOST:8420/api/services/$SERVICE_SLUG/restart | python3 -m json.tool
```
Must return `success: true`.
---
## Step 4: Add Frontend Panel
Add a service management panel to the dashboard UI. This goes in the appropriate tab (e.g., Upload, Dashboard, or a new Services tab).
```html
<!-- Service Management Panel: $SERVICE_DISPLAY_NAME -->
<div class="service-panel" id="panel-$SERVICE_SLUG">
<h3>$SERVICE_DISPLAY_NAME</h3>
<!-- Status indicator -->
<div class="status-row">
<span class="status-dot" id="status-$SERVICE_SLUG"></span>
<span id="status-text-$SERVICE_SLUG">Checking...</span>
<button onclick="refreshStatus('$SERVICE_SLUG')" class="btn-sm">Refresh</button>
</div>
<!-- Action buttons -->
<div class="action-buttons">
<button onclick="serviceAction('$SERVICE_SLUG', 'restart')" class="btn btn-warning">Restart</button>
<button onclick="serviceAction('$SERVICE_SLUG', 'stop')" class="btn btn-danger">Stop</button>
<button onclick="serviceAction('$SERVICE_SLUG', 'start')" class="btn btn-success">Start</button>
<button onclick="serviceAction('$SERVICE_SLUG', 'logs')" class="btn btn-info">View Logs</button>
</div>
<!-- Feedback area -->
<pre id="feedback-$SERVICE_SLUG" class="feedback-box" style="display:none;"></pre>
</div>
```
```javascript
// Service management JS
async function refreshStatus(slug) {
const dot = document.getElementById(`status-${slug}`);
const text = document.getElementById(`status-text-${slug}`);
try {
const resp = await fetch(`/api/services/${slug}/status`);
const data = await resp.json();
dot.className = data.active ? 'status-dot active' : 'status-dot inactive';
text.textContent = data.active ? 'Running' : 'Stopped';
} catch (e) {
dot.className = 'status-dot error';
text.textContent = 'Unreachable';
}
}
async function serviceAction(slug, action) {
const feedback = document.getElementById(`feedback-${slug}`);
feedback.style.display = 'block';
feedback.textContent = `Executing ${action}...`;
try {
const resp = await fetch(`/api/services/${slug}/${action}`, { method: 'POST' });
const data = await resp.json();
feedback.textContent = data.stdout || data.stderr || (data.success ? 'Done' : 'Failed');
// Refresh status after action
if (['start', 'stop', 'restart'].includes(action)) {
setTimeout(() => refreshStatus(slug), 2000);
}
} catch (e) {
feedback.textContent = `Error: ${e.message}`;
}
}
// Auto-refresh status every 30 seconds
setInterval(() => {
document.querySelectorAll('.service-panel').forEach(panel => {
const slug = panel.id.replace('panel-', '');
refreshStatus(slug);
});
}, 30000);
// Initial load
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.service-panel').forEach(panel => {
const slug = panel.id.replace('panel-', '');
refreshStatus(slug);
});
});
```
```css
.status-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.status-dot.active { background: #22c55e; }
.status-dot.inactive { background: #ef4444; }
.status-dot.error { background: #f59e0b; }
.feedback-box {
background: #1e1e2e;
color: #cdd6f4;
padding: 12px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
font-size: 12px;
margin-top: 8px;
}
```
---
## Step 5: Verify End-to-End
1. **Load the dashboard** in a browser: `http://$DASHBOARD_HOST:8420/`
2. **Check status indicator**: should show green dot + "Running" (or red + "Stopped")
3. **Click Restart**: feedback box should show systemctl output, status should flip briefly then return to Running
4. **Click View Logs**: should show last 50 journal lines
5. **Click Stop**: status should change to Stopped (red)
6. **Click Start**: status should change to Running (green)
---
## Adding More Services
To integrate a second service, repeat Steps 1-4 with new inputs. The `SERVICE_INTEGRATIONS` dict supports multiple entries:
```python
SERVICE_INTEGRATIONS = {
'peertube': { ... },
'downloader': {
'display_name': 'Bulk Downloader',
'target_host': 'zvx@192.168.1.170',
'service_name': 'pt-downloader',
'allowed_actions': ['start', 'stop', 'restart', 'status', 'logs'],
},
'transcoder': {
'display_name': 'H.265 Transcoder',
'target_host': 'zvx@192.168.1.150',
'service_name': 'pt-transcoder',
'allowed_actions': ['start', 'stop', 'restart', 'status', 'logs'],
},
}
```
Each service gets its own panel in the UI, its own sudoers file on the target, and its own API routes (all handled by the generic `/<slug>/<action>` pattern).
---
## Security Considerations
- **Scoped sudoers**: Only allow the specific `systemctl` and `journalctl` commands needed. Never `NOPASSWD: ALL`.
- **SSH BatchMode**: `BatchMode=yes` ensures SSH never falls back to interactive password prompt. If key auth fails, the command fails immediately.
- **Action allowlist**: The `allowed_actions` list prevents the API from executing arbitrary commands. Only listed actions are accepted.
- **No shell injection**: Use `shlex.quote()` on any user-provided or variable input before passing to `subprocess.run(shell=True)`. Or use `subprocess.run(cmd_list)` with a list to avoid shell entirely.
- **Timeout on SSH**: Always set `-o ConnectTimeout` and `subprocess.run(timeout=)` to prevent the dashboard from hanging on network issues.
---
## Troubleshooting
### Status shows "Unreachable"
SSH from the dashboard host to the target is failing. Test manually:
```bash
ssh -o BatchMode=yes -o ConnectTimeout=5 $TARGET_USER@$TARGET_HOST 'hostname'
```
Common causes: SSH key not deployed, wrong user, firewall, target host down.
### "sudo: a password is required"
The sudoers file isn't working. Check:
```bash
ssh root@$TARGET_HOST "visudo -cf /etc/sudoers.d/${SERVICE_SLUG}-mgmt"
```
Must say "parsed OK". Also verify the username in the sudoers file matches `$TARGET_USER`.
### Actions work via curl but not from the browser
CORS issue. Add CORS headers to the API:
```python
# Flask
from flask_cors import CORS
CORS(app)
# Or manually:
@app.after_request
def add_cors(response):
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST'
return response
```
### Dashboard hangs when target host is down
The SSH timeout isn't working, or it's set too high. Ensure both `-o ConnectTimeout=5` (SSH) and `timeout=10` (subprocess) are set. The subprocess timeout is the hard limit.
### Log output is truncated
The `-n 50` flag limits journalctl output. Increase it, or add a `lines` query parameter:
```python
lines = request.args.get('lines', 50, type=int)
lines = min(lines, 500) # Cap to prevent abuse
```
---
## Usage Examples
### RECON managing pipeline services (CT 130 dashboard → CT 110 PeerTube)
```
DASHBOARD_HOST=192.168.1.130 (CT 130, data node)
DASHBOARD_USER=zvx
TARGET_HOST=192.168.1.170 (CT 110, media node)
SERVICE_NAME=peertube
SERVICE_SLUG=peertube
Sudoers on CT 110:
zvx ALL=(ALL) NOPASSWD: /usr/bin/systemctl start peertube
zvx ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop peertube
zvx ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart peertube
zvx ALL=(ALL) NOPASSWD: /usr/bin/systemctl status peertube
zvx ALL=(ALL) NOPASSWD: /usr/bin/journalctl -u peertube *
API endpoints:
GET /api/services/peertube/status → returns active/inactive + raw systemctl output
POST /api/services/peertube/restart → restarts PeerTube, returns success/failure
POST /api/services/peertube/logs → returns last 50 journal lines
Dashboard panel: green/red dot + Restart/Stop/Start/Logs buttons + feedback box
```
### WATCHTOWER monitoring remote services (Contabo → multiple hosts)
```
DASHBOARD_HOST=5.189.158.149 (Contabo)
DASHBOARD_USER=root
Services managed:
- peertube (CT 110): start/stop/restart/status/logs
- pt-downloader (CT 110): start/stop/restart/status/logs
- pt-importer (CT 110): start/stop/restart/status/logs
- pt-transcoder (cortex): start/stop/restart/status/logs
- recon (CT 130): start/stop/restart/status/logs
Each service has its own sudoers file on its target host,
its own entry in SERVICE_INTEGRATIONS, and its own UI panel.
```
---
*Last updated: 2026-02-17*

View file

@ -0,0 +1,272 @@
# Syncthing: Add a New Node to the Project Sync Cluster
## Overview
Adds a new machine to the Syncthing `projects` folder mesh. All nodes sync bidirectionally — new files merge, nothing is overwritten or deleted.
**Current cluster:**
| Node | Device ID (short) | Path | OS |
|------|--------------------|------|----|
| cortex | `6VP7KIB` | `/home/zvx/projects` | Ubuntu 24.04 |
| contabo | `SBYGD4P` | `/home/zvx/projects` | Ubuntu 24.04 |
| bluefin | `5ZTWIXM` | `/var/home/malice/projects` | Fedora Atomic |
| matt-desktop | `GCH6AAG` | `E:\Documents\projects` | Windows |
**Syncthing version:** v2.0.15 (all nodes must run v2.x — v1.x is incompatible)
**Config API:** All configuration changes are done via the REST API at `http://127.0.0.1:8384/rest/config` using the API key from each node's config XML.
---
## Prerequisites
- New node has network access to at least one existing node (Tailscale preferred)
- SSH access to the new node and at least one existing node
---
## Step 1: Install Syncthing v2
### Linux (apt-based)
Download the binary directly — the apt repo may only have v1.x:
```bash
curl -fsSL https://github.com/syncthing/syncthing/releases/download/v2.0.15/syncthing-linux-amd64-v2.0.15.tar.gz -o /tmp/syncthing.tar.gz
tar -xzf /tmp/syncthing.tar.gz -C /tmp
sudo cp /tmp/syncthing-linux-amd64-v2.0.15/syncthing /usr/bin/syncthing
syncthing --version # verify v2.x
```
### Linux (Homebrew — Fedora Atomic/Bluefin)
```bash
brew install syncthing
# Creates ~/.local/state/syncthing/ for config
```
### Windows
```powershell
New-Item -ItemType Directory -Force -Path "$HOME\syncthing"
Invoke-WebRequest -Uri "https://github.com/syncthing/syncthing/releases/download/v2.0.15/syncthing-windows-amd64-v2.0.15.zip" -OutFile "$HOME\syncthing\st.zip"
Expand-Archive -Path "$HOME\syncthing\st.zip" -DestinationPath "$HOME\syncthing" -Force
Copy-Item "$HOME\syncthing\syncthing-windows-amd64-v2.0.15\syncthing.exe" "$HOME\syncthing\syncthing.exe" -Force
Remove-Item -Recurse -Force "$HOME\syncthing\syncthing-windows-amd64-v2.0.15"
Remove-Item "$HOME\syncthing\st.zip"
```
---
## Step 2: Generate Config and Get Device ID
```bash
syncthing generate
# Output includes: device=XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX-XXXXXXX
```
Save the full device ID — you'll need it for all other nodes.
---
## Step 3: Set Up Auto-Start
### Linux (systemd service — existing unit)
If `syncthing@<user>.service` exists (apt installs it):
```bash
sudo systemctl enable --now syncthing@zvx
```
### Linux (systemd user service — manual)
Create `~/.config/systemd/user/syncthing.service`:
```ini
[Unit]
Description=Syncthing - Open Source Continuous File Synchronization
After=network.target
[Service]
ExecStart=/path/to/syncthing serve --no-browser --no-restart --logflags=0
Restart=on-failure
RestartSec=10
SuccessExitStatus=3 4
RestartForceExitStatus=3 4
[Install]
WantedBy=default.target
```
```bash
systemctl --user daemon-reload
systemctl --user enable --now syncthing
```
### Windows (Scheduled Task)
```powershell
schtasks /create /tn Syncthing /tr "C:\Users\administrator\syncthing\syncthing.exe serve --no-browser --no-restart" /sc onlogon /rl highest /f
```
Then start it for the current session:
```powershell
Start-Process -FilePath "$HOME\syncthing\syncthing.exe" -ArgumentList "serve","--no-browser","--no-restart" -WindowStyle Hidden
```
### Windows Firewall
Required — syncthing won't accept connections without this:
```powershell
netsh advfirewall firewall add rule name="Syncthing" dir=in action=allow program="C:\Users\administrator\syncthing\syncthing.exe" enable=yes
netsh advfirewall firewall add rule name="Syncthing-Out" dir=out action=allow program="C:\Users\administrator\syncthing\syncthing.exe" enable=yes
```
---
## Step 4: Configure the New Node via REST API
Wait for syncthing to start (~5 seconds), then get the API key:
```bash
# Linux
grep apikey ~/.local/state/syncthing/config.xml | sed 's/.*<apikey>//' | sed 's/<\/apikey.*//'
# Windows (PowerShell)
([xml](Get-Content "$env:LOCALAPPDATA\Syncthing\config.xml")).configuration.gui.apikey
```
Use the API to add devices and the projects folder. This Python snippet does it all — run it on the **new node**:
```python
import json, urllib.request
API_KEY = "<apikey from above>"
MY_DEVICE_ID = "<new node device ID>"
PROJECTS_PATH = "<local path to projects folder>" # e.g. /home/zvx/projects
# All cluster nodes — add the new node's ID to this list when updating existing nodes
DEVICES = {
"cortex": {"id": "6VP7KIB-ZHBI3AT-XO5FMY2-LFAZYM6-UMAV75U-MZZADW3-ZOBHJXY-GF26DAC", "addr": "tcp://100.64.0.14:22000"},
"contabo": {"id": "SBYGD4P-BUWMWRQ-JJYYG75-YBR4WOO-OH42WH4-IAAO33D-STJZX6O-SZA2SQ4", "addr": "tcp://100.64.0.1:22000"},
"bluefin": {"id": "5ZTWIXM-XNBUEW5-XWJM7PG-FJDMX5H-YMXM3CC-ZVS2PNO-NG2E3KJ-D5HXKQB", "addr": "dynamic"},
"matt-desktop": {"id": "GCH6AAG-IWPH6TR-7GI7THZ-DIVXRRQ-EQMRBNN-IZG7Y2F-HM6BRLX-AC3MIQ6", "addr": "dynamic"},
}
def api(method, path, data=None):
url = f"http://127.0.0.1:8384{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method,
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req).read())
cfg = api("GET", "/rest/config")
existing_ids = [d["deviceID"] for d in cfg["devices"]]
# Add all peer devices
for name, dev in DEVICES.items():
if dev["id"] not in existing_ids and dev["id"] != MY_DEVICE_ID:
cfg["devices"].append({
"deviceID": dev["id"], "name": name,
"addresses": [dev["addr"]], "compression": "metadata",
"paused": False, "autoAcceptFolders": False
})
# Add projects folder if missing
folder_ids = [f["id"] for f in cfg["folders"]]
if "projects" not in folder_ids:
all_device_ids = [d["id"] for d in DEVICES.values()] + [MY_DEVICE_ID]
cfg["folders"].append({
"id": "projects", "label": "projects",
"path": PROJECTS_PATH, "type": "sendreceive",
"rescanIntervalS": 60, "fsWatcherEnabled": True, "fsWatcherDelayS": 10,
"devices": [{"deviceID": did} for did in set(all_device_ids)]
})
api("PUT", "/rest/config", cfg)
print("New node configured")
```
---
## Step 5: Add the New Node to ALL Existing Nodes
For **each** existing node, run the following (substituting the new node's device ID and name):
```bash
APIKEY=$(grep apikey ~/.local/state/syncthing/config.xml | sed 's/.*<apikey>//' | sed 's/<\/apikey.*//')
NEW_ID="<new node device ID>"
NEW_NAME="<new node name>"
CONFIG=$(curl -s -H "X-API-Key: $APIKEY" http://127.0.0.1:8384/rest/config)
CONFIG=$(echo "$CONFIG" | python3 -c "
import json,sys
c = json.load(sys.stdin)
did = '$NEW_ID'
ids = [d['deviceID'] for d in c['devices']]
if did not in ids:
c['devices'].append({'deviceID': did, 'name': '$NEW_NAME', 'addresses': ['dynamic'], 'compression': 'metadata', 'paused': False, 'autoAcceptFolders': False})
for f in c['folders']:
if f['id'] == 'projects':
fids = [d['deviceID'] for d in f['devices']]
if did not in fids:
f['devices'].append({'deviceID': did})
json.dump(c, sys.stdout)
")
curl -s -X PUT -H "X-API-Key: $APIKEY" -H 'Content-Type: application/json' -d "$CONFIG" http://127.0.0.1:8384/rest/config
```
> **Important:** The CLI (`syncthing cli config devices add` / `syncthing cli config folders <id> devices add`) panics on v2.0.15 with a `reflect.Value.Elem on slice Value` bug. Always use the REST API instead.
---
## Step 6: Verify
Check connections from the new node:
```bash
syncthing cli show connections
```
Check sync status:
```bash
APIKEY=$(grep apikey ~/.local/state/syncthing/config.xml | sed 's/.*<apikey>//' | sed 's/<\/apikey.*//')
curl -s -H "X-API-Key: $APIKEY" http://127.0.0.1:8384/rest/db/status?folder=projects | python3 -m json.tool
```
Key fields: `state` should be `syncing` then `idle`, `needFiles` should reach `0`.
---
## Troubleshooting
| Problem | Cause | Fix |
|---------|-------|-----|
| Connections establish then drop with "reading length: EOF" | Version mismatch (v1 vs v2) | Upgrade all nodes to v2.x |
| Node shows as device but never connects | Firewall blocking port 22000 | Open inbound/outbound for syncthing binary (Windows) or port 22000 (Linux) |
| `syncthing cli config ... add` panics | Known bug in v2.0.15 CLI | Use REST API at `http://127.0.0.1:8384/rest/config` instead |
| Windows: syncthing not listening after start | Process started but exited silently | Check `%LOCALAPPDATA%\Syncthing\` for config issues; restart with `--logfile` flag |
| SSH to Windows mangles backslashes | Bash SSH escaping | Use PowerShell scripts via SCP, or use `$HOME\` which expands server-side |
---
## Config File Locations
| OS | Config XML | Data/Index |
|----|------------|------------|
| Linux (apt) | `~/.local/state/syncthing/config.xml` | `~/.local/state/syncthing/` |
| Linux (brew) | `~/.local/state/syncthing/config.xml` | `~/.local/state/syncthing/` |
| Windows | `%LOCALAPPDATA%\Syncthing\config.xml` | `%LOCALAPPDATA%\Syncthing\` |
## API Reference
- **Get config:** `GET http://127.0.0.1:8384/rest/config`
- **Set config:** `PUT http://127.0.0.1:8384/rest/config` (full config JSON)
- **Connections:** `GET http://127.0.0.1:8384/rest/system/connections`
- **Folder status:** `GET http://127.0.0.1:8384/rest/db/status?folder=projects`
- **Header:** `X-API-Key: <apikey>`

View file

@ -1,101 +0,0 @@
# Utility Caddy LXC — Initial Setup
One-time setup. Only needed if rebuilding from scratch.
## Overview
| Item | Value |
|------|-------|
| CT ID | 101 |
| Hostname | caddy |
| Local IP | 192.168.1.101 |
| Tailscale IP | 100.64.0.2 |
| Public access | 199.6.36.163 (router forwards 80/443) |
## 1. Create LXC
```bash
ssh root@192.168.1.241
pct create 101 local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst \
--hostname caddy \
--cores 1 \
--memory 512 \
--swap 256 \
--rootfs local-lvm:8 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.1.101/24,gw=192.168.1.1 \
--features nesting=1 \
--unprivileged 1 \
--password <from .ref/credentials>
# TUN device for Tailscale
cat >> /etc/pve/lxc/101.conf << EOF
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
EOF
pct start 101
```
## 2. Install Tailscale
```bash
pct exec 101 -- bash -c "
echo nameserver 1.1.1.1 > /etc/resolv.conf
apt-get update && apt-get install -y curl
curl -fsSL https://tailscale.com/install.sh | sh
"
```
## 3. Register with Headscale
```bash
pct exec 101 -- tailscale up --login-server https://vpn.echo6.co --hostname caddy
# On Contabo — register the node
ssh root@100.64.0.6 'docker exec headscale-standby headscale nodes register --key <KEY> --user echo6'
# Verify
pct exec 101 -- tailscale status
```
## 4. Install Caddy
```bash
pct exec 101 -- bash -c "
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/gpg.key | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt | tee /etc/apt/sources.list.d/caddy-stable.list
apt-get update && apt-get install -y caddy
"
```
## 5. Install acme.sh
```bash
pct exec 101 -- bash -c "
curl https://get.acme.sh | sh -s email=admin@echo6.co
"
```
## 6. Create initial Caddyfile
```bash
pct exec 101 -- bash -c "cat > /etc/caddy/Caddyfile << 'EOF'
{
email admin@echo6.co
}
EOF
systemctl enable caddy
systemctl start caddy"
```
## 7. Router port forward
Forward on your router:
- TCP 80 → 192.168.1.101:80
- TCP 443 → 192.168.1.101:443
## Done
Add services using the expose-service-home.md runbook.

View file

@ -1,222 +0,0 @@
# Vaultwarden Deployment
**Deployed:** 2026-02-05
**Location:** Contabo VPS (5.189.158.149 / 100.64.0.6)
**URL:** https://vault.echo6.co
---
## Service Details
| Setting | Value |
|---------|-------|
| Container | `vaultwarden` |
| Image | `vaultwarden/server:latest` |
| Port | `127.0.0.1:8086` (web), `127.0.0.1:3012` (websocket) |
| Data | `/opt/vaultwarden/data` |
| Config | `/opt/vaultwarden/.env` |
| SSO | Authentik (enabled) |
| Signups | Disabled (invite-only) |
---
## Access
| Method | URL |
|--------|-----|
| Web Vault | https://vault.echo6.co |
| Admin Panel | https://vault.echo6.co/admin |
| SSO Login | "Enterprise Single Sign-On" button |
---
## Configuration Files
### Docker Compose (`/opt/vaultwarden/docker-compose.yml`)
```yaml
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
env_file:
- .env
ports:
- "127.0.0.1:8086:80"
- "127.0.0.1:3012:3012"
volumes:
- ./data:/data
environment:
- TZ=America/Boise
```
### Environment (`.env`)
```bash
# Admin
ADMIN_TOKEN=<see credentials file>
DOMAIN=https://vault.echo6.co
# Security
SIGNUPS_ALLOWED=false
INVITATIONS_ALLOWED=true
SHOW_PASSWORD_HINT=false
# WebSocket
WEBSOCKET_ENABLED=true
# SSO (Authentik)
SSO_ENABLED=true
SSO_ONLY=false
SSO_CLIENT_ID=vaultwarden
SSO_CLIENT_SECRET=<see credentials file>
SSO_AUTHORITY=https://auth.echo6.co/application/o/vaultwarden/
SSO_PKCE=true
SSO_SCOPES="openid email profile offline_access"
# Timezone
TZ=America/Boise
LOG_LEVEL=info
```
### Caddy Site Block
```caddyfile
vault.echo6.co {
reverse_proxy /notifications/hub 127.0.0.1:3012
reverse_proxy 127.0.0.1:8086
}
```
### dnsmasq Split DNS
```conf
address=/vault.echo6.co/100.64.0.6
```
---
## Authentik SSO Configuration
### Provider Settings (pk=3)
| Setting | Value |
|---------|-------|
| Name | Vaultwarden |
| Client ID | `vaultwarden` |
| Client Type | Confidential |
| Redirect URI | `https://vault.echo6.co/identity/connect/oidc-signin` |
| Signing Key | authentik Internal JWT Certificate (RS256) |
| Access Token Validity | 1 hour |
| Refresh Token Validity | 30 days |
### Scopes
- `openid` - Required for OIDC
- `email` - User email
- `profile` - User profile
- `offline_access` - Refresh tokens
### OIDC Endpoints
| Endpoint | URL |
|----------|-----|
| Discovery | https://auth.echo6.co/application/o/vaultwarden/.well-known/openid-configuration |
| JWKS | https://auth.echo6.co/application/o/vaultwarden/jwks/ |
| Authorize | https://auth.echo6.co/application/o/authorize/ |
| Token | https://auth.echo6.co/application/o/token/ |
---
## Troubleshooting
### SSO Login Loop
**Symptom:** After SSO auth, redirects back to login screen.
**Causes:**
1. Access token too short (< 5 min)
2. Missing `offline_access` scope (no refresh token)
3. Missing signing key (empty JWKS)
**Fix:**
```bash
# Check Authentik provider settings via ak shell
docker exec authentik-server ak shell -c "
from authentik.providers.oauth2.models import OAuth2Provider
p = OAuth2Provider.objects.get(name='Vaultwarden')
print(f'Access Token: {p.access_token_validity}')
print(f'Signing Key: {p.signing_key}')
print(f'Scopes: {list(p.property_mappings.values_list(\"scope_name\", flat=True))}')"
```
### SSO Discovery Error
**Symptom:** "Failed to discover OpenID provider: Failed to parse server response"
**Causes:**
1. Empty JWKS endpoint (no signing key)
2. Missing property mappings
**Fix:** Add signing key and scopes to Authentik provider.
### View Logs
```bash
# Vaultwarden
docker logs vaultwarden --tail 100 2>&1 | grep -i -E "sso|error"
# Authentik
docker logs authentik-server --tail 100 2>&1 | grep -i vaultwarden
```
---
## Maintenance
### Restart Service
```bash
ssh root@5.189.158.149
cd /opt/vaultwarden
docker compose restart
```
### Update Image
```bash
ssh root@5.189.158.149
cd /opt/vaultwarden
docker compose pull
docker compose up -d
```
### Backup Data
```bash
# Stop container first
docker compose stop
tar -czf vaultwarden-backup-$(date +%Y%m%d).tar.gz data/
docker compose start
```
---
## Credentials Reference
All credentials stored in `/home/zvx/projects/.ref/credentials`:
```
VAULTWARDEN_URL
VAULTWARDEN_ADMIN_TOKEN
VAULTWARDEN_ADMIN_URL
VAULTWARDEN_OIDC_PROVIDER_ID
VAULTWARDEN_OIDC_CLIENT_ID
VAULTWARDEN_OIDC_CLIENT_SECRET
VAULTWARDEN_OIDC_ISSUER
```
---
*Last updated: 2026-02-05*