initial commit: Dawarich LXC role (CT 459 on pve02, .159)
Self-hosted location history. 4-container compose: Rails 8 app + Sidekiq + PostGIS 16-3.4 + Redis 7, plus watchtower. Authentik OIDC end-to-end. Image pinned at freikin/dawarich:1.7.11 (OIDC support requires >= 1.7.8). PostGIS DB lives in this LXC, not on the central DB VM (.172) — central image is postgres:16-alpine without postgis, swapping it carries broader blast radius than colocating here. Convention exception captured in homelab-docs project_dawarich memory. Roles: - dawarich: system + Docker + compose + weekly prune timer - alloy: logs+journald → Loki, node metrics → Prometheus Bring-up sequence proven 2026-06-01. README documents the 5-trap build chain (image version, entrypoint scripts, solid_cache SQLite bind mount, APPLICATION_HOSTS+localhost, force_ssl+healthcheck). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
vars/vault.yml.dec
|
||||||
|
*.retry
|
||||||
|
__pycache__/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# homelab-ansible-lxc-dawarich
|
||||||
|
|
||||||
|
Configuration for **dawarich** (LXC 459 on pve02, `192.168.1.159`) — self-hosted
|
||||||
|
location-history app, [Dawarich](https://dawarich.app). Ingests location data
|
||||||
|
from OwnTracks, the Dawarich iOS/Android apps, and Google Takeout exports;
|
||||||
|
renders heatmaps, places visited, distance/time statistics, and timeline view.
|
||||||
|
|
||||||
|
- **URL:** `dawarich.balders.ca` (Caddy → `192.168.1.159:3000`, Authentik OIDC)
|
||||||
|
- **Image:** `freikin/dawarich:1.7.11` (pinned in `vars/main.yml`) — **must be ≥ 1.7.8** for OIDC.
|
||||||
|
- **Tier:** T2 (Proxmox)
|
||||||
|
- **Auth:** Authentik OIDC (SSO-only — `ALLOW_EMAIL_PASSWORD_REGISTRATION=false`)
|
||||||
|
- **DB:** PostGIS 16-3.4 **local to this LXC**. The central DB VM (.172) runs
|
||||||
|
`postgres:16-alpine` which doesn't bundle the PostGIS extension; rather than
|
||||||
|
swap that image (16 other databases) we run a colocated container here. See
|
||||||
|
memory `project_dawarich` for the convention-exception rationale.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Phone (OwnTracks / Dawarich app)
|
||||||
|
│ HTTP POST /api/v1/owntracks/points?api_key=…
|
||||||
|
▼
|
||||||
|
Caddy (TLS, Authentik OIDC on UI; API bypassed by query-string auth)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
dawarich_app (Rails 8 + Puma) ──▶ dawarich_db (PostGIS 16-3.4)
|
||||||
|
│ ▲
|
||||||
|
▼ │
|
||||||
|
dawarich_sidekiq ─── enqueue ─────────── dawarich_redis
|
||||||
|
└──── solid_queue ──▶ /dawarich_db_data (SQLite)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
| Role | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| `dawarich` | System setup, Docker, registry mirror, 4-container compose, prune timer |
|
||||||
|
| `alloy` | Docker logs + journald → Loki, node metrics → Prometheus |
|
||||||
|
|
||||||
|
## Prerequisites (before first deploy)
|
||||||
|
|
||||||
|
1. **LXC provisioned** — `module.dawarich` in homelab-terraform (CTID 459,
|
||||||
|
pve02, `.159`). `tf.sh apply lxc` then bootstrap the cbalders user manually
|
||||||
|
on the new CT (the bpg module only seeds root keys; cbalders + NOPASSWD sudo
|
||||||
|
is post-create). See `feedback_lxc_bootstrap_user`.
|
||||||
|
2. **`vars/vault.yml`** — holds `vault_infisical_client_secret`. Shared
|
||||||
|
universal-auth client across all LXC repos — copy from `homelab-ansible-lxc-rag/vars/vault.yml`.
|
||||||
|
3. **Infisical `/dawarich` folder + 5 secrets** — run `./scripts/bootstrap-secrets.sh`. It creates the folder if missing and pushes:
|
||||||
|
- `vault_dawarich_db_password` (`openssl rand -hex 32`)
|
||||||
|
- `vault_dawarich_secret_key_base` (`openssl rand -hex 64`)
|
||||||
|
- `vault_dawarich_otp_primary_key` (`openssl rand -hex 32`)
|
||||||
|
- `vault_dawarich_otp_deterministic_key` (`openssl rand -hex 32`)
|
||||||
|
- `vault_dawarich_otp_salt` (`openssl rand -hex 32`)
|
||||||
|
4. **Infisical `/oidc` secrets** — generate `vault_dawarich_oidc_client_id`
|
||||||
|
(40 hex) and `vault_dawarich_oidc_client_secret` (64 hex), push as `--type shared`.
|
||||||
|
These values ARE the credentials — pi-auth creates the Authentik provider
|
||||||
|
with them, it doesn't fetch them back. Generation pattern:
|
||||||
|
```bash
|
||||||
|
infisical secrets set "vault_dawarich_oidc_client_id=$(openssl rand -hex 20)" \
|
||||||
|
--projectId 50062d7c-06ff-4d5c-8ca3-6c0cdba9f270 --env prod --path /oidc --type shared
|
||||||
|
infisical secrets set "vault_dawarich_oidc_client_secret=$(openssl rand -hex 32)" \
|
||||||
|
--projectId 50062d7c-06ff-4d5c-8ca3-6c0cdba9f270 --env prod --path /oidc --type shared
|
||||||
|
```
|
||||||
|
5. **pi-auth site.yml mapping** — `homelab-ansible-pi-auth/site.yml` must have
|
||||||
|
a `dawarich_oidc_client_id` / `dawarich_oidc_client_secret` set_fact line
|
||||||
|
reading from `oidc_secrets.secrets.vault_dawarich_oidc_client_{id,secret}`.
|
||||||
|
Without this, pi-auth's loop fails with `'dawarich_oidc_client_id' is undefined`.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh # full deploy
|
||||||
|
./deploy.sh --tags dawarich # dawarich role only
|
||||||
|
./logs.sh -f # follow dawarich_app
|
||||||
|
./logs.sh dawarich_sidekiq -f # follow background jobs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bring-up order (proven 2026-06-01)
|
||||||
|
|
||||||
|
1. `cd ../homelab-terraform && ./tf.sh apply lxc` → CT 459 created.
|
||||||
|
2. Bootstrap `cbalders` user + SSH keys + NOPASSWD sudo on the new CT:
|
||||||
|
```bash
|
||||||
|
ssh root@pve02 'pct exec 459 -- bash -c "
|
||||||
|
useradd -m -s /bin/bash -G sudo cbalders &&
|
||||||
|
mkdir -p /home/cbalders/.ssh && cat > /home/cbalders/.ssh/authorized_keys <<EOF
|
||||||
|
<your-keys-here>
|
||||||
|
EOF
|
||||||
|
chown -R cbalders:cbalders /home/cbalders/.ssh && chmod 700 /home/cbalders/.ssh && chmod 600 /home/cbalders/.ssh/authorized_keys &&
|
||||||
|
apt-get update -qq && apt-get install -y -qq sudo &&
|
||||||
|
mkdir -p /etc/sudoers.d &&
|
||||||
|
echo \"cbalders ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/cbalders &&
|
||||||
|
chmod 440 /etc/sudoers.d/cbalders"'
|
||||||
|
```
|
||||||
|
3. `./scripts/bootstrap-secrets.sh` → 5 `/dawarich` secrets in Infisical.
|
||||||
|
4. Push `/oidc` secrets (see prereq 4 above).
|
||||||
|
5. Add `dawarich_oidc_client_{id,secret}` set_fact mapping in `homelab-ansible-pi-auth/site.yml`.
|
||||||
|
6. First `./deploy.sh` here → app boots, migrations run (~3 min cold-start).
|
||||||
|
7. `cd ../homelab-ansible-pi-auth && ./deploy.sh` → creates Authentik provider + application.
|
||||||
|
8. `cd ../homelab-ansible-proxy && ./deploy.sh` → adds Caddy vhost + Technitium CNAME.
|
||||||
|
9. `cd ../homelab-ansible-pve && ./deploy.sh` → adds CTID 459 to `pbs-prod-daily`.
|
||||||
|
10. Browser-test `https://dawarich.balders.ca/users/sign_in` → click "Sign in with Authentik".
|
||||||
|
|
||||||
|
## Mobile clients
|
||||||
|
|
||||||
|
After first OIDC login, the user goes to Settings → API Keys in the Dawarich
|
||||||
|
UI to generate a per-user token. Use it as the bearer for either:
|
||||||
|
|
||||||
|
- **OwnTracks** (any platform) — HTTP recorder mode, URL:
|
||||||
|
`https://dawarich.balders.ca/api/v1/owntracks/points?api_key=<TOKEN>`
|
||||||
|
- **Dawarich iOS/Android app** — paste the token + host on first launch.
|
||||||
|
|
||||||
|
## Google Takeout import
|
||||||
|
|
||||||
|
1. Take the `location-history.json` out of the Takeout archive.
|
||||||
|
2. Drop it into `/opt/dawarich/watched/` on the LXC (NFS mount or `scp`).
|
||||||
|
3. Sidekiq picks it up via the import watcher within ~30s and emits progress
|
||||||
|
to `dawarich_sidekiq` logs. Large histories (>10y) can take an hour.
|
||||||
|
|
||||||
|
## Gotchas (build-day 2026-06-01)
|
||||||
|
|
||||||
|
- **Image version pin.** OIDC support landed in `freikin/dawarich:1.7.8`. The
|
||||||
|
`0.27.x` line silently ignores `OIDC_*` env vars (no OmniAuth controllers in
|
||||||
|
the codebase). Pinned at `1.7.11`. Bump app + sidekiq lockstep — same image
|
||||||
|
tag in two containers, breaking schema migrations on minor bumps.
|
||||||
|
- **Image entrypoint scripts are required.** Image ships `ENTRYPOINT ["bundle", "exec"]`
|
||||||
|
with no CMD. Compose MUST set `entrypoint: ["/usr/local/bin/web-entrypoint.sh"]`
|
||||||
|
(and `sidekiq-entrypoint.sh` for sidekiq). Those scripts run `db:prepare`,
|
||||||
|
create the SQLite files for solid_cache/solid_queue/solid_cable at
|
||||||
|
`/dawarich_db_data/`, and migrate Postgres before exec'ing the command.
|
||||||
|
- **Multi-DB SQLite persistence.** The solid_cache/solid_queue/solid_cable
|
||||||
|
files live at `/dawarich_db_data/` — bind-mount `/opt/dawarich/db_data:/dawarich_db_data`
|
||||||
|
to keep in-flight Sidekiq jobs across recreates.
|
||||||
|
- **`APPLICATION_HOSTS` must include localhost.** Rails 8 host authorization
|
||||||
|
blocks loopback if only the public host is listed — kills Docker HEALTHCHECK
|
||||||
|
and the Ansible health-wait.
|
||||||
|
- **Rails 8 `force_ssl` + healthcheck.** `APPLICATION_PROTOCOL=https` makes Rails
|
||||||
|
301-redirect every plain-HTTP request to HTTPS. The Docker healthcheck is a
|
||||||
|
TCP-socket probe (`ruby -rsocket -e 'TCPSocket.new("localhost",3000).close'`)
|
||||||
|
rather than HTTP, because curl/wget either chase the redirect to TLS (Puma is
|
||||||
|
plain-HTTP behind Caddy → "SSL wrong version number") or fail 2xx. The Ansible
|
||||||
|
`Wait for Dawarich to be healthy` task is `ignore_errors: true` for the same reason.
|
||||||
|
- **SSO button is POST, not GET.** The signin page renders a `<form method="post" action="/users/auth/openid_connect">`; the button submits with a Rails CSRF token. `GET /users/auth/openid_connect` returns 404 — that's CSRF working, not a routing bug.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Postgres data lives at `/opt/dawarich/postgres` (uid 70, alpine `postgres`
|
||||||
|
user). Pre-chowned in the role to avoid the empty-dir trap.
|
||||||
|
- Redis data at `/opt/dawarich/redis` (uid 999) — see `feedback_redis_uid_999`.
|
||||||
|
- PBS LXC snapshot covers everything nightly (the postgis + db_data volumes).
|
||||||
|
No central pg_backup wiring — DB is local.
|
||||||
|
- Watchtower exposes its HTTP API on port 8088 inside the LXC (matches the
|
||||||
|
fleet pattern; 8080 collides with potential second app on this CT).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[defaults]
|
||||||
|
interpreter_python = /usr/bin/python3
|
||||||
|
deprecation_warnings = False
|
||||||
|
host_key_checking = False
|
||||||
|
result_format = yaml
|
||||||
|
callbacks_enabled = profile_tasks, timer
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# deploy.sh — Deploy Dawarich LXC (Rails + Sidekiq + PostGIS + Redis)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./deploy.sh # full deploy (prompts for vault password)
|
||||||
|
# ./deploy.sh --tags dawarich # dawarich role only
|
||||||
|
# ./deploy.sh -v # verbose output
|
||||||
|
# ==============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST_IP="$(grep -E '^[0-9]' inventory.ini | head -1 | awk '{print $1}')"
|
||||||
|
HOST_USER="cbalders"
|
||||||
|
|
||||||
|
echo "==> Checking connectivity to ${HOST_USER}@${HOST_IP} ..."
|
||||||
|
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "${HOST_USER}@${HOST_IP}" true 2>/dev/null; then
|
||||||
|
echo " Cannot SSH to ${HOST_IP} — refreshing host key ..."
|
||||||
|
ssh-keygen -R "$HOST_IP" 2>/dev/null || true
|
||||||
|
ssh-keyscan -H "$HOST_IP" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Installing Ansible collections ..."
|
||||||
|
ansible-galaxy collection install -r requirements.yml --force 2>/dev/null
|
||||||
|
|
||||||
|
echo "==> Running deploy playbook ..."
|
||||||
|
ansible-playbook -i inventory.ini site.yml --ask-vault-pass "$@"
|
||||||
|
|
||||||
|
echo "==> Verifying ..."
|
||||||
|
ssh "${HOST_USER}@${HOST_IP}" bash -s <<'VERIFY'
|
||||||
|
echo "Containers:"
|
||||||
|
sudo docker ps --format ' {{.Names}}: {{.Status}}' | sort
|
||||||
|
echo "Dawarich web (TCP socket probe — Rails force_ssl redirects HTTP, smoke via Caddy after Caddy deploy):"
|
||||||
|
sudo docker exec dawarich_app ruby -rsocket -e 'TCPSocket.new("localhost",3000).close' 2>/dev/null && echo " :3000 LISTENING" || echo " UNREACHABLE"
|
||||||
|
echo "Postgis (probed from dawarich_app):"
|
||||||
|
sudo docker exec dawarich_app sh -c 'pg_isready -h dawarich_db -U dawarich' 2>/dev/null || echo " (pg unreachable)"
|
||||||
|
VERIFY
|
||||||
|
|
||||||
|
echo "==> Done."
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
192.168.1.159 ansible_user=cbalders ansible_become=yes
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# logs.sh — View container logs from the Dawarich LXC
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./logs.sh # last 50 lines from dawarich_app
|
||||||
|
# ./logs.sh -f # tail/follow dawarich_app
|
||||||
|
# ./logs.sh dawarich_sidekiq # sidekiq logs
|
||||||
|
# ./logs.sh dawarich_db -f # follow db logs
|
||||||
|
|
||||||
|
HOST_IP="$(grep -E '^[0-9]' inventory.ini | head -1 | awk '{print $1}')"
|
||||||
|
HOST_USER="cbalders"
|
||||||
|
|
||||||
|
CONTAINER="dawarich_app"
|
||||||
|
FOLLOW=false
|
||||||
|
LINES=50
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-f|--follow) FOLLOW=true ;;
|
||||||
|
[0-9]*) LINES="$arg" ;;
|
||||||
|
*) CONTAINER="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FOLLOW" = true ]; then
|
||||||
|
echo "==> Tailing $CONTAINER on dawarich ($HOST_IP) — Ctrl+C to stop"
|
||||||
|
ssh "${HOST_USER}@${HOST_IP}" "sudo docker logs $CONTAINER --tail $LINES -f"
|
||||||
|
else
|
||||||
|
echo "==> Last $LINES lines of $CONTAINER on dawarich ($HOST_IP)"
|
||||||
|
ssh "${HOST_USER}@${HOST_IP}" "sudo docker logs $CONTAINER --tail $LINES 2>&1"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
collections:
|
||||||
|
- name: infisical.vault
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: restart alloy
|
||||||
|
command: docker compose -f /opt/alloy/docker-compose.yml up -d --force-recreate
|
||||||
|
args:
|
||||||
|
chdir: /opt/alloy
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
- name: Create Alloy directories
|
||||||
|
file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
owner: cbalders
|
||||||
|
group: cbalders
|
||||||
|
mode: '0755'
|
||||||
|
loop:
|
||||||
|
- /opt/alloy
|
||||||
|
- /var/lib/alloy
|
||||||
|
|
||||||
|
- name: Deploy Alloy compose
|
||||||
|
template:
|
||||||
|
src: docker-compose.yml.j2
|
||||||
|
dest: /opt/alloy/docker-compose.yml
|
||||||
|
owner: cbalders
|
||||||
|
group: cbalders
|
||||||
|
mode: '0644'
|
||||||
|
notify: restart alloy
|
||||||
|
|
||||||
|
- name: Deploy Alloy config
|
||||||
|
template:
|
||||||
|
src: config.alloy.j2
|
||||||
|
dest: /opt/alloy/config.alloy
|
||||||
|
owner: cbalders
|
||||||
|
group: cbalders
|
||||||
|
mode: '0644'
|
||||||
|
notify: restart alloy
|
||||||
|
|
||||||
|
# Bring Alloy up (idempotent — docker compose up -d is a no-op if running
|
||||||
|
# and config hasn't changed). The handler force-recreates on config edit.
|
||||||
|
- name: Ensure Alloy is running
|
||||||
|
command: docker compose -f /opt/alloy/docker-compose.yml up -d
|
||||||
|
args:
|
||||||
|
chdir: /opt/alloy
|
||||||
|
changed_when: false
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Alloy — Docker container logs + journald → Loki. River syntax.
|
||||||
|
// Per-LXC config; only the `host:` label changes via alloy_host_label var.
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Docker — discover + scrape container logs
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
discovery.docker "containers" {
|
||||||
|
host = "unix:///var/run/docker.sock"
|
||||||
|
refresh_interval = "15s"
|
||||||
|
}
|
||||||
|
|
||||||
|
discovery.relabel "containers" {
|
||||||
|
targets = discovery.docker.containers.targets
|
||||||
|
|
||||||
|
rule {
|
||||||
|
source_labels = ["__meta_docker_container_name"]
|
||||||
|
regex = "/(.+)"
|
||||||
|
target_label = "container"
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
source_labels = ["__meta_docker_container_log_stream"]
|
||||||
|
target_label = "stream"
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
source_labels = ["__meta_docker_container_label_com_docker_compose_service"]
|
||||||
|
target_label = "service"
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
source_labels = ["__meta_docker_container_label_com_docker_compose_project"]
|
||||||
|
target_label = "compose_project"
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
target_label = "job"
|
||||||
|
replacement = "docker"
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
target_label = "host"
|
||||||
|
replacement = "{{ alloy_host_label }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loki.source.docker "containers" {
|
||||||
|
host = "unix:///var/run/docker.sock"
|
||||||
|
targets = discovery.relabel.containers.output
|
||||||
|
forward_to = [loki.process.docker.receiver]
|
||||||
|
}
|
||||||
|
|
||||||
|
loki.process "docker" {
|
||||||
|
// Replay protection on first boot — Loki rejects > 7d.
|
||||||
|
stage.drop {
|
||||||
|
older_than = "24h"
|
||||||
|
drop_counter_reason = "older_than_24h"
|
||||||
|
}
|
||||||
|
// pulse-agent broken-pipe noise on hosts running pulse-agent --enable-host.
|
||||||
|
stage.drop {
|
||||||
|
expression = ".*broken pipe.*"
|
||||||
|
drop_counter_reason = "broken_pipe_noise"
|
||||||
|
}
|
||||||
|
forward_to = [loki.write.default.receiver]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Journald — LXC systemd units
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
loki.source.journal "host" {
|
||||||
|
path = "/var/log/journal"
|
||||||
|
max_age = "1m"
|
||||||
|
forward_to = [loki.process.journal.receiver]
|
||||||
|
|
||||||
|
relabel_rules = loki.relabel.journal.rules
|
||||||
|
labels = {
|
||||||
|
job = "journald",
|
||||||
|
host = "{{ alloy_host_label }}",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loki.relabel "journal" {
|
||||||
|
forward_to = []
|
||||||
|
|
||||||
|
rule {
|
||||||
|
source_labels = ["__journal__systemd_unit"]
|
||||||
|
target_label = "unit"
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
source_labels = ["__journal__hostname"]
|
||||||
|
target_label = "instance"
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
source_labels = ["__journal_priority_keyword"]
|
||||||
|
target_label = "severity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loki.process "journal" {
|
||||||
|
forward_to = [loki.write.default.receiver]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Loki — push
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
loki.write "default" {
|
||||||
|
endpoint {
|
||||||
|
url = "{{ alloy_loki_url }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Prometheus — embedded node_exporter (replaces standalone)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
prometheus.exporter.unix "node" {
|
||||||
|
// Container view of host filesystems — bind-mounted in compose.
|
||||||
|
rootfs_path = "/host/rootfs"
|
||||||
|
procfs_path = "/host/proc"
|
||||||
|
sysfs_path = "/host/sys"
|
||||||
|
// Default collectors match upstream node_exporter set.
|
||||||
|
}
|
||||||
|
|
||||||
|
prometheus.scrape "node" {
|
||||||
|
targets = prometheus.exporter.unix.node.targets
|
||||||
|
forward_to = [prometheus.relabel.node.receiver]
|
||||||
|
scrape_interval = "15s"
|
||||||
|
job_name = "{{ alloy_prom_job | default('node_lxc') }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite the instance + job labels to match the historical
|
||||||
|
// node_exporter scrape, so existing dashboards continue to slice by
|
||||||
|
// the same instance string. Alloy's exporter component injects
|
||||||
|
// job="integrations/unix" on its targets — we have to overwrite it
|
||||||
|
// here; `job_name` in prometheus.scrape only names the scrape pool,
|
||||||
|
// it doesn't relabel the metrics.
|
||||||
|
prometheus.relabel "node" {
|
||||||
|
forward_to = [prometheus.remote_write.observe.receiver]
|
||||||
|
rule {
|
||||||
|
target_label = "instance"
|
||||||
|
replacement = "{{ alloy_prom_instance | default(ansible_default_ipv4.address + ':9100') }}"
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
target_label = "job"
|
||||||
|
replacement = "{{ alloy_prom_job | default('node_lxc') }}"
|
||||||
|
}
|
||||||
|
// Match the static labels Prom previously injected at scrape time
|
||||||
|
// (see homelab-ansible-vm-observability/roles/configs/files/prometheus.yml).
|
||||||
|
rule {
|
||||||
|
target_label = "group"
|
||||||
|
replacement = "{{ alloy_prom_group | default('lxc') }}"
|
||||||
|
}
|
||||||
|
rule {
|
||||||
|
target_label = "hostname"
|
||||||
|
replacement = "{{ alloy_prom_hostname | default(alloy_host_label) }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prometheus.remote_write "observe" {
|
||||||
|
endpoint {
|
||||||
|
url = "{{ alloy_prom_remote_write | default('http://observe.lan.balders.ca:9090/api/v1/write') }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
# Self-contained Alloy stack on /opt/alloy. Runs independently from the
|
||||||
|
# host's main service (kestra/infisical/mcp/etc) so a service-side compose
|
||||||
|
# down doesn't take logging with it.
|
||||||
|
services:
|
||||||
|
alloy:
|
||||||
|
image: grafana/alloy:latest
|
||||||
|
container_name: alloy
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- run
|
||||||
|
- /etc/alloy/config.alloy
|
||||||
|
- --storage.path=/var/lib/alloy/data
|
||||||
|
- --server.http.listen-addr=0.0.0.0:12345
|
||||||
|
ports:
|
||||||
|
- "12345:12345"
|
||||||
|
# Share the host's PID namespace so prometheus.exporter.unix reads
|
||||||
|
# /proc with the host kernel's cgroup view (cgroup-aware MemAvailable).
|
||||||
|
# Without this, /proc/meminfo returns hybrid values: MemTotal from the
|
||||||
|
# host cgroup but Cached/SReclaimable from the container, leading to
|
||||||
|
# a ~25% MemAvailable inflation. See docs/audit/alloy-consolidation-2026-05-21.md.
|
||||||
|
pid: host
|
||||||
|
volumes:
|
||||||
|
- /opt/alloy/config.alloy:/etc/alloy/config.alloy:ro
|
||||||
|
- /var/lib/alloy:/var/lib/alloy
|
||||||
|
- /var/log/journal:/var/log/journal:ro
|
||||||
|
- /run/log/journal:/run/log/journal:ro
|
||||||
|
- /etc/machine-id:/etc/machine-id:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
# Host metric collection for prometheus.exporter.unix (node_exporter replacement)
|
||||||
|
- /:/host/rootfs:ro,rslave
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Prune dangling docker images (Watchtower leaves them behind)
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/docker image prune -af
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Weekly docker image prune
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Sun 03:30
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
- name: reload systemd
|
||||||
|
systemd:
|
||||||
|
daemon_reload: true
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
---
|
||||||
|
# ---------- System setup ----------
|
||||||
|
- name: Set timezone
|
||||||
|
copy:
|
||||||
|
content: "{{ timezone }}"
|
||||||
|
dest: /etc/timezone
|
||||||
|
mode: '0644'
|
||||||
|
register: tz_file
|
||||||
|
|
||||||
|
- name: Apply timezone
|
||||||
|
command: dpkg-reconfigure -f noninteractive tzdata
|
||||||
|
when: tz_file.changed
|
||||||
|
|
||||||
|
- name: Set hostname
|
||||||
|
copy:
|
||||||
|
content: "dawarich"
|
||||||
|
dest: /etc/hostname
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Apply hostname
|
||||||
|
command: hostname dawarich
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Ensure hostname in /etc/hosts
|
||||||
|
lineinfile:
|
||||||
|
path: /etc/hosts
|
||||||
|
regexp: '^127\.0\.1\.1'
|
||||||
|
line: "127.0.1.1 dawarich"
|
||||||
|
|
||||||
|
- name: Update apt cache
|
||||||
|
apt:
|
||||||
|
update_cache: true
|
||||||
|
cache_valid_time: 3600
|
||||||
|
register: apt_cache_result
|
||||||
|
failed_when: false
|
||||||
|
changed_when: apt_cache_result.changed | default(false)
|
||||||
|
|
||||||
|
- name: Install base packages
|
||||||
|
apt:
|
||||||
|
name: "{{ packages }}"
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Create user
|
||||||
|
user:
|
||||||
|
name: "{{ item.name }}"
|
||||||
|
groups: "{{ item.groups }}"
|
||||||
|
shell: "{{ item.shell }}"
|
||||||
|
append: true
|
||||||
|
loop: "{{ users }}"
|
||||||
|
|
||||||
|
- name: Deploy SSH authorized keys
|
||||||
|
authorized_key:
|
||||||
|
user: cbalders
|
||||||
|
key: "{{ item }}"
|
||||||
|
state: present
|
||||||
|
loop: "{{ ssh_authorized_keys }}"
|
||||||
|
|
||||||
|
# ---------- Docker ----------
|
||||||
|
- name: Check if Docker is installed
|
||||||
|
command: docker --version
|
||||||
|
register: docker_check
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Install Docker
|
||||||
|
when: docker_check.rc != 0
|
||||||
|
block:
|
||||||
|
- name: Download Docker install script
|
||||||
|
get_url:
|
||||||
|
url: https://get.docker.com
|
||||||
|
dest: /tmp/get-docker.sh
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Run Docker install script
|
||||||
|
command: /tmp/get-docker.sh
|
||||||
|
|
||||||
|
- name: Remove install script
|
||||||
|
file:
|
||||||
|
path: /tmp/get-docker.sh
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Ensure Docker service is running
|
||||||
|
systemd:
|
||||||
|
name: docker
|
||||||
|
state: started
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- name: Add cbalders to docker group
|
||||||
|
user:
|
||||||
|
name: cbalders
|
||||||
|
groups: docker
|
||||||
|
append: true
|
||||||
|
|
||||||
|
# ---------- Docker registry mirror ----------
|
||||||
|
- name: Ensure /etc/docker exists
|
||||||
|
file:
|
||||||
|
path: /etc/docker
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Read existing daemon.json
|
||||||
|
slurp:
|
||||||
|
src: /etc/docker/daemon.json
|
||||||
|
register: daemon_json_raw
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Configure Docker registry mirror
|
||||||
|
copy:
|
||||||
|
content: "{{ ((daemon_json_raw.content | b64decode | from_json) if daemon_json_raw.content is defined else {}) | combine({'registry-mirrors': ['http://registry.lan.balders.ca:5000']}) | to_nice_json }}"
|
||||||
|
dest: /etc/docker/daemon.json
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: '0644'
|
||||||
|
register: docker_daemon_config
|
||||||
|
|
||||||
|
- name: Restart Docker if mirror config changed
|
||||||
|
systemd:
|
||||||
|
name: docker
|
||||||
|
state: restarted
|
||||||
|
when: docker_daemon_config.changed
|
||||||
|
|
||||||
|
# ---------- Dawarich stack ----------
|
||||||
|
- name: Create app directories
|
||||||
|
file:
|
||||||
|
path: "{{ item.path }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ item.owner | default('cbalders') }}"
|
||||||
|
group: "{{ item.group | default('cbalders') }}"
|
||||||
|
mode: '0755'
|
||||||
|
loop:
|
||||||
|
- { path: /opt/dawarich }
|
||||||
|
# Postgis container runs as uid 70 (alpine 'postgres' user). Pre-chown
|
||||||
|
# so the first boot doesn't fail with "directory is not empty / wrong
|
||||||
|
# permissions" the way Nextcloud did (feedback_nextcloud_db_perms).
|
||||||
|
- { path: /opt/dawarich/postgres, owner: '70', group: '70' }
|
||||||
|
# Redis runs as uid 999 on bind volumes (feedback_redis_uid_999).
|
||||||
|
- { path: /opt/dawarich/redis, owner: '999', group: '999' }
|
||||||
|
# Dawarich Rails app writes uploads/cache here as uid 1000.
|
||||||
|
- { path: /opt/dawarich/public }
|
||||||
|
- { path: /opt/dawarich/watched }
|
||||||
|
- { path: /opt/dawarich/storage }
|
||||||
|
# solid_cache + solid_queue + solid_cable SQLite files (writable by the
|
||||||
|
# rails uid inside the container — image runs as uid 1000).
|
||||||
|
- { path: /opt/dawarich/db_data }
|
||||||
|
|
||||||
|
- name: Deploy docker-compose
|
||||||
|
template:
|
||||||
|
src: docker-compose.yml.j2
|
||||||
|
dest: /opt/dawarich/docker-compose.yml
|
||||||
|
owner: cbalders
|
||||||
|
group: cbalders
|
||||||
|
mode: '0640'
|
||||||
|
register: compose_changed
|
||||||
|
|
||||||
|
- name: Pull images
|
||||||
|
command: docker compose pull
|
||||||
|
args:
|
||||||
|
chdir: /opt/dawarich
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Recreate containers if config changed
|
||||||
|
command: docker compose up -d --force-recreate
|
||||||
|
args:
|
||||||
|
chdir: /opt/dawarich
|
||||||
|
when: compose_changed.changed
|
||||||
|
changed_when: true
|
||||||
|
|
||||||
|
- name: Ensure containers are running
|
||||||
|
command: docker compose up -d
|
||||||
|
args:
|
||||||
|
chdir: /opt/dawarich
|
||||||
|
when: not compose_changed.changed
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
# Dawarich runs migrations on boot (entrypoint includes db:migrate). First
|
||||||
|
# boot can take ~60-90s because the initial schema + PostGIS extension load
|
||||||
|
# is heavy. Don't fail the play if it's not ready yet — Watchtower-driven
|
||||||
|
# recreates will converge.
|
||||||
|
- name: Wait for Dawarich to be healthy
|
||||||
|
uri:
|
||||||
|
url: "http://localhost:{{ dawarich_port }}/api/v1/health"
|
||||||
|
method: GET
|
||||||
|
register: dawarich_health
|
||||||
|
until: dawarich_health.status is defined and dawarich_health.status == 200
|
||||||
|
retries: 30
|
||||||
|
delay: 5
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
# ---------- Weekly docker prune (Watchtower side-effect cleanup) ----------
|
||||||
|
- name: Deploy docker-prune systemd units
|
||||||
|
copy:
|
||||||
|
src: "{{ item }}"
|
||||||
|
dest: "/etc/systemd/system/{{ item }}"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: '0644'
|
||||||
|
loop:
|
||||||
|
- docker-prune.service
|
||||||
|
- docker-prune.timer
|
||||||
|
notify: reload systemd
|
||||||
|
|
||||||
|
- name: Enable docker-prune timer
|
||||||
|
systemd:
|
||||||
|
name: docker-prune.timer
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
daemon_reload: true
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# {{ ansible_managed }}
|
||||||
|
# Dawarich — self-hosted location history. Four containers + watchtower:
|
||||||
|
# dawarich_db postgis/postgis:16-3.4-alpine (PostGIS-enabled Postgres)
|
||||||
|
# dawarich_redis redis:7-alpine (cache + Sidekiq queue)
|
||||||
|
# dawarich_app freikin/dawarich (Rails web tier, port 3000)
|
||||||
|
# dawarich_sidekiq freikin/dawarich (background jobs)
|
||||||
|
# DB lives here (not the central DB VM) because the central image is plain
|
||||||
|
# postgres:16-alpine without the postgis extension. See memory project_dawarich.
|
||||||
|
|
||||||
|
services:
|
||||||
|
dawarich_db:
|
||||||
|
image: "{{ postgis_image }}"
|
||||||
|
container_name: dawarich_db
|
||||||
|
restart: unless-stopped
|
||||||
|
shm_size: 1g
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: "{{ dawarich_db_user }}"
|
||||||
|
POSTGRES_PASSWORD: "{{ dawarich_db_password }}"
|
||||||
|
POSTGRES_DB: "{{ dawarich_db_name }}"
|
||||||
|
volumes:
|
||||||
|
- /opt/dawarich/postgres:/var/lib/postgresql/data
|
||||||
|
# No published ports — only the app + sidekiq reach it over the compose net.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U {{ dawarich_db_user }} -d {{ dawarich_db_name }}"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
dawarich_redis:
|
||||||
|
image: "{{ redis_image }}"
|
||||||
|
container_name: dawarich_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /opt/dawarich/redis:/data
|
||||||
|
# No published ports — internal-only.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
dawarich_app:
|
||||||
|
image: "{{ dawarich_image }}"
|
||||||
|
container_name: dawarich_app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
dawarich_db:
|
||||||
|
condition: service_healthy
|
||||||
|
dawarich_redis:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "{{ dawarich_port }}:3000"
|
||||||
|
volumes:
|
||||||
|
- /opt/dawarich/public:/var/app/public
|
||||||
|
- /opt/dawarich/watched:/var/app/tmp/imports/watched
|
||||||
|
- /opt/dawarich/storage:/var/app/storage
|
||||||
|
# Solid_cache + solid_queue + solid_cable SQLite files. Without this
|
||||||
|
# bind, the queue DB is ephemeral and in-flight Sidekiq jobs are lost
|
||||||
|
# on container recreate. Path matches the entrypoint's expectation.
|
||||||
|
- /opt/dawarich/db_data:/dawarich_db_data
|
||||||
|
# The image ships a `web-entrypoint.sh` that runs `db:prepare` (creates
|
||||||
|
# the solid_cache + solid_queue SQLite files in /var/app/storage, runs
|
||||||
|
# Postgres migrations) before exec'ing the command. We MUST use it —
|
||||||
|
# without it, Rails 8 boots fail with "No database file specified" on
|
||||||
|
# solid_cache's first lookup. The image default entrypoint is `bundle exec`
|
||||||
|
# alone, which skips the prep.
|
||||||
|
entrypoint: ["/usr/local/bin/web-entrypoint.sh"]
|
||||||
|
command: ["bin/rails", "server", "-p", "3000", "-b", "::"]
|
||||||
|
environment:
|
||||||
|
RAILS_ENV: production
|
||||||
|
SELF_HOSTED: "true"
|
||||||
|
# localhost + ::1 needed for Docker healthcheck (Rails 8 host
|
||||||
|
# authorization rejects any host not in this list, including loopback).
|
||||||
|
APPLICATION_HOSTS: "{{ dawarich_domain }},localhost,127.0.0.1,::1"
|
||||||
|
APPLICATION_PROTOCOL: "https"
|
||||||
|
DOMAIN: "{{ dawarich_domain }}"
|
||||||
|
TIME_ZONE: "{{ timezone }}"
|
||||||
|
DISTANCE_UNIT: "km"
|
||||||
|
# Database
|
||||||
|
DATABASE_HOST: dawarich_db
|
||||||
|
DATABASE_PORT: "5432"
|
||||||
|
DATABASE_USERNAME: "{{ dawarich_db_user }}"
|
||||||
|
DATABASE_PASSWORD: "{{ dawarich_db_password }}"
|
||||||
|
DATABASE_NAME: "{{ dawarich_db_name }}"
|
||||||
|
RAILS_MAX_THREADS: "5"
|
||||||
|
# Redis
|
||||||
|
REDIS_URL: "redis://dawarich_redis:6379"
|
||||||
|
# Secrets
|
||||||
|
SECRET_KEY_BASE: "{{ dawarich_secret_key_base }}"
|
||||||
|
OTP_ENCRYPTION_PRIMARY_KEY: "{{ dawarich_otp_primary_key }}"
|
||||||
|
OTP_ENCRYPTION_DETERMINISTIC_KEY: "{{ dawarich_otp_deterministic_key }}"
|
||||||
|
OTP_ENCRYPTION_KEY_DERIVATION_SALT: "{{ dawarich_otp_salt }}"
|
||||||
|
# Registration policy — OIDC-only when enabled. Anything else and a
|
||||||
|
# local user form silently appears on /users/sign_up.
|
||||||
|
ALLOW_EMAIL_PASSWORD_REGISTRATION: "{{ dawarich_allow_email_password_registration | string | lower }}"
|
||||||
|
{% if dawarich_oidc_enabled %}
|
||||||
|
# ---- Auth: Authentik OIDC ----
|
||||||
|
OIDC_CLIENT_ID: "{{ dawarich_oidc_client_id }}"
|
||||||
|
OIDC_CLIENT_SECRET: "{{ dawarich_oidc_client_secret }}"
|
||||||
|
OIDC_ISSUER: "{{ dawarich_oidc_issuer }}"
|
||||||
|
OIDC_REDIRECT_URI: "{{ dawarich_oidc_redirect_uri }}"
|
||||||
|
OIDC_PROVIDER_NAME: "{{ dawarich_oidc_provider_name }}"
|
||||||
|
OIDC_AUTO_REGISTER: "{{ dawarich_oidc_auto_register | string | lower }}"
|
||||||
|
OIDC_PKCE_ENABLED: "true"
|
||||||
|
{% endif %}
|
||||||
|
# TCP socket check, not HTTP. Rails 8 force_ssl (APPLICATION_PROTOCOL=https)
|
||||||
|
# redirects every plain-HTTP path to https — curl/wget can't satisfy a 2xx
|
||||||
|
# without TLS, and Puma is plain-HTTP (Caddy terminates TLS upstream). The
|
||||||
|
# socket check confirms Puma is listening, which is what we actually care
|
||||||
|
# about for orchestration.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "ruby -rsocket -e 'TCPSocket.new(\"localhost\",3000).close' || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 120s
|
||||||
|
|
||||||
|
dawarich_sidekiq:
|
||||||
|
image: "{{ dawarich_image }}"
|
||||||
|
container_name: dawarich_sidekiq
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
dawarich_db:
|
||||||
|
condition: service_healthy
|
||||||
|
dawarich_redis:
|
||||||
|
condition: service_healthy
|
||||||
|
dawarich_app:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- /opt/dawarich/public:/var/app/public
|
||||||
|
- /opt/dawarich/watched:/var/app/tmp/imports/watched
|
||||||
|
- /opt/dawarich/storage:/var/app/storage
|
||||||
|
# Solid_cache + solid_queue + solid_cable SQLite files. Without this
|
||||||
|
# bind, the queue DB is ephemeral and in-flight Sidekiq jobs are lost
|
||||||
|
# on container recreate. Path matches the entrypoint's expectation.
|
||||||
|
- /opt/dawarich/db_data:/dawarich_db_data
|
||||||
|
# Same entrypoint pattern as the web tier — sidekiq's script ensures the
|
||||||
|
# DB is migrated before workers start consuming jobs.
|
||||||
|
entrypoint: ["/usr/local/bin/sidekiq-entrypoint.sh"]
|
||||||
|
command: ["sidekiq"]
|
||||||
|
environment:
|
||||||
|
RAILS_ENV: production
|
||||||
|
SELF_HOSTED: "true"
|
||||||
|
# localhost + ::1 needed for Docker healthcheck (Rails 8 host
|
||||||
|
# authorization rejects any host not in this list, including loopback).
|
||||||
|
APPLICATION_HOSTS: "{{ dawarich_domain }},localhost,127.0.0.1,::1"
|
||||||
|
APPLICATION_PROTOCOL: "https"
|
||||||
|
DOMAIN: "{{ dawarich_domain }}"
|
||||||
|
TIME_ZONE: "{{ timezone }}"
|
||||||
|
DATABASE_HOST: dawarich_db
|
||||||
|
DATABASE_PORT: "5432"
|
||||||
|
DATABASE_USERNAME: "{{ dawarich_db_user }}"
|
||||||
|
DATABASE_PASSWORD: "{{ dawarich_db_password }}"
|
||||||
|
DATABASE_NAME: "{{ dawarich_db_name }}"
|
||||||
|
REDIS_URL: "redis://dawarich_redis:6379"
|
||||||
|
SECRET_KEY_BASE: "{{ dawarich_secret_key_base }}"
|
||||||
|
OTP_ENCRYPTION_PRIMARY_KEY: "{{ dawarich_otp_primary_key }}"
|
||||||
|
OTP_ENCRYPTION_DETERMINISTIC_KEY: "{{ dawarich_otp_deterministic_key }}"
|
||||||
|
OTP_ENCRYPTION_KEY_DERIVATION_SALT: "{{ dawarich_otp_salt }}"
|
||||||
|
BACKGROUND_PROCESSING_CONCURRENCY: "{{ dawarich_sidekiq_concurrency }}"
|
||||||
|
|
||||||
|
watchtower:
|
||||||
|
container_name: dawarich_watchtower
|
||||||
|
image: containrrr/watchtower:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
dns:
|
||||||
|
- 192.168.1.1
|
||||||
|
ports:
|
||||||
|
- "8088:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
- DOCKER_API_VERSION=1.40
|
||||||
|
- WATCHTOWER_HTTP_API_UPDATE=true
|
||||||
|
- WATCHTOWER_HTTP_API_TOKEN={{ watchtower_api_token }}
|
||||||
|
- TZ={{ timezone }}
|
||||||
|
- WATCHTOWER_CLEANUP=true
|
||||||
|
- WATCHTOWER_INCLUDE_RESTARTING=true
|
||||||
|
- WATCHTOWER_NO_STARTUP_MESSAGE=true
|
||||||
|
- WATCHTOWER_NOTIFICATION_REPORT=true
|
||||||
|
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||||
|
- WATCHTOWER_NOTIFICATION_URL={{ watchtower_gotify_url }}
|
||||||
|
- WATCHTOWER_NOTIFICATIONS_HOSTNAME=DAWARICH
|
||||||
|
- WATCHTOWER_NOTIFICATION_TITLE_TAG=DAWARICH
|
||||||
|
# Render empty when nothing was updated/failed — see feedback_watchtower_pushover.
|
||||||
|
- 'WATCHTOWER_NOTIFICATION_TEMPLATE={% raw %}{{- if .Report -}}{{- with .Report -}}{{- if or .Updated .Failed -}}{{ len .Updated }} updated, {{ len .Failed }} failed{{- range .Updated}} | updated {{.Name}}{{- end -}}{{- range .Failed}} | FAILED {{.Name}}{{- end -}}{{- end -}}{{- end -}}{{- end -}}{% endraw %}'
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# bootstrap-secrets.sh — Generate + push the 5 Dawarich app secrets to Infisical
|
||||||
|
#
|
||||||
|
# Run ONCE before the first deploy. Idempotent in the sense that re-running it
|
||||||
|
# rotates every key — which will invalidate existing Rails sessions + the OTP-
|
||||||
|
# encrypted columns. Do NOT re-run blindly on an existing install.
|
||||||
|
# ==============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INF_PROJECT="50062d7c-06ff-4d5c-8ca3-6c0cdba9f270"
|
||||||
|
ENV="prod"
|
||||||
|
PATH_PREFIX="/dawarich"
|
||||||
|
|
||||||
|
command -v infisical >/dev/null || { echo "ERROR: infisical CLI not installed"; exit 1; }
|
||||||
|
command -v openssl >/dev/null || { echo "ERROR: openssl not installed"; exit 1; }
|
||||||
|
|
||||||
|
push() {
|
||||||
|
local name="$1" value="$2"
|
||||||
|
echo " -> $name"
|
||||||
|
infisical secrets set "${name}=${value}" \
|
||||||
|
--projectId "$INF_PROJECT" --env "$ENV" --path "$PATH_PREFIX" \
|
||||||
|
--type shared >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Generating + pushing 5 Dawarich secrets to ${PATH_PREFIX}/ ..."
|
||||||
|
push vault_dawarich_db_password "$(openssl rand -hex 32)"
|
||||||
|
push vault_dawarich_secret_key_base "$(openssl rand -hex 64)"
|
||||||
|
push vault_dawarich_otp_primary_key "$(openssl rand -hex 32)"
|
||||||
|
push vault_dawarich_otp_deterministic_key "$(openssl rand -hex 32)"
|
||||||
|
push vault_dawarich_otp_salt "$(openssl rand -hex 32)"
|
||||||
|
|
||||||
|
echo "==> Done."
|
||||||
|
echo
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1) Apply terraform (module.dawarich) — provisions CT 459 on pve02 at .159."
|
||||||
|
echo " 2) Bootstrap cbalders user + SSH keys + NOPASSWD sudo on the new CT (see README)."
|
||||||
|
echo " 3) Generate + push 2 OIDC credentials (pi-auth uses these to CREATE the Authentik client — they are not fetched back):"
|
||||||
|
echo " infisical secrets set \"vault_dawarich_oidc_client_id=\$(openssl rand -hex 20)\" --projectId 50062d7c-06ff-4d5c-8ca3-6c0cdba9f270 --env prod --path /oidc --type shared"
|
||||||
|
echo " infisical secrets set \"vault_dawarich_oidc_client_secret=\$(openssl rand -hex 32)\" --projectId 50062d7c-06ff-4d5c-8ca3-6c0cdba9f270 --env prod --path /oidc --type shared"
|
||||||
|
echo " 4) Confirm homelab-ansible-pi-auth/site.yml has the dawarich_oidc_client_{id,secret} set_fact mapping."
|
||||||
|
echo " 5) First ./deploy.sh — picks up dawarich:1.7.11 (OIDC requires >= 1.7.8), runs migrations, comes up healthy."
|
||||||
|
echo " 6) cd ../homelab-ansible-pi-auth && ./deploy.sh — creates Authentik provider + application."
|
||||||
|
echo " 7) cd ../homelab-ansible-proxy && ./deploy.sh — adds Caddy vhost + Technitium CNAME."
|
||||||
|
echo " 8) cd ../homelab-ansible-pve && ./deploy.sh — adds CTID 459 to pbs-prod-daily."
|
||||||
|
echo " 9) Browser-test https://dawarich.balders.ca/users/sign_in → 'Sign in with Authentik'."
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
# ==============================================================================
|
||||||
|
# Dawarich LXC — Self-hosted location history (Google Timeline alternative)
|
||||||
|
# ==============================================================================
|
||||||
|
# Rails app + Sidekiq worker + Redis + PostGIS, all on this LXC. PostGIS lives
|
||||||
|
# here (not central DB VM .172) because the DB VM's postgres:16-alpine image
|
||||||
|
# doesn't bundle the postgis extension — see memory project_dawarich for the
|
||||||
|
# convention-exception rationale.
|
||||||
|
#
|
||||||
|
# Ingests: OwnTracks (HTTP, no MQTT broker needed), Dawarich's own iOS/Android
|
||||||
|
# app, Google Takeout exports. Auth: Authentik OIDC end-to-end.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
- name: Deploy Dawarich LXC
|
||||||
|
hosts: all
|
||||||
|
become: true
|
||||||
|
vars_files:
|
||||||
|
- vars/main.yml
|
||||||
|
- vars/vault.yml
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Deploy banner
|
||||||
|
debug:
|
||||||
|
msg: "===== {{ ansible_play_name }} → {{ inventory_hostname }} ({{ ansible_host }}) ====="
|
||||||
|
|
||||||
|
- name: Install infisicalsdk on controller
|
||||||
|
pip:
|
||||||
|
name: infisicalsdk
|
||||||
|
state: present
|
||||||
|
extra_args: --break-system-packages
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
- name: Authenticate with Infisical
|
||||||
|
infisical.vault.login:
|
||||||
|
url: "{{ infisical_url }}"
|
||||||
|
auth_method: universal_auth
|
||||||
|
universal_auth_client_id: "{{ infisical_client_id }}"
|
||||||
|
universal_auth_client_secret: "{{ infisical_client_secret }}"
|
||||||
|
register: infisical_login
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
- name: Read dawarich secrets
|
||||||
|
infisical.vault.read_secrets:
|
||||||
|
login_data: "{{ infisical_login.login_data }}"
|
||||||
|
project_id: "{{ infisical_project_id }}"
|
||||||
|
env_slug: "prod"
|
||||||
|
path: "/dawarich"
|
||||||
|
as_dict: true
|
||||||
|
register: repo_secrets
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
# OIDC creds live in /oidc alongside every other Authentik client (the same
|
||||||
|
# pair pi-auth pushes into Authentik). default('') keeps the play
|
||||||
|
# idempotent before secrets exist / OIDC is flipped on.
|
||||||
|
- name: Read oidc secrets
|
||||||
|
infisical.vault.read_secrets:
|
||||||
|
login_data: "{{ infisical_login.login_data }}"
|
||||||
|
project_id: "{{ infisical_project_id }}"
|
||||||
|
env_slug: "prod"
|
||||||
|
path: "/oidc"
|
||||||
|
as_dict: true
|
||||||
|
register: oidc_secrets
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
- name: Read shared secrets
|
||||||
|
infisical.vault.read_secrets:
|
||||||
|
login_data: "{{ infisical_login.login_data }}"
|
||||||
|
project_id: "{{ infisical_project_id }}"
|
||||||
|
env_slug: "prod"
|
||||||
|
path: "/shared"
|
||||||
|
as_dict: true
|
||||||
|
register: shared_secrets
|
||||||
|
delegate_to: localhost
|
||||||
|
become: false
|
||||||
|
run_once: true
|
||||||
|
|
||||||
|
- name: Map secrets to vars
|
||||||
|
set_fact:
|
||||||
|
dawarich_db_password: "{{ repo_secrets.secrets.vault_dawarich_db_password }}"
|
||||||
|
dawarich_secret_key_base: "{{ repo_secrets.secrets.vault_dawarich_secret_key_base }}"
|
||||||
|
# OTP encryption keys — Dawarich ships built-in defaults but rotating them
|
||||||
|
# off the public defaults is a one-line hardening win. All three required.
|
||||||
|
dawarich_otp_primary_key: "{{ repo_secrets.secrets.vault_dawarich_otp_primary_key }}"
|
||||||
|
dawarich_otp_deterministic_key: "{{ repo_secrets.secrets.vault_dawarich_otp_deterministic_key }}"
|
||||||
|
dawarich_otp_salt: "{{ repo_secrets.secrets.vault_dawarich_otp_salt }}"
|
||||||
|
dawarich_oidc_client_id: "{{ oidc_secrets.secrets.vault_dawarich_oidc_client_id | default('') }}"
|
||||||
|
dawarich_oidc_client_secret: "{{ oidc_secrets.secrets.vault_dawarich_oidc_client_secret | default('') }}"
|
||||||
|
watchtower_gotify_url: "{{ shared_secrets.secrets.vault_watchtower_gotify_url }}"
|
||||||
|
watchtower_api_token: "{{ shared_secrets.secrets.vault_watchtower_api_token }}"
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- dawarich
|
||||||
|
- alloy
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
timezone: America/Toronto
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- apt-utils
|
||||||
|
- bash-completion
|
||||||
|
- ca-certificates
|
||||||
|
- curl
|
||||||
|
- git
|
||||||
|
- gnupg
|
||||||
|
- htop
|
||||||
|
- net-tools
|
||||||
|
- openssh-server
|
||||||
|
- python3
|
||||||
|
- python3-pip
|
||||||
|
- sudo
|
||||||
|
- vim
|
||||||
|
- wget
|
||||||
|
|
||||||
|
users:
|
||||||
|
- name: cbalders
|
||||||
|
groups: sudo
|
||||||
|
shell: /bin/bash
|
||||||
|
|
||||||
|
ssh_authorized_keys:
|
||||||
|
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINALaic1jpoP6t1urbZqJLI1eU5NeTVD9k8AAMAvOvvk OfficeMini"
|
||||||
|
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGzTHdCiQjhIHsGB8oMpyKtr9TZXrXeIRKwcwe698zMW Generated By Termius"
|
||||||
|
|
||||||
|
# Alloy ships Docker logs + journald to Loki on observe.lan.balders.ca + node
|
||||||
|
# metrics to Prometheus. Pattern mirrors every other LXC.
|
||||||
|
alloy_host_label: dawarich
|
||||||
|
alloy_loki_url: http://observe.lan.balders.ca:3100/loki/api/v1/push
|
||||||
|
alloy_prom_job: node_lxc
|
||||||
|
alloy_prom_group: lxc
|
||||||
|
alloy_prom_hostname: dawarich
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Dawarich — self-hosted location history
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Pin image tags (Dawarich ships breaking schema changes on minor bumps). Bump
|
||||||
|
# in lockstep across app + sidekiq, never split — they share the same DB
|
||||||
|
# migrations. Check release notes before bumping.
|
||||||
|
# Pinned tag — OIDC support first landed in 1.7.8 (2026-05-16). DO NOT
|
||||||
|
# downgrade below 1.7.8 without disabling OIDC. Bump in lockstep with sidekiq.
|
||||||
|
dawarich_image: "freikin/dawarich:1.7.11"
|
||||||
|
# Official PostGIS image — alpine variant. Strict superset of postgres:16-alpine
|
||||||
|
# so swapping to it later (if we ever centralize on DB VM) is non-breaking.
|
||||||
|
postgis_image: "postgis/postgis:16-3.4-alpine"
|
||||||
|
redis_image: "redis:7-alpine"
|
||||||
|
|
||||||
|
dawarich_port: 3000
|
||||||
|
dawarich_site_url: "https://dawarich.balders.ca"
|
||||||
|
dawarich_domain: "dawarich.balders.ca"
|
||||||
|
|
||||||
|
# Database (local — central DB VM doesn't load PostGIS extension; see memory
|
||||||
|
# project_dawarich for the convention exception rationale).
|
||||||
|
dawarich_db_name: "dawarich"
|
||||||
|
dawarich_db_user: "dawarich"
|
||||||
|
|
||||||
|
# Background processing — keep modest to leave headroom for the web tier.
|
||||||
|
# Dawarich docs default 5; bump cautiously if Sidekiq queue depth grows.
|
||||||
|
dawarich_sidekiq_concurrency: 5
|
||||||
|
|
||||||
|
# Geocoding — disabled in v1. Dawarich falls back to its internal lightweight
|
||||||
|
# lookup. Self-hosted Photon stack is a future addition; uncomment + set host
|
||||||
|
# when wired.
|
||||||
|
# dawarich_photon_api_host: "photon.lan.balders.ca"
|
||||||
|
|
||||||
|
# OIDC via Authentik. Client id/secret live in Infisical /oidc/ (pushed into
|
||||||
|
# Authentik by homelab-ansible-pi-auth's oidc_clients role).
|
||||||
|
dawarich_oidc_enabled: true
|
||||||
|
dawarich_oidc_issuer: "https://auth.balders.ca/application/o/dawarich/"
|
||||||
|
dawarich_oidc_redirect_uri: "https://dawarich.balders.ca/users/auth/openid_connect/callback"
|
||||||
|
dawarich_oidc_provider_name: "Authentik"
|
||||||
|
# false → only OIDC users can sign in (we want SSO-only). First OIDC login
|
||||||
|
# becomes the canonical user account.
|
||||||
|
dawarich_allow_email_password_registration: false
|
||||||
|
dawarich_oidc_auto_register: true
|
||||||
|
|
||||||
|
# Infisical (secrets source)
|
||||||
|
infisical_url: "https://secrets.balders.ca"
|
||||||
|
infisical_project_id: "50062d7c-06ff-4d5c-8ca3-6c0cdba9f270"
|
||||||
|
infisical_client_id: "828d2cc8-eb25-4b1e-a711-c9a4b1580106"
|
||||||
|
infisical_client_secret: "{{ vault_infisical_client_secret }}"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
$ANSIBLE_VAULT;1.1;AES256
|
||||||
|
66366365356236623964366166336662353433626337323337343365316662636332356636336534
|
||||||
|
6364616163666431333863613639353837623165636264390a363030376536373966316230356335
|
||||||
|
30623466653337326133666539343966656362613964353763636539623634396364633137323733
|
||||||
|
3636613464393534660a313334393333343835616235613833346663373537363738383064363437
|
||||||
|
34373430306665376639633032373961653134303233613164633738356166376234663039303138
|
||||||
|
65313065383061636263393262353139646239383638303036313662373663316132333666366537
|
||||||
|
65333866356235373830323734623730356138653338663538616666643230303835653461343236
|
||||||
|
31616161333461356665316238363133316134376665353437386564313939356137313331613333
|
||||||
|
35653238383931376131323834383633313930396533323032363863666138383332
|
||||||
Reference in New Issue
Block a user