Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a88bebc76d | ||
|
|
7897b50318 | ||
|
|
22143c6a7c | ||
|
|
bc896e550c | ||
|
|
ea6e8e5083 | ||
|
|
bf2b520fc4 | ||
|
|
780401fe91 | ||
|
|
f9a9dc7ff8 | ||
|
|
05abc3291f | ||
|
|
35a8a8d975 | ||
|
|
a003df0ef8 | ||
|
|
f0e48b226a | ||
|
|
669ef1c5ee | ||
|
|
11cd02ec44 | ||
|
|
68865e15c5 | ||
|
|
be2f8b9a18 | ||
|
|
7dacb6320c | ||
|
|
8a00b7735d |
@@ -7,6 +7,8 @@ when:
|
||||
# install-netbird-proxy.yml also applies the netbird-proxy-watchdog role,
|
||||
# so changes to it must redeploy this host too.
|
||||
- "ansible/roles/netbird-proxy-watchdog/**"
|
||||
# install-netbird-proxy.yml also applies the edge bouncer.
|
||||
- "ansible/roles/crowdsec-edge-bouncer/**"
|
||||
- "ansible/plays/install-netbird-proxy.yml"
|
||||
- ".woodpecker/deploy-netbird-proxy.yml"
|
||||
- event: deployment
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# Bouncer key for crowdsec-edge-bouncer, scoped to the proxy hosts.
|
||||
#
|
||||
# A single encrypted VALUE rather than a whole-file vault like its siblings in
|
||||
# group_vars/all and group_vars/edge_host. Deliberate: adding this variable to
|
||||
# an existing whole-file vault means decrypting that file first, which exposes
|
||||
# every other secret in it for the sake of appending one line. encrypt_string
|
||||
# keeps the blast radius at this one value.
|
||||
#
|
||||
# Same value as OpenBao homelab/infrastructure/crowdsec-edge-bouncer-key, which
|
||||
# the crowdsec-edge-bouncer-key ExternalSecret feeds to BOUNCER_KEY_edge on the
|
||||
# LAPI. Both sides must move together — and unlike crowdsec-bouncer-key, this
|
||||
# one is NOT in the quarterly rotator, because nothing on these hosts re-reads
|
||||
# OpenBao on a schedule. Rotating it means updating OpenBao and re-running
|
||||
# plays/install-netbird-proxy.yml.
|
||||
vault_crowdsec_edge_bouncer_key: !vault |
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
32653764333536316163303838333839616562663862666637626165303837633866643033393239
|
||||
3363376538656236643161386432326437633232643564630a633635306365323537346139626364
|
||||
36613033343938363366306239363638383466396337623333623136313234656366333366623433
|
||||
6437656632373364340a346566343866366433306533353431383064356362346536383136323663
|
||||
39333864643534326433653233303137373162663066633132623931316135323733333863306330
|
||||
6366623463666435636362376238313639666263323163303733
|
||||
@@ -26,3 +26,7 @@
|
||||
- docker-install
|
||||
- netbird-proxy
|
||||
- netbird-proxy-watchdog
|
||||
# Enforces the cluster's CrowdSec decisions here, where public traffic
|
||||
# enters. Deployed in dry-run: it logs what it would block and creates no
|
||||
# nftables rules until crowdsec_bouncer_mode is flipped per host.
|
||||
- crowdsec-edge-bouncer
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
# CrowdSec firewall bouncer on the NetBird proxy hosts.
|
||||
#
|
||||
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
|
||||
# where public traffic actually enters: these hosts terminate TLS for every
|
||||
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
|
||||
# cluster.
|
||||
#
|
||||
# Detection stays in the cluster. The reverse-proxy produces no parseable
|
||||
# access log and there is no CrowdSec collection for it, while Traefik has a
|
||||
# working one — and every scenario in use needs HTTP detail (paths, status
|
||||
# codes, user agents) that does not exist at this layer.
|
||||
|
||||
# Pinned. A release tarball URL is immutable, and this checksum is of the
|
||||
# amd64 asset for exactly this tag.
|
||||
crowdsec_bouncer_version: v0.0.36
|
||||
crowdsec_bouncer_sha256: f86e4b72693549d99f40a9402abefb894108f047a3fbe6e72fada25ee17ce88b
|
||||
|
||||
# The LAPI over the public path these hosts already serve, see
|
||||
# kubernetes/infrastructure/crowdsec/lapi-ingress.yaml. The proxies cannot
|
||||
# reach it over the mesh: the reverse-proxy container runs non-root, so its
|
||||
# embedded NetBird client terminates the tunnel in userspace and the tunnel
|
||||
# belongs to that process rather than to the host netns.
|
||||
#
|
||||
# No ca_cert_path: Traefik serves a Let's Encrypt certificate for this name,
|
||||
# and the bouncer adds ca_cert_path to the system pool rather than replacing
|
||||
# it — the system pool already trusts LE.
|
||||
crowdsec_bouncer_api_url: https://crowdsec.renner.uno/
|
||||
crowdsec_bouncer_api_key: "{{ vault_crowdsec_edge_bouncer_key }}"
|
||||
|
||||
# STARTS INERT, and that is the point.
|
||||
#
|
||||
# dry-run pulls decisions and logs what it would block without creating a
|
||||
# single nftables rule. The first bouncer to arm anywhere enforces ~24k CAPI
|
||||
# decisions at once on the only external entry path, so the false-positive
|
||||
# window has to come before the enforcement, not after it. Flip to `nftables`
|
||||
# one host at a time once the log looks right.
|
||||
crowdsec_bouncer_mode: dry-run
|
||||
|
||||
crowdsec_bouncer_update_frequency: 10s
|
||||
crowdsec_bouncer_log_level: info
|
||||
|
||||
# Bound to loopback deliberately. These hosts are public and have no metrics
|
||||
# collection path at all today — opening :60601 to the internet to gain a
|
||||
# scrape target is the wrong trade. Revisit when host metrics exist.
|
||||
crowdsec_bouncer_metrics_listen: 127.0.0.1
|
||||
crowdsec_bouncer_metrics_port: 60601
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
# A restart drops both nftables tables and rebuilds them from the next
|
||||
# successful LAPI pull, so there is a short window with no blocklist. That is
|
||||
# the documented behaviour of a clean shutdown, not a defect — and it is why
|
||||
# retry_initial_connect matters.
|
||||
- name: Restart crowdsec firewall bouncer
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec-firewall-bouncer.service
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
# See defaults/main.yml for why this exists and why it starts inert.
|
||||
#
|
||||
# The static release tarball, not a distribution package: the AUR package
|
||||
# `crowdsec-firewall-bouncer-nftables` depends on `crowdsec` and would pull the
|
||||
# whole Security Engine onto hosts that only need the bouncer, the group is
|
||||
# mixed (proxy-1 Arch, VPS-1 Debian), and the AUR would be a new pattern this
|
||||
# repo does not otherwise have. The tarball is the same artefact the AUR
|
||||
# package downloads.
|
||||
|
||||
- name: Ensure nftables is present (the enforcing backend)
|
||||
ansible.builtin.package:
|
||||
name: nftables
|
||||
state: present
|
||||
|
||||
- name: Ensure the bouncer config directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/crowdsec/bouncers
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Fetch the pinned bouncer release
|
||||
ansible.builtin.get_url:
|
||||
url: >-
|
||||
https://github.com/crowdsecurity/cs-firewall-bouncer/releases/download/{{
|
||||
crowdsec_bouncer_version }}/crowdsec-firewall-bouncer-linux-amd64.tgz
|
||||
dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
checksum: sha256:{{ crowdsec_bouncer_sha256 }}
|
||||
mode: "0644"
|
||||
|
||||
# Only the binary. The tarball also carries install.sh / upgrade.sh /
|
||||
# uninstall.sh, which manage systemd and config themselves — that is this
|
||||
# role's job, and letting them run would leave two owners for the same files.
|
||||
- name: Unpack the bouncer binary
|
||||
ansible.builtin.unarchive:
|
||||
src: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
|
||||
dest: /opt
|
||||
remote_src: true
|
||||
include:
|
||||
- crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
|
||||
creates: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
|
||||
|
||||
- name: Install the bouncer binary
|
||||
ansible.builtin.copy:
|
||||
src: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
|
||||
dest: /usr/local/bin/crowdsec-firewall-bouncer
|
||||
remote_src: true
|
||||
mode: "0755"
|
||||
notify: Restart crowdsec firewall bouncer
|
||||
|
||||
# 0600: this file carries the LAPI key.
|
||||
- name: Deploy the bouncer configuration
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-firewall-bouncer.yaml.j2
|
||||
dest: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
|
||||
mode: "0600"
|
||||
notify: Restart crowdsec firewall bouncer
|
||||
|
||||
- name: Deploy the bouncer systemd unit
|
||||
ansible.builtin.template:
|
||||
src: crowdsec-firewall-bouncer.service.j2
|
||||
dest: /etc/systemd/system/crowdsec-firewall-bouncer.service
|
||||
mode: "0644"
|
||||
notify: Restart crowdsec firewall bouncer
|
||||
|
||||
- name: Enable and start the bouncer
|
||||
ansible.builtin.systemd:
|
||||
name: crowdsec-firewall-bouncer.service
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Report how to read it
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
crowdsec-firewall-bouncer deployed in mode "{{ crowdsec_bouncer_mode }}".
|
||||
|
||||
What it would block: journalctl -u crowdsec-firewall-bouncer -f
|
||||
What it does block: nft list table ip crowdsec ; nft list table ip6 crowdsec6
|
||||
Metrics: curl -s {{ crowdsec_bouncer_metrics_listen }}:{{ crowdsec_bouncer_metrics_port }}/metrics
|
||||
|
||||
Rollback: systemctl stop crowdsec-firewall-bouncer. A clean stop deletes
|
||||
both nftables tables by itself. Only after a kill -9 do they need
|
||||
removing by hand: nft delete table ip crowdsec ; nft delete table ip6 crowdsec6
|
||||
@@ -0,0 +1,24 @@
|
||||
# Managed by Ansible (crowdsec-edge-bouncer role) — do not edit.
|
||||
#
|
||||
# Upstream's shipped unit orders itself After=crowdsec.service. There is no
|
||||
# local Security Engine on these hosts — the LAPI is in the cluster — so that
|
||||
# dependency is dropped; nss-lookup instead, because api_url is a public name
|
||||
# that has to resolve before the first connect.
|
||||
[Unit]
|
||||
Description=CrowdSec firewall bouncer (edge enforcement for the NetBird proxy)
|
||||
After=network-online.target nss-lookup.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
# -t validates the config and exits non-zero on a bad one, so a broken config
|
||||
# fails the start instead of taking the running rules down with it.
|
||||
ExecStartPre=/usr/local/bin/crowdsec-firewall-bouncer -c /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml -t
|
||||
ExecStart=/usr/local/bin/crowdsec-firewall-bouncer -c /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
LimitNOFILE=65536
|
||||
KillMode=mixed
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,55 @@
|
||||
# Managed by Ansible (crowdsec-edge-bouncer role) — do not edit.
|
||||
mode: {{ crowdsec_bouncer_mode }}
|
||||
update_frequency: {{ crowdsec_bouncer_update_frequency }}
|
||||
|
||||
# stdout, so journald owns the log and there is no second rotation policy to
|
||||
# keep in sync. The shipped example writes to /var/log; that only matters for
|
||||
# hosts without journald.
|
||||
log_mode: stdout
|
||||
log_level: {{ crowdsec_bouncer_log_level }}
|
||||
|
||||
api_url: {{ crowdsec_bouncer_api_url }}
|
||||
api_key: {{ crowdsec_bouncer_api_key }}
|
||||
|
||||
# Without this the process EXITS when the LAPI is unreachable at startup —
|
||||
# there is no retry on the first connect. And because a clean shutdown deletes
|
||||
# the nftables tables, the systemd restart loop would leave the host with no
|
||||
# blocklist at all until the first successful connect. Present in the code,
|
||||
# absent from the shipped example config.
|
||||
retry_initial_connect: true
|
||||
|
||||
disable_ipv4: false
|
||||
disable_ipv6: false
|
||||
deny_action: DROP
|
||||
|
||||
supported_decisions_types:
|
||||
- ban
|
||||
|
||||
# Separate tables per family; the bouncer never touches an existing table or
|
||||
# chain, and never sets a chain policy. Priority -10 puts it ahead of anything
|
||||
# at the default filter priority.
|
||||
nftables:
|
||||
ipv4:
|
||||
enabled: true
|
||||
set-only: false
|
||||
table: crowdsec
|
||||
chain: crowdsec-chain
|
||||
priority: -10
|
||||
ipv6:
|
||||
enabled: true
|
||||
set-only: false
|
||||
table: crowdsec6
|
||||
chain: crowdsec6-chain
|
||||
priority: -10
|
||||
|
||||
# input is the traffic this exists for: the reverse-proxy is host-networked and
|
||||
# owns :443, so its packets traverse INPUT. forward covers the bridge-networked
|
||||
# http-redirect sidecar, whose packets never do.
|
||||
nftables_hooks:
|
||||
- input
|
||||
- forward
|
||||
|
||||
prometheus:
|
||||
enabled: true
|
||||
listen_addr: {{ crowdsec_bouncer_metrics_listen }}
|
||||
listen_port: {{ crowdsec_bouncer_metrics_port }}
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
Converts one MS-01 from "Proxmox host running a Talos VM" to "Talos on bare
|
||||
metal", and swaps its Ceph OSD onto a Samsung PM983 at the same time. Written
|
||||
after doing jupiter on 2026-08-25; saturn and neptune remain.
|
||||
after doing jupiter on 2026-08-25.
|
||||
|
||||
All three nodes are now on bare metal — `talosctl get systeminformation`
|
||||
reports Minisforum MS-01 hardware with distinct chassis serials for k8s-1,
|
||||
k8s-2 and k8s-3, with no hypervisor in between. Kept as the procedure for a
|
||||
rebuild, not as outstanding work.
|
||||
|
||||
Everything here that reads like an over-specific warning is something that
|
||||
actually happened. The conversion itself is straightforward — the failures were
|
||||
|
||||
@@ -111,9 +111,23 @@ statistics max_id=13323904 max_start_ts=1787162400.0
|
||||
statistics_short_term max_id=159205762 max_start_ts=1787166000.0
|
||||
```
|
||||
|
||||
## Phase 2c — cutover (REMAINING)
|
||||
## Phase 2c — cutover: DONE
|
||||
|
||||
Do not start until phase 1 is verified.
|
||||
Verified 2026-09-02 against the live database:
|
||||
|
||||
| table | rows | span |
|
||||
|---|---|---|
|
||||
| `statistics` | 11,092,586 | 2024-03-13 20:00 → current |
|
||||
| `statistics_short_term` | 1,883,709 | rolling ~7 days |
|
||||
| `statistics_meta` | 1,480 | |
|
||||
|
||||
The 2024-03-13 origin is the proof that matters. It is unchanged from the
|
||||
pre-migration count in "What is at risk" above, so the history was copied
|
||||
across engines rather than started from zero — which is the failure this
|
||||
document exists to prevent. `states` carries current rows, so `db_url` points
|
||||
at Postgres.
|
||||
|
||||
The procedure below is kept as the record of what was done.
|
||||
|
||||
**Migrate statistics only.** Copying all 18M `states` rows is slow, needs a long
|
||||
HA outage, and buys a table that is discarded within 7 days anyway.
|
||||
|
||||
@@ -56,10 +56,9 @@ spec:
|
||||
# Re-rendered on every pod start, so a CNPG password rotation takes
|
||||
# effect rather than silently leaving a stale URI behind.
|
||||
#
|
||||
# INERT until the recorder is repointed: nothing reads this key until
|
||||
# `db_url: !secret recorder_db_url` lands in configuration.yaml in the
|
||||
# homeassistant repo. That ordering is required — HA refuses to start
|
||||
# when a referenced secret is missing.
|
||||
# Load-bearing: `db_url: !secret recorder_db_url` in the homeassistant
|
||||
# repo's configuration.yaml reads this key, so HA does not start
|
||||
# without it. Removing this init container takes the recorder down.
|
||||
- name: render-db-url
|
||||
image: busybox:1.36
|
||||
command:
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
# That is the point of the move — it attacks the multiplier rather than trimming
|
||||
# what gets recorded, which would only hide the problem.
|
||||
#
|
||||
# STAGING
|
||||
# Phase 1 (this manifest): the cluster comes up EMPTY and HA keeps using SQLite.
|
||||
# Nothing depends on it, so a failure here is inert. Verify Ready, WAL archiving
|
||||
# to Hetzner, and a successful base backup before going further.
|
||||
# Phase 2: migrate the statistics tables and repoint recorder.db_url.
|
||||
# See MIGRATION-recorder-postgres.md for the sequence and the rollback.
|
||||
# This is the live recorder database. HA writes here; the SQLite file is no
|
||||
# longer the system of record. The 2.4 years of long-term statistics were
|
||||
# copied across engines rather than regenerated — HA does not migrate data
|
||||
# between database engines, so an empty Postgres would have silently started
|
||||
# from zero and taken the energy dashboard's history with it.
|
||||
# MIGRATION-recorder-postgres.md holds the sequence that was followed.
|
||||
apiVersion: postgresql.cnpg.io/v1
|
||||
kind: Cluster
|
||||
metadata:
|
||||
|
||||
@@ -60,9 +60,8 @@ resources:
|
||||
- zigbee2mqtt-gate-service.yaml
|
||||
- zigbee2mqtt-ingress.yaml
|
||||
# Postgres backend for the recorder (MIGRATION-recorder-postgres.md).
|
||||
# PHASE 1: this cluster comes up EMPTY and nothing consumes it — HA is still
|
||||
# on SQLite. Deploying it is therefore inert; phase 2 migrates the statistics
|
||||
# tables and repoints recorder.db_url in the homeassistant repo.
|
||||
# Live: recorder.db_url in the homeassistant repo points here, and the
|
||||
# long-term statistics carry their full history from 2024.
|
||||
- ha-pg-hetzner-creds-externalsecret.yaml
|
||||
- ha-pg-objectstore.yaml
|
||||
- ha-pg-cluster.yaml
|
||||
|
||||
@@ -10,15 +10,15 @@ metadata:
|
||||
# Kept permanently as a standing guard.
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
# Single instance during pgvecto.rs → VectorChord migration window;
|
||||
# pg_basebackup tar format chokes on the long pg_vectors/indexes/<uuid>
|
||||
# paths, blocking replica join. Bump back to 2 once Immich's in-app
|
||||
# vchord migration drops the vectors extension.
|
||||
instances: 1
|
||||
# See ak-pg cluster.yaml for rationale. Single-instance, so no failover, but
|
||||
# the barman-plugin shutdown wedge (cloudnative-pg#9770) takes the whole DB
|
||||
# down until SIGKILL — bounding terminationGracePeriod (stopDelay) matters
|
||||
# even without a standby. smartShutdownTimeout keeps pod restarts quick.
|
||||
# A replica is viable because nothing here writes pg_vectors any more. Both
|
||||
# vector indexes run on vchordrq and the vectors extension is gone, so the
|
||||
# long pg_vectors/indexes/<uuid> paths are absent — pg_basebackup's tar
|
||||
# format cannot represent them, and that is what blocks a replica join.
|
||||
instances: 2
|
||||
# See ak-pg cluster.yaml for rationale. The barman-plugin shutdown wedge
|
||||
# (cloudnative-pg#9770) holds an instance up until SIGKILL, so bounding
|
||||
# terminationGracePeriod (stopDelay) is what keeps a failover from waiting
|
||||
# on it. smartShutdownTimeout keeps pod restarts quick.
|
||||
smartShutdownTimeout: 10
|
||||
stopDelay: 60
|
||||
imageName: ghcr.io/corentingiraud/cnpg-pgvector-vectorchord:16-migration
|
||||
@@ -36,8 +36,10 @@ spec:
|
||||
database: immich
|
||||
owner: immich
|
||||
postgresql:
|
||||
# vchord only. The image also ships pgvecto.rs (vectors.so), but its
|
||||
# extension is not installed and preloading it would recreate pg_vectors
|
||||
# in PGDATA for nothing. pgvector needs no preloading.
|
||||
shared_preload_libraries:
|
||||
- vectors.so
|
||||
- vchord.so
|
||||
parameters:
|
||||
shared_buffers: "512MB"
|
||||
|
||||
@@ -44,12 +44,37 @@ spec:
|
||||
- Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0
|
||||
# Serves POST /v1/embeddings.
|
||||
- --embeddings
|
||||
# A short, stable id for consumers. Without it the model is
|
||||
# advertised as the whole --hf-repo string, which would end up
|
||||
# verbatim in Paperless's config and break on the next model swap.
|
||||
# One name only: llama-server keeps aliases in a sorted set and
|
||||
# reports the first as the id, so a second name can steal it.
|
||||
- --alias
|
||||
- qwen3-embedding-0.6b
|
||||
# Qwen3-Embedding is trained for last-token pooling. Left
|
||||
# unspecified llama-server takes the GGUF's default, which is not
|
||||
# visible from outside and would silently produce vectors the model
|
||||
# was not trained to emit. Set before anything is indexed: changing
|
||||
# pooling later invalidates every stored embedding.
|
||||
- --pooling
|
||||
- last
|
||||
- --n-gpu-layers
|
||||
- "99"
|
||||
- --threads
|
||||
- "2"
|
||||
# Paperless chunks documents at 1024 tokens. The defaults are
|
||||
# batch 2048 / ubatch 512, and with --embeddings llama-server
|
||||
# clamps both down to 512 rather than run them mismatched — below
|
||||
# the chunk size. Matching both to 1024 fits a whole chunk in one
|
||||
# pass; 2048 is deliberately avoided, since that is where this
|
||||
# Vulkan stack fails with ErrorDeviceLost.
|
||||
- --batch-size
|
||||
- "1024"
|
||||
- --ubatch-size
|
||||
- "1024"
|
||||
# Room for a full chunk plus overhead rather than exactly one.
|
||||
- --ctx-size
|
||||
- "2048"
|
||||
- "4096"
|
||||
# No browser ever talks to this Service, so the WebUI is dead weight
|
||||
# and one more unauthenticated surface.
|
||||
- --no-webui
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# paperless-data: CephFS → RBD
|
||||
|
||||
## Why
|
||||
|
||||
`paperless-data` is `ReadWriteMany` on `ceph-filesystem`, but **only the paperless
|
||||
Deployment mounts it**. Verified 2026-08-20:
|
||||
|
||||
```
|
||||
$ pods mounting paperless-data
|
||||
paperless-68cb8b8744-7hjb8 <- one, and only one
|
||||
|
||||
paperless-data ReadWriteMany ceph-filesystem
|
||||
paperless-media ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
|
||||
paperless-consume ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
|
||||
```
|
||||
|
||||
Its contents are single-writer state — the 50 MB Whoosh index,
|
||||
`classification_model.pickle`, `celerybeat-schedule.db`, `log/`,
|
||||
`migration_lock`, `protonmail.pem`. 75 files. Nothing else reads or writes them.
|
||||
|
||||
It is on CephFS by default, not by requirement.
|
||||
|
||||
## What it costs
|
||||
|
||||
Every access goes through the MDS. Measured with **zero CI pipelines running**:
|
||||
|
||||
```
|
||||
paperless-data client request_load_avg 22 (all other sessions 0-1)
|
||||
MDS over 180s 4,052 client requests = 22.5/s
|
||||
2,718 journal events = 15/s
|
||||
2 dir commits <- almost none: not file writes
|
||||
```
|
||||
|
||||
So it is cap/inode churn from repeated access, not data being written. That is
|
||||
most of the constant **7-8 GB/day** written to `cephfs-metadata`, and since that
|
||||
pool is `size: 3`, roughly **21-24 GB/day** reaches the drives.
|
||||
|
||||
`cephfs-metadata` deliberately stays at size 3. Losing it destroys the entire
|
||||
filesystem rather than the objects on one OSD, and the 275 GiB in `cephfs-data0`
|
||||
depends on it. Cutting the writes at source is correct; cutting the replication
|
||||
is not.
|
||||
|
||||
## Sequence
|
||||
|
||||
Staged deliberately: the PVC lands first and empty, so merging it changes
|
||||
nothing. The copy and the repoint are separate.
|
||||
|
||||
### Phase 1 — provision (this PR)
|
||||
|
||||
`paperless-data-block` (RWO, `ceph-block`, 10Gi) is created and mounted by
|
||||
nothing. Inert.
|
||||
|
||||
Verify: `kubectl -n paperless get pvc paperless-data-block` → Bound.
|
||||
|
||||
### Phase 2 — copy
|
||||
|
||||
The Whoosh index must come across intact. Paperless *can* rebuild it
|
||||
(`document_index reindex`), but that reprocesses every document, so copying is
|
||||
both faster and less disruptive.
|
||||
|
||||
1. Scale paperless to 0. The source must be quiescent — `celerybeat-schedule.db`
|
||||
and the Whoosh index are both live-written, and copying them under an active
|
||||
writer gives a torn read.
|
||||
2. Run a one-off pod that mounts BOTH PVCs and copies:
|
||||
```
|
||||
cp -a /src/. /dst/
|
||||
```
|
||||
The RWO target pins the pod to one node; the RWX source will follow.
|
||||
3. Verify file count and index size match:
|
||||
```
|
||||
find /src -type f | wc -l == find /dst -type f | wc -l
|
||||
du -sh /src/index == du -sh /dst/index
|
||||
```
|
||||
|
||||
### Phase 3 — repoint
|
||||
|
||||
Change the Deployment's `data` volume to `claimName: paperless-data-block`,
|
||||
merge, let it roll.
|
||||
|
||||
Verify afterwards:
|
||||
- paperless starts, search returns results (proves the Whoosh index survived)
|
||||
- `ceph tell mds.cephfs-b session ls` no longer shows a busy `paperless-data`
|
||||
client
|
||||
- `rate(ceph_pool_wr_bytes{pool_id="3"})` drops from its 7-8 GB/day baseline
|
||||
|
||||
**Do not delete `paperless-data` until search has been confirmed working.** It is
|
||||
the rollback, and it is `prune: disabled` so Flux will not remove it.
|
||||
|
||||
## Expected effect
|
||||
|
||||
~7-8 GB/day off `cephfs-metadata`, so ~21-24 GB/day off the drives once
|
||||
replication is counted. Against ~432 GB/day cluster-wide that is roughly 5% —
|
||||
worth doing, but it is a correctness fix (a single-writer volume does not belong
|
||||
on a shared filesystem) more than a large win.
|
||||
|
||||
It also removes 22 req/sec of MDS load, which benefits the workloads that
|
||||
genuinely need CephFS — Woodpecker CI workspaces share the same MDS.
|
||||
@@ -57,6 +57,20 @@ spec:
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# The cluster's own LLM, for AI suggestions and RAG embeddings. Scoped to
|
||||
# the two pods and the one port rather than the ai namespace as a whole, so
|
||||
# this grant cannot widen if something else is ever placed there.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: llama-server
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": ai
|
||||
app.kubernetes.io/name: llama-embed
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
# External HTTPS only — Tika model downloads, runtime image
|
||||
# fetches, etc. RFC1918 + ULA are excluded from `world` via the
|
||||
# CIDR-deny pattern. `world` covers public IPs / DNS-resolvable
|
||||
|
||||
@@ -31,12 +31,14 @@ data:
|
||||
PAPERLESS_CONSUMER_DELETE_DUPLICATES: "true"
|
||||
PAPERLESS_CONSUMER_ENABLE_BARCODES: "true"
|
||||
PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE: "true"
|
||||
PAPERLESS_CONSUMER_BARCODE_SCANNER: "ZXING"
|
||||
# No CONSUMER_BARCODE_SCANNER: zxing-cpp is the only backend, so the
|
||||
# setting no longer exists. pyzbar and libzbar are gone from the image.
|
||||
#
|
||||
# CephFS does not propagate inotify across nodes — files written
|
||||
# via the RWX consume PVC from a pod on a different node than
|
||||
# paperless never fire IN_CREATE in paperless's kernel. Polling
|
||||
# is the reliable backstop. See wiki: cephfs-inotify-no-cross-node-propagation.
|
||||
PAPERLESS_CONSUMER_POLLING: "10"
|
||||
PAPERLESS_CONSUMER_POLLING_INTERVAL: "10"
|
||||
PAPERLESS_REDIS: "redis://redis:6379"
|
||||
PAPERLESS_DBENGINE: "postgresql"
|
||||
PAPERLESS_DBHOST: "pprl-pg-rw"
|
||||
@@ -44,6 +46,44 @@ data:
|
||||
PAPERLESS_TIKA_ENABLED: "1"
|
||||
PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
|
||||
# AI suggestions against the cluster's own LLM (kubernetes/apps/llama-server).
|
||||
# Nothing leaves the cluster: both endpoints are ClusterIP Services, so
|
||||
# document text is never sent to a third party and there is no API key to
|
||||
# rotate — llama-server has no authentication, and the ai namespace's network
|
||||
# policy is what restricts who may reach it.
|
||||
#
|
||||
# CAVEAT, and it is a real one: paperless reads these from an
|
||||
# ApplicationConfiguration row in the database and falls back to the
|
||||
# environment only when that row is empty. A value set in the admin UI
|
||||
# therefore overrides this ConfigMap silently. Configure AI in ONE place —
|
||||
# here — and leave those fields untouched in the UI.
|
||||
PAPERLESS_AI_ENABLED: "true"
|
||||
PAPERLESS_AI_LLM_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_ENDPOINT: "http://llama-server.ai.svc.cluster.local:8080/v1"
|
||||
# Must match --alias in kubernetes/apps/llama-server/deployment.yaml. The
|
||||
# server ignores the field on the wire, but paperless sends it and the value
|
||||
# is what appears in its logs.
|
||||
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
|
||||
# ClusterIP addresses are private, and paperless blocks non-public endpoints
|
||||
# unless this is set. Default is already true; explicit because the whole
|
||||
# integration silently stops working if it ever flips.
|
||||
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
|
||||
# Under llama-server's own 16384, leaving room for the response.
|
||||
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
|
||||
# The 120s default is too tight at ~11 tok/s: a classification is roughly
|
||||
# 2-4k prompt tokens plus a second round trip for localisation.
|
||||
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "300"
|
||||
# Matched against Django's settings.LANGUAGES, so the region is required —
|
||||
# "de" alone does not resolve. Without it the model returns German tags but
|
||||
# English document types, which was reproducible against this endpoint.
|
||||
PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
|
||||
# RAG embeddings from llama-embed. Its Qwen3-Embedding covers 100+ languages,
|
||||
# where paperless's default all-MiniLM-L6-v2 is documented as suited to
|
||||
# primarily English archives — the better fit for a German corpus.
|
||||
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "qwen3-embedding-0.6b"
|
||||
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -56,6 +96,13 @@ metadata:
|
||||
# Reloader restarts paperless when paperless-secrets changes (e.g. after a
|
||||
# secret-key rotation). See kubernetes/infrastructure/openbao/rotation/paperless.yaml.
|
||||
secret.reloader.stakater.com/reload: paperless-secrets
|
||||
# And when paperless-config changes. The ConfigMap is consumed with
|
||||
# envFrom, and Kubernetes does not restart a pod when a ConfigMap it reads
|
||||
# that way is updated — the running container keeps the environment it
|
||||
# started with. Without this, a settings change applies cleanly in git and
|
||||
# in the cluster while the application never sees it, which is how the AI
|
||||
# settings landed with no effect at all.
|
||||
configmap.reloader.stakater.com/reload: paperless-config
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
@@ -120,7 +167,7 @@ spec:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
- name: paperless
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:2.20.15 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.2 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
@@ -172,6 +219,25 @@ spec:
|
||||
# Safe against ALLOWED_HOSTS: the kubelet sends the pod IP as Host,
|
||||
# and PAPERLESS_ALLOWED_HOSTS is "*". Narrowing that value later
|
||||
# means giving these probes an explicit Host header.
|
||||
|
||||
# The container does not serve until its init sequence finishes:
|
||||
# s6 makes svc-webserver depend on init-migrations and
|
||||
# init-search-index. On the first 3.x start those are the SHA256
|
||||
# checksum backfill, which opens and reads every original and every
|
||||
# archive file, and a full Tantivy index build, since the Whoosh
|
||||
# index cannot be converted. Both scale with the corpus.
|
||||
#
|
||||
# startupProbe holds liveness and readiness off until the webserver
|
||||
# answers, giving that boot 30 * 20s = 10 minutes. Without it the
|
||||
# 120s liveness delay plus three 30s periods kills the pod 210s in,
|
||||
# part-way through a migration, and the next boot starts over.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
port: http
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 30
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /accounts/login/
|
||||
@@ -220,22 +286,16 @@ spec:
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
# paperless-data-block (RWO, ceph-block), NOT paperless-data
|
||||
# (RWX, ceph-filesystem). Only this Deployment ever mounted the volume,
|
||||
# and its contents are single-writer state — the Whoosh index,
|
||||
# classification model, celerybeat schedule, logs. Being on CephFS meant
|
||||
# every access went through the MDS: 22 requests/sec sustained with zero
|
||||
# CI running, ~15 journal events/sec against 2 directory commits per
|
||||
# 180s, i.e. cap/inode churn rather than file writes. That was most of
|
||||
# the constant 7-8 GB/day on cephfs-metadata, and that pool is size 3.
|
||||
# `data` is RWO on ceph-block, not RWX on ceph-filesystem. Only this
|
||||
# Deployment mounts it and its contents are single-writer state — the
|
||||
# search index, classification model, celerybeat schedule, logs. On
|
||||
# CephFS every access went through the MDS: 22 requests/sec sustained
|
||||
# with zero CI running, ~15 journal events/sec against 2 directory
|
||||
# commits per 180s, i.e. cap/inode churn rather than file writes. That
|
||||
# was most of the constant 7-8 GB/day on cephfs-metadata, a size 3 pool.
|
||||
#
|
||||
# `consume` and `media` below STAY on CephFS: the ftp-scanner writes to
|
||||
# those from another node, so they genuinely need RWX.
|
||||
#
|
||||
# paperless-data is retained (prune: disabled) as the rollback. Do not
|
||||
# delete it until search has been confirmed working — that is what
|
||||
# proves the Whoosh index survived the copy, not merely that paperless
|
||||
# starts. Migration record: MIGRATION-data-to-block.md.
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: paperless-data-block
|
||||
|
||||
@@ -3,33 +3,18 @@
|
||||
# (e.g. when the seed initContainer or the Garage migration creds are
|
||||
# removed once we're done with the migration phase). Velero's
|
||||
# bootstrap-restore then owns their contents.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: paperless-data
|
||||
namespace: paperless
|
||||
annotations:
|
||||
kustomize.toolkit.fluxcd.io/prune: disabled
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: ceph-filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
# Replacement for paperless-data, on RBD instead of CephFS.
|
||||
|
||||
# paperless's single-writer state, on RBD rather than CephFS.
|
||||
#
|
||||
# WHY: paperless-data is declared ReadWriteMany on ceph-filesystem, but only ONE
|
||||
# pod ever mounts it — the paperless Deployment itself. Its contents are all
|
||||
# single-writer state: the 50 MB Whoosh index, classification_model.pickle,
|
||||
# celerybeat-schedule.db, logs, migration_lock. Nothing shares it. Only `consume`
|
||||
# and `media` genuinely need RWX, because the FTP scanner writes to those from a
|
||||
# different node.
|
||||
# WHY RWO: only ONE pod mounts it — the paperless Deployment itself. Its
|
||||
# contents are all single-writer state: the Tantivy search index,
|
||||
# classification_model.pickle, celerybeat-schedule.db, logs, migration_lock.
|
||||
# Nothing shares it. Only `consume` and `media` genuinely need RWX, because the
|
||||
# FTP scanner writes to those from a different node.
|
||||
#
|
||||
# COST OF LEAVING IT: every access goes through the MDS. Measured 2026-08-20 with
|
||||
# zero CI running, this volume's client was doing 22 MDS requests/sec sustained,
|
||||
# and the MDS logged ~15 journal events/sec against only 2 directory commits per
|
||||
# WHY NOT CEPHFS: every access goes through the MDS. With zero CI running, this
|
||||
# volume's client sustained 22 MDS requests/sec, and the MDS logged ~15 journal
|
||||
# events/sec against only 2 directory commits per
|
||||
# 180s — i.e. cap and inode churn from repeated access, not file writes. That is
|
||||
# most of the constant 7-8 GB/day written to cephfs-metadata, which is a size 3
|
||||
# pool, so ~21-24 GB/day actually hits the drives.
|
||||
@@ -38,10 +23,6 @@ spec:
|
||||
# the metadata pool destroys the whole filesystem, not just the objects on one
|
||||
# OSD, so the 275 GiB in cephfs-data0 rides on it. Reducing the writes at source
|
||||
# is the right fix; reducing the replication is not.
|
||||
#
|
||||
# MIGRATION: this PVC is created EMPTY and nothing mounts it yet. The data copy
|
||||
# and the deployment repoint are a separate, staged step — see
|
||||
# MIGRATION-data-to-block.md. Merging this alone is inert.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
|
||||
@@ -18,7 +18,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: whisper
|
||||
image: rhasspy/wyoming-whisper:3.6.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
|
||||
image: rhasspy/wyoming-whisper:3.7.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
|
||||
args:
|
||||
- --model=base-int8
|
||||
- --language=de
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Bouncer key for the firewall bouncers on proxy-1/proxy-2.
|
||||
#
|
||||
# Separate from crowdsec-bouncer-key ON PURPOSE. That key is rotated
|
||||
# quarterly by kubernetes/infrastructure/openbao/rotation/crowdsec-bouncer-key.yaml,
|
||||
# and both of its consumers live in the cluster, so ESO propagates a rotation
|
||||
# within 10m. The edge bouncers are Ansible-managed hosts: nothing there
|
||||
# re-reads OpenBao on a schedule, so a rotated key would leave them 401ing
|
||||
# against the LAPI until someone runs the playbook. This key is therefore
|
||||
# deliberately NOT enrolled in the rotator — it rotates when the playbook runs,
|
||||
# and the playbook is what carries the new value to the hosts.
|
||||
#
|
||||
# The value must match ansible vault_crowdsec_edge_bouncer_key.
|
||||
apiVersion: external-secrets.io/v1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: crowdsec-edge-bouncer-key
|
||||
namespace: crowdsec
|
||||
spec:
|
||||
refreshInterval: 10m
|
||||
secretStoreRef:
|
||||
kind: ClusterSecretStore
|
||||
name: openbao
|
||||
target:
|
||||
name: crowdsec-edge-bouncer-key
|
||||
dataFrom:
|
||||
- extract:
|
||||
key: infrastructure/crowdsec-edge-bouncer-key
|
||||
@@ -36,6 +36,36 @@ spec:
|
||||
values:
|
||||
container_runtime: containerd
|
||||
|
||||
# Addresses a community blocklist must never be allowed to ban.
|
||||
#
|
||||
# This is the ONLY declarative allowlist the chart offers. `cscli
|
||||
# allowlists` objects live in the LAPI database, and that database runs
|
||||
# without persistence here on purpose (see lapi.persistentVolume below),
|
||||
# so anything created through cscli is gone at the next restart. This file
|
||||
# is a ConfigMap mount, so it survives by construction. Upstream marks it
|
||||
# deprecated in favour of centralized allowlists; it is still the only
|
||||
# thing that works against a LAPI with no PVC.
|
||||
#
|
||||
# Scope: CAPI decisions only. Locally generated decisions are not filtered
|
||||
# here — they currently carry real client IPs (verified), because Traefik
|
||||
# resolves X-Forwarded-For past the netbird-ingress pod IP.
|
||||
#
|
||||
# The home WAN address is deliberately absent and is not needed: LAN
|
||||
# clients never present it. opentofu/unifi_nat_hairpin.tf DNATs the public
|
||||
# proxy addresses to the Traefik VIP on the UDM, so on-LAN traffic reaches
|
||||
# the cluster directly with a LAN source and never traverses the proxies.
|
||||
# Private ranges cannot appear in a community blocklist either way.
|
||||
config:
|
||||
capi_whitelists.yaml: |
|
||||
cidrs:
|
||||
# The proxy hosts themselves. Same three addresses as the
|
||||
# netbird-ingress-egress toCIDRSet and the hairpin endpoints —
|
||||
# keep all three lists in sync. proxy-1 is the v6-only member,
|
||||
# proxy-2 the dual-stack one.
|
||||
- 45.157.177.26/32
|
||||
- 2a03:4000:4b:c1e:4474:43ff:fedf:8f85/128
|
||||
- 2a03:4000:28:f6e:a4c7:59ff:fe1e:9a29/128
|
||||
|
||||
# mTLS between agents and the LAPI. Do not switch back to password auth:
|
||||
# the LAPI runs without persistence, so its machine-registration DB is
|
||||
# wiped on every restart, and password-registered agents then get 401
|
||||
@@ -86,10 +116,18 @@ spec:
|
||||
requests:
|
||||
cpu: 50m
|
||||
podAnnotations:
|
||||
# Reloader restarts the LAPI when crowdsec-bouncer-key changes (it's an
|
||||
# env-var secret, so a restart is required to pick up a rotated key). See
|
||||
# Reloader restarts the LAPI when either bouncer key changes. Both are
|
||||
# consumed as env vars from a secretKeyRef, and env is fixed at pod
|
||||
# start — without a restart the LAPI keeps trusting the old value while
|
||||
# the bouncer already presents the new one, which is a 401 at exactly
|
||||
# the moment a rotation is supposed to be seamless.
|
||||
#
|
||||
# crowdsec-bouncer-key rotates on a schedule, see
|
||||
# kubernetes/infrastructure/openbao/rotation/crowdsec-bouncer-key.yaml.
|
||||
secret.reloader.stakater.com/reload: crowdsec-bouncer-key
|
||||
# crowdsec-edge-bouncer-key rotates by hand together with
|
||||
# ansible/plays/install-netbird-proxy.yml; it still needs to be listed
|
||||
# here, because the LAPI side of that rotation is this env var.
|
||||
secret.reloader.stakater.com/reload: crowdsec-bouncer-key,crowdsec-edge-bouncer-key
|
||||
persistentVolume:
|
||||
data:
|
||||
# Kept OFF deliberately. The #337 attempt to set this `true` (persist
|
||||
@@ -118,6 +156,17 @@ spec:
|
||||
secretKeyRef:
|
||||
name: crowdsec-bouncer-key
|
||||
key: key
|
||||
# The firewall bouncers on proxy-1/proxy-2, over the public LAPI
|
||||
# endpoint (lapi-ingress.yaml). The chart's entrypoint re-runs
|
||||
# `register_bouncer` for every BOUNCER_KEY_* on each start and skips
|
||||
# names that already exist, so this survives the LAPI's wiped
|
||||
# registration DB the same way BOUNCER_KEY_ingress does — the env var
|
||||
# is the source of truth, not the database.
|
||||
- name: BOUNCER_KEY_edge
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: crowdsec-edge-bouncer-key
|
||||
key: key
|
||||
|
||||
agent:
|
||||
# Same reasoning as lapi.resources above, multiplied by three: this is a
|
||||
|
||||
@@ -2,4 +2,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- bouncer-key-externalsecret.yaml
|
||||
- edge-bouncer-key-externalsecret.yaml
|
||||
- lapi-ingress.yaml
|
||||
- helmrelease.yaml
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Public LAPI endpoint for the firewall bouncers on proxy-1/proxy-2.
|
||||
#
|
||||
# WHY PUBLIC AT ALL. The bouncers run as host processes on the proxies, and
|
||||
# the proxies have no mesh access: the netbird-proxy container runs non-root
|
||||
# (see the ip_unprivileged_port_start task in the netbird-proxy role), so it
|
||||
# has no NET_ADMIN and no /dev/net/tun, and its embedded NetBird client
|
||||
# therefore terminates the tunnel in userspace. The tunnel belongs to that
|
||||
# process, not to the host netns — a second process on the same host cannot
|
||||
# route through it. The alternative was a host-level NetBird client plus the
|
||||
# LAPI on a LoadBalancer VIP; this reuses the path every other service already
|
||||
# takes instead of adding two peers and an ACL policy.
|
||||
#
|
||||
# NO IP ALLOWLIST, deliberately. The bouncer key is the access control.
|
||||
#
|
||||
# An allowlist was the obvious second factor and it does not work here. A
|
||||
# request originating on a proxy host does not arrive carrying that host's
|
||||
# public address: the client connects to the local proxy on 127.0.0.1, the
|
||||
# reverse-proxy puts what it saw into X-Forwarded-For, and Traefik resolves
|
||||
# that. Measured against the netbird-proxy-watchdog, which makes exactly this
|
||||
# kind of request every round:
|
||||
#
|
||||
# 172.18.0.3 - - [02/Sep/2026:10:12:01] "GET /" 200 websecure-immich-...
|
||||
# 172.18.0.3 - - [02/Sep/2026:10:12:01] "GET /" 200 websecure-home-...
|
||||
#
|
||||
# 172.18.0.3 is a Docker bridge address on the proxy side. Allowlisting the
|
||||
# published proxy addresses would have rejected the bouncer with 403;
|
||||
# allowlisting 172.18.0.0/16 would be a private range on a host we do not
|
||||
# control the addressing of, which is not a security control.
|
||||
#
|
||||
# What remains is the API key, which is the real control anyway: this endpoint
|
||||
# serves read-only decision streams to a valid bouncer key and nothing else.
|
||||
# The admin surface is cscli, in-cluster. If the source address ever becomes
|
||||
# deterministic, an ipAllowList can be added on top.
|
||||
---
|
||||
# The LAPI serves HTTPS only once tls.enabled is set — there is no plaintext
|
||||
# fallback and no second port; 8080 is the TLS port. Its certificate comes
|
||||
# from the chart's own self-signed CA, so Traefik needs that CA to verify the
|
||||
# backend. SANs include crowdsec-service.crowdsec.svc.cluster.local, which is
|
||||
# what the Service resolves to.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: ServersTransport
|
||||
metadata:
|
||||
name: crowdsec-lapi
|
||||
namespace: crowdsec
|
||||
spec:
|
||||
serverName: crowdsec-service.crowdsec.svc.cluster.local
|
||||
rootCAsSecrets:
|
||||
- crowdsec-root-secret
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: crowdsec-lapi
|
||||
namespace: crowdsec
|
||||
labels:
|
||||
# Public DNS via Cloudflare; the proxies resolve it from outside. The UDM
|
||||
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
|
||||
dns.renner.uno/public: "true"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-cluster
|
||||
# NOT behind the Authentik gate: the client is a daemon with an API key,
|
||||
# not a browser that can complete an OIDC flow.
|
||||
traefik.ingress.kubernetes.io/service.serversscheme: https
|
||||
traefik.ingress.kubernetes.io/service.serverstransport: crowdsec-crowdsec-lapi@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: crowdsec.renner.uno
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: crowdsec-service
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- secretName: crowdsec-lapi-public-tls
|
||||
hosts:
|
||||
- crowdsec.renner.uno
|
||||
@@ -7,7 +7,7 @@ spec:
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx
|
||||
interval: 1h
|
||||
---
|
||||
# Allow only patch + minor bumps within the 2.x line. A 3.0 release
|
||||
# Allow only patch + minor bumps within the 3.x line. A 4.0 release
|
||||
# requires manual policy widening — major upgrades typically need a
|
||||
# migration plan and shouldn't land via auto-merge.
|
||||
apiVersion: image.toolkit.fluxcd.io/v1
|
||||
@@ -20,4 +20,4 @@ spec:
|
||||
name: paperless-ngx
|
||||
policy:
|
||||
semver:
|
||||
range: "2.x"
|
||||
range: "3.x"
|
||||
|
||||
@@ -24,6 +24,16 @@ spec:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
# Paperless calls both models for AI suggestions and RAG embeddings. The
|
||||
# matching egress grant on its side is scoped to these two pods on :8080.
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": paperless
|
||||
app.kubernetes.io/name: paperless
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
"k8s:io.kubernetes.pod.namespace": monitoring
|
||||
|
||||
Reference in New Issue
Block a user