Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 f86f19045e fix(crowdsec): move the LAPI backend annotations onto the Service
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Both bouncers reach the LAPI and pull every 10s, and every pull gets a 400:

  2a03:4000:28:f6e:...:9a29 "GET /v1/decisions/stream?..." 400 48
  2a03:4000:4b:c1e:...:8f85 "GET /v1/decisions/stream?..." 400 48

The body says what it is: "Client sent an HTTP request to an HTTPS server."
Traefik was talking plaintext to a backend that serves TLS only.

serversscheme and serverstransport were on the Ingress. Under the Kubernetes
Ingress provider `traefik.ingress.kubernetes.io/service.*` are SERVICE
annotations; on an Ingress they are silently ignored. Nothing warns, and the
failure surfaces as a 400 that reads like a malformed request rather than a
scheme mismatch. They move to the chart's Service via lapi.service.annotations.

With tls.enabled the LAPI serves HTTPS on 8080 and offers no plaintext port,
so https is mandatory here rather than hardening.

Also switches the ServersTransport from rootCAsSecrets to rootCAs, which is
what Traefik has been warning about on every reconcile:

  RootCAsSecrets option is deprecated, please use the RootCA option instead.

Two of my own diagnostics were wrong along the way and both were measurement
artifacts. "Traefik sees no requests" came from a Loki query whose limit was
consumed by those deprecation warnings. And the source address that justified
dropping the IPAllowList was measured on the watchdog, which curls 127.0.0.1;
the bouncer resolves the public name and arrives on the proxy's public
address, as the log lines above show.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:53:53 +02:00
k.renner 116d124e2d Merge pull request 'fix(crowdsec): unpack the bouncer on the host, not on the controller' (#758) from crowdsec-bouncer-unpack-on-host into master
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#758
2026-09-02 11:52:46 +00:00
claude-botandClaude Opus 5 cf1237bf33 fix(crowdsec): unpack the bouncer on the host, not on the controller
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The download moved to the controller in the previous fix and now works. The
extraction that followed it does not:

  Failed to find handler for "/tmp/crowdsec-firewall-bouncer-v0.0.36.tgz".
  Command "/bin/tar" detected as tar type None. GNU tar required.

The ci-ansible image ships a tar that ansible's unarchive rejects. Both host
distributions have GNU tar, and only the download ever needed to move — the
v6-only problem was reaching github.com, not opening a tarball.

So the controller now ships the archive rather than the unpacked binary, and
the host extracts it. That also keeps the artefact the checksum was verified
against intact all the way to its destination, instead of verifying an archive
and then copying something extracted from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:46:23 +02:00
k.renner 4742b26047 Merge pull request 'feat(llama-embed): switch to bge-m3, activating the Paperless AI config' (#757) from embed-bgem3 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#757
2026-09-02 11:41:19 +00:00
claude-botandClaude Opus 5 3bde2a4316 feat(llama-embed): switch to bge-m3, activating the Paperless AI config
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
First candidate to be carried through to an end-to-end measurement. The
embedding comparison on 400 real archive documents puts bge-m3 level with the
deployed Qwen3-Embedding-0.6B where it matters and ahead everywhere else:

  correspondent  74.0% vs 75.0%   (ceiling 82.5%, so 90% vs 91% of achievable)
  document type  70.0% vs 68.2%
  time           147s vs 380s for 400 documents
  peak memory    1.6 GiB vs 10.6 GiB

Same 1024 dimensions, so nothing downstream changes shape.

Note the pooling type differs — bge-m3 wants CLS where the Qwen family wants
last-token. Read from the model's own 1_Pooling/config.json rather than assumed;
across the candidates measured, three different pooling types were required, and
a wrong one produces plausible but untrained vectors with no error anywhere.

The memory limit is now measured rather than estimated. It also supersedes #745,
which raised the limit for the model being replaced: the footprint is dominated
by four slots at 4096 context plus the Vulkan working set, not by the weights —
which is why several candidates were OOMKilled at limits that looked generous
against their model size.

This is not a final answer. Every candidate has to go through the end-to-end
test (scripts/paperless-eval.sh) before the embedding choice means anything,
because nothing has yet verified that the retrieval metric predicts what
Paperless actually suggests. bge-m3 is simply the one to start with.

Changing paperless-config is also what lets that test run at all: the AI
settings from #739 have never reached the running pod, which predates the
Reloader annotation from #741. This edit gives Reloader something real to react
to, rather than forcing a restart by hand or inventing a change to trigger one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:39:36 +02:00
k.renner f38033e30a Merge pull request 'fix(crowdsec): fetch the bouncer on the controller, not on the host' (#755) from crowdsec-bouncer-fetch-controller into master
ci/woodpecker/push/deploy-netbird-proxy Pipeline failed
Reviewed-on: k.renner/homelab#755
2026-09-02 11:33:52 +00:00
claude-botandClaude Opus 5 a5d8bf35a9 fix(crowdsec): fetch the bouncer on the controller, not on the host
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The deploy failed on proxy-1:

  Request failed: <urlopen error [Errno 101] Network is unreachable>
  url: https://github.com/.../crowdsec-firewall-bouncer-linux-amd64.tgz

proxy-1 is IPv6-only, and neither github.com nor
objects.githubusercontent.com publishes an AAAA record. The host cannot reach
GitHub at all, so no retry or timeout would have helped.

Two alternatives do not work either. Mirroring into gitea.renner.uno is out:
its public records are the internal LB VIP (10.2.99.86 / fd99::86), so it is
unreachable from outside the LAN. And there is no upstream container image for
this bouncer, which is expected of something that manipulates host netfilter —
crowdsecurity publishes cloudflare, fastly, custom and others, but no firewall
bouncer.

So the controller fetches and pushes instead of the host pulling. The CI runner
has v4. The checksum is still verified, now before anything reaches a host,
which is the better place for it anyway.

The repo had told me twice that proxy-1 is the v6-only member — in
unifi_nat_hairpin.tf and in the netbird-proxy role's dhcpcd comment — and I
wrote it into the PR description myself. I never asked whether a v6-only host
can reach GitHub.

Nothing was deployed before this: the play runs serial: 1, proxy-1 went first
and failed, so proxy-2 never started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:25:39 +02:00
k.renner 60cfcf9e7e Merge pull request 'feat(crowdsec): LAPI endpoint and key for the edge firewall bouncers' (#751) from crowdsec-edge-lapi into master
ci/woodpecker/push/deploy-netbird-proxy Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#751
2026-09-02 11:15:56 +00:00
k.renner f0dcda4917 Merge pull request 'chore(llama-server): grow the model cache to 120Gi for candidate evaluation' (#748) from embed-pvc into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#748
2026-09-02 09:10:58 +00:00
claude-botandClaude Opus 5 e99e502da7 chore(llama-server): grow the model cache to 120Gi for candidate evaluation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The embedding model was picked from a recommendation and never compared. MTEB's
German retrieval results put the deployed Qwen3-Embedding-0.6B well down the
field, so a dozen candidates are being measured against the real archive with
scripts/embed-eval-real.sh — and they will not fit in 40Gi alongside the two
deployed models.

Growing the shared cache rather than using a scratch volume is deliberate: on a
slow connection the download is the long pole, and a shared cache lets the large
candidates pull in the background while the small ones are already being
measured. It also means nothing is fetched twice across runs, and a candidate
that wins is already local when it is promoted.

120Gi against 309 GiB currently free in cephfs-data0. CephFS has
allowVolumeExpansion, so this grows online without disturbing the running pods.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 11:10:04 +02:00
6 changed files with 94 additions and 22 deletions
@@ -19,14 +19,41 @@
state: directory
mode: "0755"
- name: Fetch the pinned bouncer release
# Fetched on the CONTROLLER and pushed, not pulled by the host.
#
# proxy-1 is IPv6-only, and neither github.com nor objects.githubusercontent.com
# publishes an AAAA record — a get_url on the host fails with "Network is
# unreachable" and no amount of retrying fixes it. Mirroring into
# gitea.renner.uno is no help either: its public records are the internal LB
# VIP (10.2.99.86 / fd99::86), so it is unreachable from outside the LAN. And
# upstream ships no container image for this bouncer, which is expected —
# it manipulates host netfilter.
#
# The CI runner has v4, so it does the fetching. The checksum is verified
# there, before anything is copied to a host.
- name: Fetch the pinned bouncer release on the controller
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
dest: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
checksum: sha256:{{ crowdsec_bouncer_sha256 }}
mode: "0644"
delegate_to: localhost
become: false
run_once: true
# Only the download had to move to the controller — the extraction stays on
# the host. The ci-ansible image ships a tar that ansible rejects ("detected as
# tar type None. GNU tar required"), while both host distributions have GNU
# tar. Shipping the archive rather than the unpacked binary also keeps the
# artefact that the checksum was verified against intact all the way to the
# host.
- name: Copy the bouncer archive to the host
ansible.builtin.copy:
src: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
mode: "0644"
# Only the binary. The tarball also carries install.sh / upgrade.sh /
# uninstall.sh, which manage systemd and config themselves — that is this
+19 -9
View File
@@ -41,7 +41,7 @@ spec:
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
args:
- --hf-repo
- Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0
- gpustack/bge-m3-GGUF:Q8_0
# Serves POST /v1/embeddings.
- --embeddings
# A short, stable id for consumers. Without it the model is
@@ -50,14 +50,16 @@ spec:
# 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.
- bge-m3
# bge-m3 is trained for CLS pooling — a different type from the
# Qwen family this replaces, which uses last-token. Left unspecified
# llama-server takes the GGUF default, which is not visible from
# outside and would silently produce vectors the model was not
# trained to emit. Read from the model's own 1_Pooling/config.json
# rather than assumed; of the candidates measured, three different
# pooling types were needed.
- --pooling
- last
- cls
- --n-gpu-layers
- "99"
- --threads
@@ -91,6 +93,14 @@ spec:
capabilities:
drop:
- ALL
# Measured, not estimated: embedding 400 real documents peaked at
# 1.6 GiB (Prometheus max_over_time on the working set). The limit is
# well above that because the footprint is dominated by the four slots
# at 4096 context and the Vulkan working set rather than by the
# weights — the Qwen model this replaces peaked at 10.6 GiB on the
# same settings for the same 600 MiB of weights, and several
# candidates were OOMKilled at limits that looked generous against
# their model size.
resources:
requests:
cpu: 250m
@@ -98,7 +108,7 @@ spec:
gpu.intel.com/i915: "1"
limits:
cpu: "2"
memory: 4Gi
memory: 8Gi
gpu.intel.com/i915: "1"
# ~600 MiB rather than ~5 GiB, so a much shorter budget than the chat
# model needs, but still sized for a cold download.
+16 -6
View File
@@ -1,10 +1,20 @@
---
# Holds the GGUF weights, downloaded once by the Deployment's initContainer.
# Prune-disabled so retiring this Kustomization does not throw away several
# GiB of model files that would have to be pulled from Hugging Face again.
# Holds the GGUF weights, downloaded once by llama-server itself via --hf-repo.
# Prune-disabled so retiring this Kustomization does not throw away tens of GiB
# of model files that would have to be pulled from Hugging Face again.
#
# ReadWriteMany so a second replica or a one-off benchmark Job can mount the
# same weights; the models are read-only at inference time.
# ReadWriteMany so a second replica, a one-off benchmark Job, or a candidate
# model being evaluated can mount the same weights; the models are read-only at
# inference time.
#
# Sized well beyond the two deployed models. The cache is shared by every
# candidate under evaluation (scripts/embed-eval-real.sh), and on a slow
# connection the download is the long pole — a shared cache lets the large
# candidates pull in the background while the small ones are already being
# measured, and means a model is never fetched twice across runs.
#
# CephFS supports online expansion (allowVolumeExpansion on the storage class),
# so growing this does not disturb the running pods.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -18,4 +28,4 @@ spec:
storageClassName: ceph-filesystem
resources:
requests:
storage: 40Gi
storage: 120Gi
+4 -1
View File
@@ -82,7 +82,10 @@ data:
# 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"
# Must match --alias in kubernetes/apps/llama-server/embed.yaml. Changing the
# embedding model invalidates every stored vector, so a swap means the LLM
# index has to be rebuilt.
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "bge-m3"
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
---
apiVersion: apps/v1
@@ -108,6 +108,24 @@ spec:
lapi:
replicas: 1
service:
# Backend wiring for the public LAPI Ingress (lapi-ingress.yaml).
#
# These belong on the SERVICE, not on the Ingress: under the Kubernetes
# Ingress provider `traefik.ingress.kubernetes.io/service.*` are service
# annotations, and on an Ingress they are silently ignored. The symptom
# is Traefik speaking plaintext to a TLS-only backend, which the LAPI
# answers with "Client sent an HTTP request to an HTTPS server." and a
# 400 — an error that looks like a malformed request rather than a
# scheme mismatch.
#
# With tls.enabled the LAPI serves HTTPS on 8080 and offers no
# plaintext port, so https here is mandatory rather than a hardening
# choice. The ServersTransport carries the chart's own CA, since that
# certificate is self-signed.
annotations:
traefik.ingress.kubernetes.io/service.serversscheme: https
traefik.ingress.kubernetes.io/service.serverstransport: crowdsec-crowdsec-lapi@kubernetescrd
# Far below the chart default, which reserves half a core per LAPI for a
# workload that draws single-digit millicores — enough to leave the
# cluster unschedulable with one node out. Only the reservation shrinks;
@@ -44,8 +44,8 @@ metadata:
namespace: crowdsec
spec:
serverName: crowdsec-service.crowdsec.svc.cluster.local
rootCAsSecrets:
- crowdsec-root-secret
rootCAs:
- secret: crowdsec-root-secret
---
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -60,8 +60,12 @@ metadata:
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
#
# The backend scheme and ServersTransport are NOT set here. Under the
# Kubernetes Ingress provider, `traefik.ingress.kubernetes.io/service.*`
# are SERVICE annotations — on an Ingress they are silently ignored, and
# Traefik then talks plaintext to a TLS-only backend. They live on the
# chart's Service instead, via lapi.service.annotations in helmrelease.yaml.
spec:
ingressClassName: traefik
rules: