initial config
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Watchtower is a single-node observability stack running on a Hetzner CPX42 in
|
||||
Nuremberg (nbg1). It hosts Mimir (metrics), Loki (logs), Tempo (traces), and
|
||||
Grafana (UI). Clients ship telemetry over a WireGuard VPN; nothing is exposed
|
||||
to the public internet.
|
||||
|
||||
## Diagram
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ Hetzner Cloud Firewall│ allow: 22/tcp, 51820/udp
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ AlmaLinux 10│
|
||||
│ CPX42 │
|
||||
│ │
|
||||
│ nftables │ default-drop input + fail2ban
|
||||
│ ┌──────────┴──────────┐
|
||||
│ │ wg0 (10.8.0.1/24) │ ◄── WireGuard peers
|
||||
│ └──┬──────────────────┘
|
||||
│ │ all service ports bind to 10.8.0.1
|
||||
│ ┌──┴──────────────────────┐
|
||||
│ │ Podman observability │
|
||||
│ │ network (172.20.0.0/24) │
|
||||
│ │ mimir :8080 :9095 │
|
||||
│ │ loki :3100 │
|
||||
│ │ tempo :3200/4317/4318│
|
||||
│ │ grafana :3000 │
|
||||
│ └──┬──────────────────────┘
|
||||
│ │
|
||||
│ /var/lib/observability ◄── LUKS2 (xfs)
|
||||
└─────┘
|
||||
│ S3 API (HTTPS)
|
||||
▼
|
||||
Hetzner Object Storage (nbg1)
|
||||
- watchtower-mimir
|
||||
- watchtower-loki
|
||||
- watchtower-tempo
|
||||
```
|
||||
|
||||
## Data flow
|
||||
|
||||
1. **Clients** run Grafana Alloy with PII scrubbing pipelines (see
|
||||
`config/alloy/config.alloy.example`).
|
||||
2. Alloy ships **logs → Loki**, **traces → Tempo OTLP**, **metrics → Mimir
|
||||
remote_write**, all over WireGuard with `X-Scope-OrgID` headers.
|
||||
3. Mimir/Loki/Tempo write blocks/chunks to Hetzner Object Storage. WALs and
|
||||
compactor scratch live on the LUKS-encrypted local volume.
|
||||
4. Grafana queries Mimir/Loki/Tempo via the `observability` Podman bridge
|
||||
(container DNS), with tenant header injected per Org's datasource config.
|
||||
|
||||
## Components and ports
|
||||
|
||||
| Service | Container | Bind | Public? |
|
||||
|----------|-----------|------------------|---------|
|
||||
| Mimir | mimir | 10.8.0.1:8080,9095| no |
|
||||
| Loki | loki | 10.8.0.1:3100 | no |
|
||||
| Tempo | tempo | 10.8.0.1:3200, OTLP 4317/4318 | no |
|
||||
| Grafana | grafana | 10.8.0.1:3000 | no |
|
||||
|
||||
## Storage
|
||||
|
||||
| Layer | Backend | Encryption |
|
||||
|-------------|----------------|-----------------------------|
|
||||
| Long-term | Hetzner S3 | Server-side (Hetzner SSE) |
|
||||
| Local WAL | LUKS2 + xfs | At-rest (LUKS2 on /dev/sdX or loop) |
|
||||
| Transit | WireGuard | ChaCha20-Poly1305 |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- **OpenTofu** provisions the server, firewall, and S3 buckets (state in
|
||||
Cloudflare R2).
|
||||
- **Ansible** configures AlmaLinux, LUKS, nftables + fail2ban, WireGuard, and deploys
|
||||
the Podman Quadlets.
|
||||
- **systemd Quadlet generator** turns `*.container` files into
|
||||
`mimir.service`, `loki.service`, `tempo.service`, `grafana.service`.
|
||||
@@ -0,0 +1,137 @@
|
||||
# GDPR Compliance
|
||||
|
||||
This stack processes telemetry that is **likely to contain personal data**
|
||||
(IPs, user IDs, request bodies, emails). The deployment is hosted by Hetzner
|
||||
in Nuremberg, Germany — all data stays within the EU.
|
||||
|
||||
## Roles
|
||||
|
||||
- **Data Controller:** the operator deploying this stack.
|
||||
- **Data Processor (infrastructure):** Hetzner Online GmbH (DE).
|
||||
- **Sub-processors:** none by default.
|
||||
|
||||
## Legal basis (Art. 6)
|
||||
|
||||
Document the basis for each tenant under
|
||||
`docs/gdpr-compliance.md` (per-tenant section to be filled in). Typical bases:
|
||||
|
||||
- **Art. 6(1)(f)** legitimate interest — operational logging.
|
||||
- **Art. 6(1)(b)** contract performance — telemetry necessary to deliver service.
|
||||
|
||||
## Required documents
|
||||
|
||||
| Document | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Data Processing Agreement with Hetzner | sign in Cloud Console | required Art. 28 |
|
||||
| Record of Processing Activities (RoPA) | template below | required Art. 30 |
|
||||
| Data Protection Impact Assessment (DPIA) | required | Art. 35 — high-risk processing |
|
||||
| Breach response runbook | `docs/runbook.md` | Art. 33/34 |
|
||||
|
||||
## Data residency
|
||||
|
||||
All telemetry buckets live in `nbg1` (Hetzner, Nuremberg). The OpenTofu
|
||||
state lives in Cloudflare R2 — operators must configure R2 to use only EU
|
||||
jurisdictions (`Jurisdiction: EU`) when creating the state bucket, otherwise
|
||||
state metadata may be replicated outside the EU.
|
||||
|
||||
## Retention register
|
||||
|
||||
| Signal | Application Retention | Bucket Lifecycle | Effective Erasure Window |
|
||||
|---------|----------------------|------------------|--------------------------|
|
||||
| Logs | 30 days (Loki) | 90 days (S3 lifecycle) | 30 days (compactor) |
|
||||
| Metrics | 90 days (Mimir) | 365 days (S3 lifecycle)| 90 days (compactor) |
|
||||
| Traces | 14 days (Tempo) | 30 days (S3 lifecycle) | 14 days (compactor) |
|
||||
|
||||
Bucket-level lifecycle is a **safety net** — application-level retention is
|
||||
the primary erasure mechanism. The wider lifecycle window allows
|
||||
application-level rollback in case of a misconfiguration.
|
||||
|
||||
## PII minimization
|
||||
|
||||
### Collector-side scrubbing
|
||||
|
||||
Clients run Grafana Alloy with the redaction pipeline at
|
||||
`config/alloy/config.alloy.example`. Patterns redacted:
|
||||
|
||||
- IPv4 / IPv6 addresses → `[REDACTED-IP*]`
|
||||
- Email addresses → `[REDACTED-EMAIL]`
|
||||
- JWT tokens → `[REDACTED-JWT]`
|
||||
- Bearer tokens in `Authorization` headers → `[REDACTED-TOKEN]`
|
||||
- Card numbers (PAN, 13–19 digits) → `[REDACTED-PAN]`
|
||||
- UUIDs → `[REDACTED-UUID]`
|
||||
- Lines containing `password=`, `ssn=`, `cvv=`, `cvc=` → **dropped**
|
||||
|
||||
### OTLP attribute scrubbing
|
||||
|
||||
The `otelcol.processor.attributes` block hashes/deletes these attributes
|
||||
before traces reach Tempo:
|
||||
|
||||
| Attribute | Action |
|
||||
|-----------|--------|
|
||||
| `enduser.id` | hashed (correlatable but not reversible) |
|
||||
| `user.email` | deleted |
|
||||
| `client.address` | deleted |
|
||||
| `http.request.header.authorization` | deleted |
|
||||
| `http.request.header.cookie` | deleted |
|
||||
| `http.request.body` | deleted |
|
||||
|
||||
### Label / cardinality discipline
|
||||
|
||||
Mimir and Tempo do **not** support per-record deletion. To stay compliant,
|
||||
**no PII may ever appear in metric labels or trace resource attributes**.
|
||||
Tenants must use opaque identifiers (UUIDs hashed at the collector, opaque
|
||||
session IDs) — never emails, names, or phone numbers as label values.
|
||||
|
||||
## Right to erasure (Art. 17)
|
||||
|
||||
| Signal | Mechanism | RTO |
|
||||
|---------|-----------|-----|
|
||||
| Logs | Loki Delete API via `playbooks/gdpr-purge.yml` | hours |
|
||||
| Metrics | Retention only — short window + label discipline | ≤90 days |
|
||||
| Traces | Retention only — 14-day window | ≤14 days |
|
||||
|
||||
Document each erasure in `/var/log/observability/gdpr-audit.log` (the playbook
|
||||
appends automatically). Notify the data subject within one month per Art. 12(3).
|
||||
|
||||
## Encryption
|
||||
|
||||
| Layer | Mechanism |
|
||||
|-------|-----------|
|
||||
| In transit (client → server) | WireGuard (ChaCha20-Poly1305) |
|
||||
| In transit (server → S3) | TLS 1.2+ |
|
||||
| At rest (local WAL/Grafana DB) | LUKS2 |
|
||||
| At rest (S3) | Hetzner SSE |
|
||||
|
||||
## Access control & audit
|
||||
|
||||
- WireGuard: each peer has its own key; revoke by removing from `wg0.conf`.
|
||||
- Host firewall: nftables with default-drop input policy. Service ports are
|
||||
bound to `10.8.0.1` (wg0) and unreachable on the public interface. SSH and
|
||||
WireGuard are restricted to administrator CIDRs (`admin_allow_ipv4` /
|
||||
`admin_allow_ipv6`) and rate-limited at the kernel.
|
||||
- fail2ban: brute-force defense for SSH (jail `sshd`) plus a `recidive` jail
|
||||
that long-bans repeat offenders. Bans are enforced via fail2ban's own
|
||||
nftables table (`inet f2b-table`).
|
||||
- Grafana: `[auditing]` enabled — all dashboard / API actions logged to
|
||||
`/var/log/grafana/audit.log` with rotation.
|
||||
- SSH: ed25519 keys only, root password auth disabled.
|
||||
- Tenant isolation: enforced by `X-Scope-OrgID` at Mimir/Loki/Tempo plus
|
||||
Grafana Org RBAC.
|
||||
|
||||
## Breach response (Art. 33/34)
|
||||
|
||||
72-hour notification to the **Bayerisches Landesamt für Datenschutzaufsicht
|
||||
(BayLDA)** — Bavaria's supervisory authority. Procedure documented in
|
||||
`docs/runbook.md` § Breach Response.
|
||||
|
||||
## RoPA template
|
||||
|
||||
For each tenant, fill in:
|
||||
|
||||
- Name & contact of controller
|
||||
- Categories of data subjects
|
||||
- Categories of personal data processed in telemetry
|
||||
- Recipients (none — internal only)
|
||||
- Transfers outside EEA (none)
|
||||
- Retention periods (per signal — see above)
|
||||
- Technical & organisational measures (this document)
|
||||
@@ -0,0 +1,60 @@
|
||||
# Multi-tenancy
|
||||
|
||||
This stack is multi-tenant: multiple teams or projects share the same Mimir,
|
||||
Loki, Tempo, and Grafana instances. Tenant isolation is enforced at two layers:
|
||||
|
||||
1. **Storage layer** — Mimir/Loki/Tempo route reads and writes by the
|
||||
`X-Scope-OrgID` HTTP header. Each tenant has a unique slug.
|
||||
2. **Grafana layer** — one Org per tenant. Each Org has its own datasources
|
||||
that inject the tenant's `X-Scope-OrgID` automatically.
|
||||
|
||||
## Tenant slug rules
|
||||
|
||||
- Lowercase ASCII letters, digits, and dashes only.
|
||||
- Max 40 characters.
|
||||
- Must be unique across the deployment.
|
||||
- Examples: `team-alpha`, `web-prod`, `customer-acme`.
|
||||
|
||||
## Onboarding
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/playbooks/onboard-tenant.yml \
|
||||
-e tenant_slug=team-alpha \
|
||||
-e tenant_name='Team Alpha' \
|
||||
-e admin_email=alpha-admin@example.com
|
||||
```
|
||||
|
||||
This creates a Grafana Org named "Team Alpha", switches into it, and
|
||||
provisions Mimir/Loki/Tempo datasources whose secure HTTP header
|
||||
`X-Scope-OrgID = team-alpha` is set.
|
||||
|
||||
## Client configuration
|
||||
|
||||
Clients use Grafana Alloy with the tenant slug embedded:
|
||||
|
||||
```alloy
|
||||
loki.write "default" {
|
||||
endpoint {
|
||||
url = "http://10.8.0.1:3100/loki/api/v1/push"
|
||||
headers = { "X-Scope-OrgID" = "team-alpha" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See `config/alloy/config.alloy.example`.
|
||||
|
||||
## Offboarding
|
||||
|
||||
1. Submit Loki delete requests for the tenant's logs:
|
||||
`ansible-playbook playbooks/gdpr-purge.yml -e tenant=team-alpha -e selector='{job=~".+"}' -e start=2020-01-01T00:00:00Z`
|
||||
2. Stop ingesting (revoke the tenant's WireGuard peer + remove their Alloy
|
||||
instance).
|
||||
3. Delete the Grafana Org via the Grafana API.
|
||||
4. Wait for retention to lapse on Mimir/Tempo (90/14 days respectively) — they
|
||||
have no per-tenant deletion API on this version.
|
||||
|
||||
## Per-tenant overrides
|
||||
|
||||
Mimir limits, Loki retention, etc. can be customized per tenant via
|
||||
`runtime.yaml` (Mimir) or `limits_config` overrides (Loki). Add per-tenant
|
||||
sections under `overrides:` in each config — see upstream docs for the schema.
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
# Runbook
|
||||
|
||||
## Day-to-day operations
|
||||
|
||||
### Start / stop services
|
||||
|
||||
```bash
|
||||
systemctl start mimir loki tempo grafana
|
||||
systemctl stop mimir loki tempo grafana
|
||||
systemctl status mimir
|
||||
journalctl -u mimir -f
|
||||
```
|
||||
|
||||
### Reload config
|
||||
|
||||
After editing a config template:
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/playbooks/deploy.yml --ask-vault-pass
|
||||
```
|
||||
|
||||
The handler restarts only services whose config changed.
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
journalctl -u loki --since '1 hour ago'
|
||||
podman logs -f loki
|
||||
```
|
||||
|
||||
## WireGuard
|
||||
|
||||
### Add a peer
|
||||
|
||||
1. Peer generates a key pair: `wg genkey | tee priv | wg pubkey > pub`.
|
||||
2. Add to `ansible/group_vars/all/vault.yml` (vault-encrypted):
|
||||
```yaml
|
||||
vault_wireguard_peers:
|
||||
- name: alice-laptop
|
||||
public_key: <CONTENTS_OF_PUB>
|
||||
allowed_ips: 10.8.0.11/32
|
||||
```
|
||||
3. `ansible-playbook playbooks/site.yml --tags wireguard --ask-vault-pass`
|
||||
4. Send peer their config (including server public key + endpoint).
|
||||
|
||||
### Revoke a peer
|
||||
|
||||
1. Remove the peer block from vault.
|
||||
2. Re-run the wireguard role.
|
||||
3. Live revocation: `wg set wg0 peer <PUB> remove`.
|
||||
|
||||
## GDPR right to erasure
|
||||
|
||||
```bash
|
||||
ansible-playbook ansible/playbooks/gdpr-purge.yml \
|
||||
-e tenant=team-alpha \
|
||||
-e 'selector={user_id="abc-123"}' \
|
||||
-e start=2025-01-01T00:00:00Z \
|
||||
--ask-vault-pass
|
||||
```
|
||||
|
||||
The Loki compactor processes deletes after `retention_delete_delay = 2h`.
|
||||
Confirm completion via:
|
||||
|
||||
```bash
|
||||
curl -H 'X-Scope-OrgID: team-alpha' \
|
||||
'http://10.8.0.1:3100/loki/api/v1/delete'
|
||||
```
|
||||
|
||||
Audit log: `/var/log/observability/gdpr-audit.log`.
|
||||
|
||||
## Breach response
|
||||
|
||||
1. **Detect** — alert from Grafana audit log or Hetzner abuse notice.
|
||||
2. **Contain** — block the affected WireGuard peer, rotate Hetzner Object
|
||||
Storage credentials (Cloud Console → Security → Generate new keys), update
|
||||
`vault.yml`, redeploy.
|
||||
3. **Assess** — what tenant, what data, what window. Use Grafana audit log
|
||||
and journald for forensics.
|
||||
4. **Notify** — within 72 hours notify **BayLDA**
|
||||
(https://www.lda.bayern.de/). If high risk to data subjects, notify them
|
||||
directly per Art. 34.
|
||||
5. **Document** — append to `/var/log/observability/breach-log.md`.
|
||||
|
||||
## Recovery / DR
|
||||
|
||||
- All telemetry is reproducible from clients — no need to back up local WAL.
|
||||
- Grafana DB (dashboards, users, orgs): nightly export via
|
||||
`scripts/backup-grafana.sh` to S3 (`watchtower-grafana-backup`).
|
||||
- OpenTofu state: versioned in Cloudflare R2 with object versioning enabled.
|
||||
- Server replacement procedure:
|
||||
1. `tofu apply -replace=hcloud_server.watchtower`
|
||||
2. `ansible-playbook playbooks/site.yml`
|
||||
3. Restore Grafana DB from backup.
|
||||
4. Buckets are unchanged → telemetry resumes ingesting.
|
||||
|
||||
## Common issues
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| Service won't start, `cannot assign requested address` | wg0 not up | `systemctl status wg-quick@wg0`; restart |
|
||||
| Mimir 403 from S3 | bad keys in `s3.env` | regenerate Hetzner keys, redeploy |
|
||||
| Loki "no org id" | client missing X-Scope-OrgID header | check Alloy config |
|
||||
| LUKS volume not mounted after reboot | keyfile missing | check `/root/observability.luks.key` exists, re-run luks role |
|
||||
| Grafana login redirects loop | cookie_secure=true behind plain HTTP | leave cookie_secure=false (VPN provides encryption) |
|
||||
|
||||
## Host firewall (nftables)
|
||||
|
||||
### Inspect ruleset
|
||||
```bash
|
||||
# Watchtower's own table:
|
||||
nft list table inet watchtower
|
||||
|
||||
# Everything (includes fail2ban's table inet f2b-table and Podman's tables):
|
||||
nft list ruleset
|
||||
```
|
||||
|
||||
### Reload after editing
|
||||
The ruleset is rendered to `/etc/nftables/watchtower.nft` and loaded via
|
||||
`/etc/sysconfig/nftables.conf`.
|
||||
|
||||
```bash
|
||||
# Validate before applying:
|
||||
nft --check --file /etc/nftables/watchtower.nft
|
||||
|
||||
# Apply:
|
||||
systemctl reload nftables # or: systemctl restart nftables
|
||||
```
|
||||
|
||||
### Drop counters / log
|
||||
Drops are logged at `info` level (rate-limited 5/min) with prefix `nft-drop-input:`.
|
||||
|
||||
```bash
|
||||
journalctl -k --since '1 hour ago' | grep nft-drop-input
|
||||
nft list table inet watchtower | grep counter
|
||||
```
|
||||
|
||||
### Adjust admin allow lists
|
||||
Edit `ansible/group_vars/all/main.yml` (`admin_allow_ipv4`, `admin_allow_ipv6`) and
|
||||
re-run `ansible-playbook ansible/playbooks/site.yml --tags firewall` (or just rerun
|
||||
the whole site playbook).
|
||||
|
||||
## fail2ban
|
||||
|
||||
### Status
|
||||
```bash
|
||||
fail2ban-client status # list of active jails
|
||||
fail2ban-client status sshd # per-jail: banned IPs, totals
|
||||
fail2ban-client status recidive
|
||||
```
|
||||
|
||||
### Unban an IP
|
||||
```bash
|
||||
fail2ban-client set sshd unbanip 203.0.113.42
|
||||
fail2ban-client set recidive unbanip 203.0.113.42
|
||||
```
|
||||
|
||||
### Inspect bans at the kernel level
|
||||
fail2ban uses its own nftables table — bans are sets inside `inet f2b-table`:
|
||||
|
||||
```bash
|
||||
nft list table inet f2b-table
|
||||
```
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
tail -f /var/log/fail2ban.log
|
||||
journalctl -u fail2ban -f
|
||||
```
|
||||
|
||||
### Tuning
|
||||
Defaults live in `ansible/roles/fail2ban/defaults/main.yml`. Override in
|
||||
`group_vars/all/main.yml` — e.g. shorten `fail2ban_findtime`, raise
|
||||
`fail2ban_maxretry`, or extend `fail2ban_recidive_bantime`.
|
||||
|
||||
## Automatic security updates
|
||||
|
||||
Configured via `dnf-automatic` (`roles/base`):
|
||||
|
||||
- **Scope**: `upgrade_type = security` only — feature/major version bumps are not auto-applied.
|
||||
- **Apply + reboot**: `apply_updates = yes`, `reboot = when-needed` (kernel/glibc/systemd
|
||||
triggers `shutdown -r +5`).
|
||||
- **Schedule**: `dnf-automatic-install.timer`, pinned via drop-in to `01:00 America/Chicago` (follows CST/CDT automatically)
|
||||
with up to 30m random delay.
|
||||
- **Excluded packages** (operator applies these manually): `podman*`, `conmon`,
|
||||
`crun`, `containers-common`, `netavark`, `aardvark-dns`, `container-selinux` —
|
||||
upgrading these mid-day would restart the observability stack.
|
||||
|
||||
### Inspect
|
||||
```bash
|
||||
systemctl list-timers dnf-automatic-install.timer
|
||||
systemctl status dnf-automatic-install.service
|
||||
journalctl -u dnf-automatic-install.service --since '2 days ago'
|
||||
dnf updateinfo list security # what's pending
|
||||
dnf needs-restarting -r # is a reboot needed?
|
||||
```
|
||||
|
||||
### Run on demand
|
||||
```bash
|
||||
systemctl start dnf-automatic-install.service
|
||||
```
|
||||
|
||||
### Update the excluded container packages (manual)
|
||||
Schedule a maintenance window, then:
|
||||
```bash
|
||||
dnf -y upgrade podman conmon crun containers-common netavark aardvark-dns container-selinux
|
||||
systemctl restart mimir loki tempo grafana
|
||||
```
|
||||
|
||||
### Disable temporarily
|
||||
```bash
|
||||
systemctl disable --now dnf-automatic-install.timer
|
||||
```
|
||||
Reference in New Issue
Block a user