Same pattern as portfolio: Namespace, Deployment (pulls
gitea.nik4nao.com/nik/me.nik4nao.com:latest), Service, Let's Encrypt
Certificate, and Traefik IngressRoute for me.nik4nao.com, reconciled
by a new Argo CD Application. Includes the per-namespace registry
pull-secret script (run manually against the live cluster, not
committed as plaintext).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KS514CDC88M4wDwMLnS7u
Filebar sends its auth only on the legacy X-Emby-Authorization header,
with unquoted values (Client=Filebar instead of Client="Filebar"), and
never sends the Authorization header Jellyfin 12.0 requires to resolve
request.App -- causing every login to 400. Switches the debug proxy to
OpenResty and adds a Lua shim that rewrites just Filebar's requests into
the header format Jellyfin expects (matching SenPlayer's working
requests); everything else passes through unchanged.
This is a workaround for a Filebar bug, not a permanent fix -- see the
TODO in jellyfin-header-debug.yaml to remove it once Filebar ships a
corrected release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EQrGkfXFae4PZG9QfPRf4
Adds a transparent nginx passthrough that logs auth-related request
headers (Authorization, X-Emby-Authorization, X-Emby-Token,
X-MediaBrowser-Token, User-Agent) to diagnose why the Filebar iOS app
gets 400s on /Users/AuthenticateByName against Jellyfin 12.0. All
traffic still reaches the real jellyfin Service unchanged. Revert the
Ingress backend and remove jellyfin-header-debug.yaml once captured.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EQrGkfXFae4PZG9QfPRf4
10.11.11 can't read the schema Jellyfin 12.0 already migrated the config
DB to (SQLite errors on every user query), which broke login entirely.
Pin to 12.0 instead of :latest to restore service and stop future
silent version jumps.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016EQrGkfXFae4PZG9QfPRf4
Replace the NodePort (30889) with type: LoadBalancer on the standard
SOCKS5 port 1080, at the user's request for a more natural port than a
30000+ NodePort value. loadBalancerIP: 10.10.40.20 (nik-debian) matches
values/gitea.yaml's gitea-ssh Service pattern exactly.
Documented the actual behavior after checking, not assuming: k3s's
ServiceLB (Klipper) binds the port on every node via a DaemonSet by
default - loadBalancerIP only sets the reported external IP, it
doesn't scope which nodes bind the port. That needs a separate
enablelb/lbpool node-label mechanism this repo doesn't use anywhere
yet (including on the existing gitea-ssh Service, which likely has the
same characteristic, unnoticed until now). So this change doesn't
narrow exposure to nik-debian only - it's still reachable at any node
IP on port 1080, the same multi-node reachability the NodePort version
already had, just a different port number.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
manifests/media/gluetun-proxy.yaml (the standalone Gluetun/OpenVPN
HTTP proxy, NodePort 30888) is now fully replaced by
manifests/multus/30-browser-vpn-proxy.yaml, live and validated
end-to-end (connectivity, DNS-through-proxy, PIA egress, fail-closed
kill switch). Removing the file from git - the live Deployment/Service
still need a separate kubectl delete, since media has prune: false.
Note gluetun.home.arpa (the dashy VPN-IP-banner hostname, and the
Certificate/IngressRoute in qbittorrent.yaml routing it to that pod's
ip-reporter sidecar) is unrelated and unaffected - it never pointed at
this standalone proxy.
Update the root README's VLAN 50 status table with browser-vpn-proxy
and this removal, including the exact commit to restore the old file
from if ever needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sync-wave 3 (after the canary's wave 2) was a documentation convention,
not a real dependency, and it broke syncing entirely: this cluster's
argocd-cm ignores /status updates for reconciliation cluster-wide
(resource.customizations.ignoreResourceUpdates.all), so Argo's wave
orchestrator can never observe a freshly recreated Pod's Pending ->
Running transition happening during an active sync - exactly what the
canary does every test round, since it's deleted and recreated each
time. The sync hung forever on "waiting for healthy state of
/Pod/vlan50-canary" even once the pod was actually Running live.
Move browser-vpn-proxy to wave 1, alongside the NAD - the only thing
it actually needs to exist first. Document the root cause and the
kubectl patch to clear a stuck operation in the README, since this
risk applies to anything synced after the canary in this Application,
not just this one file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit (89524d4) only captured the file rename - a git
add with one stale/invalid pathspec aborted before staging the actual
content, and the resulting empty diff wasn't caught before committing.
This commit has the real changes described in that one's message:
EXPECTED_VLAN50_IP/NET_RAW on the guard init container, REQUIRE_AUTH
dropped to false with ALLOWED_IPS removed entirely (broken as drafted
- see the file's own header for why), sync-wave 3 annotation, and the
README updates dropping the now-unneeded secret-script references.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Move manifests/multus/reserved/browser-vpn-proxy.yaml into the active
multus tree (sync-wave 3) now that qBittorrent and JDownloader have
both independently passed their canary, validation, and soak gates -
the precondition this file's own header already called for.
Add EXPECTED_VLAN50_IP/NET_RAW to its guard init container, matching
the fix qBittorrent/JDownloader/canary all needed for the arping-based
checks to run.
Deliberately no auth (REQUIRE_AUTH=false) and no ALLOWED_IPS, after
discussion: access restriction relies on the home network's own
firewall (Trusted/Lab -> Lab NodePort ALLOW, other VLANs DENY except
Technitium DNS), not an app-level control. Along the way, found and
avoided shipping a real bug in the original draft - go-socks5-proxy's
ALLOWED_IPS parses values with net.ParseIP (not CIDR-aware) and
matches with exact net.IP.Equal(), so the drafted "10.10.40.0/24"
value would have produced a nil whitelist entry that matches no real
client, rejecting every connection. This library has no way to express
a subnet allowlist at all - removed rather than left silently broken.
Also drops the now-unneeded browser-vpn-proxy-credentials Secret and
its companion script/README references.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
qbittorrent.yaml and jdownloader.yaml's own header comments still
pointed at manifests/media/rollback/*-gluetun.yaml and described the
migration as pending approval/preconditions - both are live and
validated now, and that directory is being removed (no kept on-disk
Gluetun copy). Rewrite both headers as a status note instead, pointing
rollback at git history (with the exact pre-migration commit) to match
the root README's rollback table, which gets the same exact-commit
treatment here instead of the vaguer reference from the previous
commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both the root README and manifests/README were still written as if the
whole VLAN 50 migration was drafted-but-unapplied; it's now fully live
and independently validated end to end (canary matrix, both media
workloads' own fail-closed kill-switch test). Update the status line
and PIA region accordingly.
Also drop references to manifests/media/rollback/*-gluetun.yaml, which
is being removed now that qBittorrent/JDownloader are confirmed
working - rollback guidance now points at git history instead of a
kept-file copy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous ip6tables->sysctl fix (26894d1) was itself wrong: writing
/proc/sys/net/ipv6/conf/all/disable_ipv6 failed live with "Read-only
file system" despite NET_ADMIN and a passing [ -w ] check - the
container runtime mounts /proc/sys read-only by default regardless of
capabilities, independent of file permission bits. Making it writable
needs either kubelet's securityContext.sysctls (and
net.ipv6.conf.*.disable_ipv6 isn't on its default safe-sysctls
allowlist, so that means a node-level --allowed-unsafe-sysctls flag)
or securityContext.procMount: Unmasked (which needs pod-level user
namespaces) - too much blast radius for one pod's IPv6 kill switch.
nft (nftables) is genuinely present in the same image (the "nftables"
apk package, installed alongside "iptables" but not "ip6tables") and
handles the ip6 address family without a separate binary, so it needs
no image or capability change. Verified by actually running the exact
commands against the real pinned nicolaka/netshoot:v0.11 image
(digest-matched to what's on nik-debian), not just against docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nicolaka/netshoot:v0.11 (the pinned tag actually in use) is built on
Alpine 3.18.0, where ip6tables is packaged separately from iptables;
this image's Dockerfile only installs the latter, so ip6tables does
not exist in the container at all (confirmed live: "ip6tables: not
found", and against Alpine's own v3.18 package index, not master's).
Write 1 to /proc/sys/net/ipv6/conf/all/disable_ipv6 instead - per the
kernel's own ip-sysctl docs this is equivalent to also setting
conf/default/disable_ipv6 and every existing per-interface
disable_ipv6 (lo/eth0/net1) at once, and is strictly stronger than an
iptables DROP policy would have been: no IPv6 activity happens on any
interface at all, not just filtered output. Needs no capability beyond
the NET_ADMIN the guard init container already has.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diagnosed the vlan50-canary FailedCreatePodSandBox->net1-timeout failure
as a stale Pod artifact of the Multus DaemonSet rollout race (sandbox
created via a transient daemon/shim state mid-rollout; the current,
fully-settled daemon's own logs show no ADD for that UID, only a DEL).
No defect found in 02-daemonset.yaml or 10-nad-vlan50.yaml; both are
unchanged.
Independent of that diagnosis, harden the shared guard script per
review: print safe interface/address/route diagnostics before every
FATAL exit; validate net1 actually carries the workload's expected
static /24 address rather than just existing; add arping-based
duplicate-address and gateway-reachability checks before installing
the net1 default route (exit-code semantics verified against arping's
own source). Requires EXPECTED_VLAN50_IP and NET_RAW (for arping's raw
ARP sockets) on every consumer - wired into 20-canary.yaml now,
qbittorrent.yaml/jdownloader.yaml need the same when they're migrated.
Document the canary's Pod lifecycle: restartPolicy: Never means a
Failed canary never reruns on its own, and Argo "Synced" only reflects
manifest match, not runtime success - recreate it (new UID) after any
Multus/CNI change before trusting its result. Recommend keeping it a
manually recreated, controller-less Pod rather than a Job/Deployment,
since unattended auto-retry risks a duplicate-address race on its
static .100 IP - the exact class of bug this diagnosis just walked
through.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
k3s deliberately ships only the plugins its own flannel networking
needs (bandwidth, bridge, firewall, flannel, host-local, loopback,
portmap — confirmed against k3s's own build script; macvlan's absence
is an intentional upstream scope decision, k3s-io/k3s#9224). Multus's
macvlan delegate for the vlan50 NAD therefore had nothing to exec even
after the binDir/mount fixes.
Add a cni-plugins init container mirroring Rancher's own official
rke2-multus Helm chart (the fix k3s's own docs recommend for this
exact gap): rancher/hardened-cni-plugins, digest-pinned, writing into
our existing cnibin mount instead of the image's hardcoded
/opt/cni/bin, with SKIP_CNI_BINARIES=flannel so it doesn't overwrite
k3s's already-working flannel plugin.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
binDir alone (previous commit) wasn't sufficient: k3s's plugin bin dir
is a directory of symlinks into a content-hash-named sibling directory,
and the narrow cnibin mount didn't cover that sibling. FindInPath's
os.Stat follows symlinks, so it failed inside the container even though
the same path resolves fine on the real host. Mount the parent
(k3s-cni-data) into the main container instead so the symlinks resolve;
the init container keeps the narrower cnibin mount since it only writes
files and never follows them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root cause: Multus's ChrootExec resolves a delegate plugin's absolute
path from the daemon container's own filesystem view, then execs that
exact path string after chrooting into the real host root. binDir
defaulted to /opt/cni/bin, which is valid inside the container (bind-
mounted to k3s's real bin dir) but doesn't exist on the actual host, so
every pod attaching to the VLAN 50 NAD failed FailedCreatePodSandBox
trying to delegate to flannel. Confirmed against multus-cni's source at
the exact pinned v4.3.0 tag, not assumed. Sets binDir explicitly to
k3s's real path and matches the cnibin mount path to it on both sides of
the chroot boundary (main container and the install_multus init
container).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CRD, RBAC, thick-plugin DaemonSet (nik-debian only, k3s CNI paths,
digest-pinned), the VLAN 50 NAD (macvlan bridge on enp1s0.50), the
shared pod egress-guard script, a temporary canary pod, and the
not-yet-deployed browser-vpn-proxy workload (kept in reserved/, outside
this Application's non-recursive source path).
Referenced by argocd/apps/multus.yaml (already pushed) but not deployed
by it — that Application has no syncPolicy.automated, so this still
needs an explicit selective sync per resource.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
minisforum, debian, mac-mini, and gpu-node all moved from
192.168.7.0/24 to 10.10.40.0/24. Updates K3s server/agent config and
node IPs (including gpu-node's host_vars override), NFS export
allow-list and exports template, Pi-hole DNS records and
kube-vip/loadBalancerIP pins, WireGuard's pushed DNS/AllowedIPs, and
the NFS server IP baked into Jellyfin/Kavita/gitea-backup PVs and the
Ollama URL used by ai-gateway/Dashy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This reverts commit fd13b8a. The registry-host migration broke image
pulls for home-services/portfolio (internal CA not trusted by
containerd) and has left them Degraded for 11 days; reverting back to
gitea.nik4nao.com pending a decision on how to proceed. Not pushed yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The public domain is unreachable while moving, and the cluster had no
Traefik route to Gitea at all (public or internal), leaving every Argo
CD Application stuck in Unknown sync. Add a gitea.home.arpa
Certificate/IngressRoute, repoint Argo CD's repoURL, Gitea's own
DOMAIN/ROOT_URL/SSH_DOMAIN, the container registry references, the
Gitea Actions runner, and the watch-party clone URL at the internal
hostname.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Callers on the LAN (e.g. nik-gpu) don't trust this cluster's internal CA,
and installing it everywhere was judged not worth it for a LAN-only,
bearer-token-authenticated endpoint. Drops the internal-CA Certificate and
switches the IngressRoute from websecure to the web entrypoint.
TODO: revert once done - restores plaintext-only gRPC (no client cert
required) so synthesis can be exercised via port-forward + grpcurl
-plaintext from outside the cluster, without extracting cert material.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>