Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 4938f8f5a2 chore(opentofu): drop the spent removed block for module.k8s_3
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The removal has been applied:

  Apply complete! Resources: 0 added, 0 changed, 0 destroyed, 1 forgotten.

`tofu state list` now returns no Proxmox resources at all, and a full plan with
refresh enabled produces no Proxmox errors — the "no route to host" against
10.2.40.201:8006 that failed every infrastructure plan since the conversion is
gone.

The block was always one-shot. Left in place it refers to a state entry that no
longer exists, which is harmless but misleading: it reads as pending work.

Worth recording for the next time this shape comes up, because it cost a failed
merge to learn. A `removed` block alone does not fix an unreachable provider:
OpenTofu still refreshes the resource before it can forget it, and that refresh
is exactly what cannot succeed. It takes one run with `-refresh=false` to
process. `-target` does not help — targeting does not reach `removed` blocks, so
a targeted plan reports "No changes" while the state entry sits there untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 08:07:45 +02:00
k.renner 0653361bc4 Merge pull request 'chore: remove the decommissioned Proxmox DNS server and its migration script' (#622) from drop-decommissioned-dns into master
ci/woodpecker/push/deploy-infrastructure Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#622
2026-08-26 05:20:29 +00:00
k.renner 7b2900e594 Merge pull request 'fix(opentofu): drop the last Proxmox VM and forget it from state' (#621) from tofu-drop-proxmox-vm into master
ci/woodpecker/push/deploy-infrastructure Pipeline failed
Reviewed-on: k.renner/homelab#621
2026-08-26 05:19:34 +00:00
claude-botandClaude Opus 5 56bb47e483 chore: remove the decommissioned Proxmox DNS server and its migration script
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The Proxmox DNS HA VIP (10.2.40.53) was decommissioned after it dead-ended
non-Ingress resolution and broke ~1374 Home Assistant entities on a pod restart
(2026-07-14). With Proxmox itself now gone, the implementation and everything
pointing at it are dead weight — and one piece was actively dangerous.

Removed:

  dns-server/                         the Go implementation, its systemd unit,
                                      and a 7.4 MB checked-in binary
  opentofu/scripts/remove_unifi_clients.sh
  the "Proxmox DNS (VIP)" Gatus check in the ansible role

remove_unifi_clients.sh is the one worth calling out. It instructs the reader
to deploy module.proxmox_dns and point UniFi DNS at 10.2.40.53 — precisely the
configuration that caused the outage. It also works by `tofu state rm`, which
orphans resources rather than removing them. It was already marked deprecated
in its own header; anyone who ignored that and followed it would have
reintroduced a known failure.

The Gatus check queried k8s-1.srv.renner.uno against 10.2.40.53 every 30s. It
belongs to the ansible gatus role, which targets a Proxmox LXC that no longer
exists, so nothing was running it — but the check itself asserts the decommis-
sioned server should answer, which is the opposite of the intended end state.

Deliberately kept: the three comments in bootstrap.sh, coredns-config and
k8s-gateway that record 10.2.40.53 as DECOMMISSIONED and say not to reintroduce
it. They carry the outage date and blast radius, and are the reason this keeps
not happening again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 07:18:23 +02:00
claude-botandClaude Opus 5 c1a6a2c6ca fix(opentofu): drop the last Proxmox VM and forget it from state
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Every infrastructure plan has failed since neptune was wiped. module.k8s_3 still
declared a Proxmox VM, so the bpg/proxmox provider tried to reach a hypervisor
that no longer exists:

  Error: failed to get resources list of type ("vm") for cluster:
  dial tcp 10.2.40.201:8006: connect: no route to host
    with module.k8s_3.proxmox_virtual_environment_vm.this

Deleting vm_152_k8s_3.tf alone does not fix that. Removing a resource from
configuration makes OpenTofu plan a destroy, and planning a destroy refreshes
the resource through the same unreachable provider.

A `removed` block with `lifecycle { destroy = false }` forgets the state entry
without contacting Proxmox. Nothing is orphaned: the guest, its disks and the
hypervisor underneath were all destroyed during the conversion. This is the
declarative equivalent of a state removal, reviewable in git rather than run by
hand against production state.

talos_image.tf goes with it. Its locals named the nocloud image for VM import
and had no consumer other than the VM module.

Deliberately kept for a follow-up: the proxmox provider block, the
proxmox_vm/proxmox_lxc/bpg_proxmox_container modules and sync_state_from_pve.py.
A provider with no resources is never contacted, so leaving it costs nothing,
and removing it should wait until a clean plan proves state holds no other
Proxmox resources. That check cannot be made from here — the S3 credentials
used for local runs have been revoked.

Verified with `tofu validate` and `tofu fmt -check` offline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 07:05:16 +02:00
k.renner 8173cf5517 Merge pull request 'chore: remove the Proxmox observability and access surface' (#620) from drop-pve-monitoring into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#620
2026-08-26 04:49:16 +00:00
claude-botandClaude Opus 5 d50be81ed1 chore: remove the Proxmox observability and access surface
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
No Proxmox remains. jupiter, saturn and neptune all run Talos on bare metal,
so everything that scraped, proxied, alerted on or rotated credentials for the
PVE API now points at hosts that answer nothing on :8006.

Two of these were not merely dead but actively failing on a schedule:

  gatus            an Infrastructure check on https://pve.renner.uno, every
                   60s, which can now only ever fail
  openbao rotation a Class-B CronJob rolling the pve-exporter@pve API token
                   against pve.apps.svc.cluster.local:8006

Removed:

  apps/pve/                       Service, EndpointSlice (10.2.40.201-203),
                                  IngressRoute for the PVE web UIs
  monitoring/pve-exporter-*       Deployment and its config ExternalSecret
  prometheus scrape configs       job_name pve and pve-node
  dashboards/proxmox-cluster.json and its ConfigMap generator entry
  image-automation/pve-exporter   image policy for an image nothing runs
  openbao/rotation/pve-exporter   the rotation CronJob
  openbao/policies/rotator-proxmox.hcl
  crowdsec pve-ui-polling-whitelist, which suppressed false bans on PVE UI
                                  API polling that no longer happens

Kept, with comments corrected: the insecure-backend ServersTransport, still
used by apps/file for the NAS web UI.

The gatus check leaves a numbering gap at "Infrastructure 2" rather than
renumbering 3 and 4. The labels are deliberately opaque and renumbering would
relabel the surviving checks, discarding their history for cosmetics.

Not touched: the opentofu Proxmox provider, VM and LXC modules,
vm_152_k8s_3.tf, sync_state_from_pve.py, and the ansible PVE inventory. Those
carry state implications and want a deliberate pass rather than folding into a
manifest cleanup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 06:46:40 +02:00
k.renner 357f8d79c2 Merge pull request 'chore(ci): drop the Talos image seeding step' (#612) from drop-talos-image-seeding into master
ci/woodpecker/push/deploy-infrastructure Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline failed
Reviewed-on: k.renner/homelab#612
2026-08-26 04:38:38 +00:00
k.renner 0d8f436466 Merge pull request 'fix(cilium): roll the agent DaemonSet when cilium-config changes' (#613) from cilium-reloader into master
Reviewed-on: k.renner/homelab#613
2026-08-26 04:37:21 +00:00
k.renner e33b0f584a Merge pull request 'fix(talos): install from the Image Factory metal installer' (#614) from talos-factory-installer into master
Reviewed-on: k.renner/homelab#614
2026-08-26 04:36:57 +00:00
claude-botandClaude Opus 5 f84df3b188 fix(talos): install from the Image Factory metal installer
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
machine.install.image was never set, so every node installed from the stock
ghcr.io/siderolabs/installer that talosctl gen config defaults to. Extensions
live in the installer image, which makes the schematic a property of the boot
media alone — nothing lands on disk. talosctl get extensions returns empty on
all three nodes.

Two extensions are missing as a result, and the cold-rebuild runbook explains
why each was chosen for bare metal:

  siderolabs/intel-ucode   CPU microcode. On a VM the hypervisor supplied it;
                           on bare metal nothing does except the BIOS.
  siderolabs/thunderbolt   Inert until machine.kernel.modules asks for it.
                           Present so enabling the MS-01 mesh later needs
                           neither a new image nor a reinstall — precisely the
                           property installing without it destroys.

Set in the shared cluster patch rather than per node: all three are bare metal
now and want the same image. Schematic a21cec8f is the bare-metal one from
RUNBOOK-cold-rebuild.md — same net.ifnames=1 as the Proxmox schematic, minus
qemu-guest-agent.

Config-only. install.image is read at install and upgrade time, so no running
node changes until it is upgraded; the three nodes stay without extensions
until a deliberate rolling upgrade is scheduled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:41:03 +02:00
claude-botandClaude Opus 5 e11ba99991 fix(cilium): roll the agent DaemonSet when cilium-config changes
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Cilium reads datapath options once, at agent start, and the chart puts no
config checksum on the pod template. A changed ConfigMap therefore leaves every
agent running the previous datapath until something restarts it, and nothing
does — the DaemonSet is not rolled by a ConfigMap-only Helm change.

This already cost an outage. vlan-bpf-bypass landed in cilium-config and every
v30iot macvlan workload stayed broken until the agents were deleted by hand,
one node at a time (issue #592). The merge looked successful: HelmRelease
Ready, release upgraded, key present in the ConfigMap, and nothing working.

policyMapMax carries the same exposure. Its comment says "takes effect on agent
restart", which is documentation standing in for automation — as does the
equivalent note in the two bootstrap copies.

Reloader is already deployed and used across the repo for the same purpose.
Named per-ConfigMap rather than blanket, matching the existing convention.

The trade, stated plainly: any cilium-config change now rolls the CNI
cluster-wide, node by node. That is the same disruption a datapath change
already required; the difference is that it happens on its own rather than
being forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:37:31 +02:00
claude-botandClaude Opus 5 43143847e9 chore(ci): drop the Talos image seeding step
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The step pushed a nocloud Talos image onto each Proxmox node so the k8s VMs
could boot from it. No Proxmox node remains — neptune was the last one, and all
three control-plane nodes now boot the metal image from their own installed
disk.

Left in place the step would call the Proxmox storage API for a host that no
longer answers it, then sit through its 60x10s download-wait before giving up,
on every infrastructure apply.

The block's own comment already scheduled its removal: "the whole step goes away
once neptune is converted too."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:35:46 +02:00
k.renner 1febebc55d Merge pull request 'fix(cilium): allow VLAN 30 through the eBPF datapath' (#611) from cilium-vlan-bypass into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#611
2026-08-26 00:27:35 +00:00
claude-botandClaude Opus 5 aade7b5d29 fix(cilium): allow VLAN 30 through the eBPF datapath
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
Every v30iot macvlan workload on a bare-metal node has been stuck in sandbox
creation since its node was converted. Cilium's datapath drops VLAN-tagged
frames unless bpf.vlanBypass lists the tag, and cilium-config carried no
vlan-bpf-bypass key at all.

Inside the Proxmox guests ens19 was an untagged virtio NIC on the v30iot
bridge, so nothing was tagged by the time Cilium saw it. On bare metal ens19 is
a VLAN 30 sub-interface of enp2s0f0np0, and the macvlan children's DHCP
exchange never reaches the wire.

Measured rather than inferred: cilium_drop_count_total on the affected node
reports ~123k INGRESS and ~1.1k EGRESS drops with reason "VLAN traffic
disallowed by VLAN filter", against none on the node that was still a VM, and
node_network_receive_packets_total for ens19 reads 0 there against 10 pkt/s on
the VM. Full analysis in issue #592.

The symptom hides its own cause. A tc-layer drop surfaces to an AF_PACKET
sender as ENOBUFS, so the CNI dhcp plugin reports "no buffer space available"
and "network is down" instead of a DHCP timeout.

VLAN 40 is deliberately not listed. mgmt0 rides the same parent and carries the
node IP, etcd and the API, and it passes today without a bypass entry, so
adding it would change a working control-plane datapath to fix an IoT one.

All three copies of the key move together — the HelmRelease, kubernetes/talos/
cilium.yaml, and the inline bootstrap manifest in patches/cilium-cni.yaml — so
a cold rebuild does not come up without it.

The runbook predicted this exact failure and is updated to record that it
happened, along with how to recognise it from the drop counters rather than the
misleading CNI error. music-assistant's comment claiming ens19 is "the v30iot
NIC" on every node is corrected; it is a NIC on the edge worker and a VLAN
sub-interface on the control-plane nodes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:26:41 +02:00
k.renner 02817ef45e Merge pull request 'feat(rook-ceph): restore replicapool and cephfs-data0 to size 3' (#610) from restore-replicapool-size into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#610
2026-08-26 00:14:05 +00:00
37 changed files with 118 additions and 1536 deletions
-36
View File
@@ -128,42 +128,6 @@ steps:
- printf "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
- echo "$VAULT_PASS" > /tmp/.vault_pass && chmod 600 /tmp/.vault_pass
- export ANSIBLE_VAULT_PASSWORD_FILE=/tmp/.vault_pass
- |
# Ensure Talos image exists on all Proxmox nodes via API.
#
# Only nodes still running Proxmox belong in this list. jupiter and
# saturn run Talos on bare metal now, so the storage API returns
# nothing for them and the download-wait loop below would burn its full
# 60x10s per node before giving up. Bare-metal nodes boot the
# metal-amd64 image from their own installed disk instead, and the
# whole step goes away once neptune is converted too.
TALOS_VERSION="v1.12.6"
TALOS_SCHEMATIC="88e3752db032a08082972bd0adf16a80849e3870c3f95cf5b669936c5699e5ad"
TALOS_URL="https://factory.talos.dev/image/$TALOS_SCHEMATIC/$TALOS_VERSION/nocloud-amd64.raw"
TALOS_FILENAME="talos-$TALOS_VERSION-$(echo "$TALOS_SCHEMATIC" | cut -c1-12)-nocloud-amd64.raw"
API_TOKEN="$TF_VAR_proxmox_user!$TF_VAR_proxmox_token_id=$TF_VAR_proxmox_token_secret"
for node in neptune; do
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
if [ "$EXISTS" -gt 0 ]; then
echo "Talos image already exists on $node"
continue
fi
echo "Downloading Talos image to $node..."
curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/download-url" \
-H "Authorization: PVEAPIToken=$API_TOKEN" \
--data-urlencode "url=$TALOS_URL" \
--data-urlencode "content=import" \
--data-urlencode "filename=$TALOS_FILENAME" \
--max-time 600
echo "Waiting for download on $node..."
for i in $(seq 1 60); do
sleep 10
EXISTS=$(curl -sk "$TF_VAR_proxmox_api_url/api2/json/nodes/$node/storage/local/content" \
-H "Authorization: PVEAPIToken=$API_TOKEN" 2>/dev/null | grep -c "$TALOS_FILENAME" || true)
if [ "$EXISTS" -gt 0 ]; then echo "Download complete on $node"; break; fi
done
done
- tofu init -upgrade -backend-config="access_key=$AWS_ACCESS_KEY_ID" -backend-config="secret_key=$AWS_SECRET_ACCESS_KEY"
- |
if [ -n "$$TOFU_STATE_RM" ]; then
@@ -253,22 +253,6 @@ endpoints:
{% endif %}
# Infrastructure
- name: Proxmox DNS (VIP)
group: infrastructure
url: 10.2.40.53
interval: 30s
dns:
query-name: k8s-1.srv.renner.uno
query-type: A
conditions:
- "[DNS_RCODE] == NOERROR"
- "[RESPONSE_TIME] < 500"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: PostgreSQL HA (VIP)
group: infrastructure
url: tcp://10.2.40.135:5432
-31
View File
@@ -1,31 +0,0 @@
# Proxmox DNS Server Configuration
# Proxmox API connection
proxmox:
host: "pve.renner.uno"
user: "root@pam"
# Token-based authentication (recommended)
token_name: "dns"
token_value: "your-token-secret-here"
# OR password-based authentication
# password: "your-password-here"
verify_ssl: false
# DNS server settings
dns_host: "0.0.0.0" # Listen on all interfaces
dns_port: 53 # Standard DNS port (requires root or CAP_NET_BIND_SERVICE)
# DNS suffix for VM/LXC hostnames
dns_suffix: ".renner.uno"
# TTL for DNS responses (in seconds)
dns_ttl: 300
# Cache settings
cache_ttl: 60 # How often to refresh the cache (in seconds)
# Upstream DNS server for non-Proxmox queries
upstream_dns: "10.2.40.1"
-15
View File
@@ -1,15 +0,0 @@
module proxmox-dns
go 1.21
require (
github.com/miekg/dns v1.1.58
gopkg.in/yaml.v3 v3.0.1
)
require (
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/tools v0.17.0 // indirect
)
-16
View File
@@ -1,16 +0,0 @@
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-565
View File
@@ -1,565 +0,0 @@
package main
import (
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"gopkg.in/yaml.v3"
)
type Config struct {
Proxmox struct {
Host string `yaml:"host"`
User string `yaml:"user"`
TokenName string `yaml:"token_name"`
TokenValue string `yaml:"token_value"`
VerifySSL bool `yaml:"verify_ssl"`
} `yaml:"proxmox"`
DNSHost string `yaml:"dns_host"`
DNSPort int `yaml:"dns_port"`
DNSSuffix string `yaml:"dns_suffix"`
DNSTTL int `yaml:"dns_ttl"`
CacheTTL int `yaml:"cache_ttl"`
UpstreamDNS string `yaml:"upstream_dns"`
}
type DNSRecord struct {
IPv4 string
IPv6 string
Timestamp time.Time
}
type ProxmoxDNS struct {
config Config
cache map[string]*DNSRecord
cacheMux sync.RWMutex
client *http.Client
stopChan chan struct{}
nodes []string
nodesMux sync.RWMutex
}
func NewProxmoxDNS(configPath string) (*ProxmoxDNS, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Set defaults
if config.DNSPort == 0 {
config.DNSPort = 53
}
if config.CacheTTL == 0 {
config.CacheTTL = 60
}
if config.DNSTTL == 0 {
config.DNSTTL = 300
}
if config.UpstreamDNS == "" {
config.UpstreamDNS = "10.2.40.1:53"
}
if !strings.Contains(config.UpstreamDNS, ":") {
config.UpstreamDNS += ":53"
}
return &ProxmoxDNS{
config: config,
cache: make(map[string]*DNSRecord),
nodes: []string{},
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
},
stopChan: make(chan struct{}),
}, nil
}
// isValidIPv6ForDNS checks if an IPv6 address is suitable for DNS records
func isValidIPv6ForDNS(ip net.IP) bool {
if ip == nil || ip.To16() == nil {
return false
}
// Reject loopback
if ip.IsLoopback() {
return false
}
// Reject link-local (fe80::/10) - THIS IS THE KEY FIX
if ip.IsLinkLocalUnicast() {
return false
}
// Reject multicast
if ip.IsMulticast() {
return false
}
// Reject unspecified
if ip.IsUnspecified() {
return false
}
// Accept global unicast and ULA (fc00::/7)
return true
}
func (p *ProxmoxDNS) queryProxmoxAPI(endpoint string) ([]byte, error) {
// Remove port from host if present
host := p.config.Proxmox.Host
if !strings.Contains(host, ":") {
host += ":8006"
}
url := fmt.Sprintf("https://%s/api2/json/%s", host, endpoint)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
auth := fmt.Sprintf("PVEAPIToken=%s!%s=%s",
p.config.Proxmox.User,
p.config.Proxmox.TokenName,
p.config.Proxmox.TokenValue)
req.Header.Set("Authorization", auth)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
func (p *ProxmoxDNS) discoverNodes() error {
data, err := p.queryProxmoxAPI("nodes")
if err != nil {
return fmt.Errorf("failed to discover nodes: %w", err)
}
var response struct {
Data []struct {
Node string `json:"node"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
return fmt.Errorf("failed to parse nodes: %w", err)
}
p.nodesMux.Lock()
defer p.nodesMux.Unlock()
p.nodes = []string{}
for _, node := range response.Data {
if node.Status == "online" {
p.nodes = append(p.nodes, node.Node)
}
}
log.Printf("Discovered %d online nodes: %v", len(p.nodes), p.nodes)
return nil
}
func (p *ProxmoxDNS) refreshCache() {
log.Println("Refreshing DNS cache...")
// Discover nodes first
if err := p.discoverNodes(); err != nil {
log.Printf("Failed to discover nodes: %v", err)
return
}
newCache := make(map[string]*DNSRecord)
p.nodesMux.RLock()
nodes := append([]string{}, p.nodes...)
p.nodesMux.RUnlock()
for _, node := range nodes {
// Query VMs
p.refreshVMs(node, newCache)
// Query LXC containers
p.refreshLXC(node, newCache)
}
p.cacheMux.Lock()
p.cache = newCache
p.cacheMux.Unlock()
log.Printf("Cache refreshed: %d entries", len(newCache))
}
func (p *ProxmoxDNS) refreshVMs(node string, cache map[string]*DNSRecord) {
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu", node))
if err != nil {
log.Printf("Failed to query VMs on %s: %v", node, err)
return
}
var response struct {
Data []struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
log.Printf("Failed to parse VM data: %v", err)
return
}
for _, vm := range response.Data {
if vm.Status != "running" || vm.Name == "" {
continue
}
// Query agent network info
agentData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu/%d/agent/network-get-interfaces", node, vm.VMID))
if err != nil {
log.Printf("VM %s (ID %d): no guest agent data", vm.Name, vm.VMID)
continue
}
var agentResp struct {
Data struct {
Result []struct {
Name string `json:"name"`
IPAddresses []struct {
IPAddress string `json:"ip-address"`
IPAddressType string `json:"ip-address-type"`
} `json:"ip-addresses"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(agentData, &agentResp); err != nil {
continue
}
var ipv4, ipv6 string
for _, iface := range agentResp.Data.Result {
if iface.Name == "lo" {
continue
}
for _, ipAddr := range iface.IPAddresses {
if ipAddr.IPAddressType == "ipv4" && ipv4 == "" {
if !strings.HasPrefix(ipAddr.IPAddress, "127.") {
ipv4 = ipAddr.IPAddress
}
} else if ipAddr.IPAddressType == "ipv6" && ipv6 == "" {
ip := net.ParseIP(ipAddr.IPAddress)
if isValidIPv6ForDNS(ip) {
ipv6 = ipAddr.IPAddress
log.Printf(" VM %s: accepting IPv6 %s", vm.Name, ipv6)
} else if ip != nil {
log.Printf(" VM %s: rejecting IPv6 %s (link-local/loopback/multicast)", vm.Name, ipAddr.IPAddress)
}
}
}
// Stop after finding both on first non-lo interface
if ipv4 != "" && ipv6 != "" {
break
}
}
if ipv4 != "" || ipv6 != "" {
hostname := strings.ToLower(vm.Name)
// Strip DNS suffix from hostname if present
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
cache[hostname] = &DNSRecord{
IPv4: ipv4,
IPv6: ipv6,
Timestamp: time.Now(),
}
log.Printf("VM: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
}
}
}
func (p *ProxmoxDNS) refreshLXC(node string, cache map[string]*DNSRecord) {
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc", node))
if err != nil {
log.Printf("Failed to query LXC on %s: %v", node, err)
return
}
var response struct {
Data []struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
log.Printf("Failed to parse LXC data: %v", err)
return
}
for _, lxc := range response.Data {
if lxc.Status != "running" || lxc.Name == "" {
continue
}
// Query LXC interfaces
ifaceData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc/%d/interfaces", node, lxc.VMID))
if err != nil {
log.Printf("LXC %s (ID %d): failed to get interfaces", lxc.Name, lxc.VMID)
continue
}
var ifaceResp struct {
Data []struct {
Name string `json:"name"`
Inet string `json:"inet"`
Inet6 string `json:"inet6"`
} `json:"data"`
}
if err := json.Unmarshal(ifaceData, &ifaceResp); err != nil {
continue
}
var ipv4, ipv6 string
for _, iface := range ifaceResp.Data {
if iface.Name == "lo" {
continue
}
if iface.Inet != "" && ipv4 == "" {
// Format is "IP/CIDR"
parts := strings.Split(iface.Inet, "/")
if len(parts) > 0 && !strings.HasPrefix(parts[0], "127.") {
ipv4 = parts[0]
}
}
if iface.Inet6 != "" && ipv6 == "" {
parts := strings.Split(iface.Inet6, "/")
if len(parts) > 0 {
ip := net.ParseIP(parts[0])
if isValidIPv6ForDNS(ip) {
ipv6 = parts[0]
log.Printf(" LXC %s: accepting IPv6 %s", lxc.Name, ipv6)
} else if ip != nil {
log.Printf(" LXC %s: rejecting IPv6 %s (link-local/loopback/multicast)", lxc.Name, parts[0])
}
}
}
// Stop after finding both on first non-lo interface
if ipv4 != "" && ipv6 != "" {
break
}
}
if ipv4 != "" || ipv6 != "" {
hostname := strings.ToLower(lxc.Name)
// Strip DNS suffix from hostname if present
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
cache[hostname] = &DNSRecord{
IPv4: ipv4,
IPv6: ipv6,
Timestamp: time.Now(),
}
log.Printf("LXC: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
}
}
}
func (p *ProxmoxDNS) handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Authoritative = true
for _, q := range r.Question {
name := strings.ToLower(strings.TrimSuffix(q.Name, "."))
qtype := dns.TypeToString[q.Qtype]
// Check if query is for our domain suffix
if !strings.HasSuffix(name, strings.TrimPrefix(p.config.DNSSuffix, ".")) {
// Forward to upstream
log.Printf("Query %s (%s) - forwarding to upstream", name, qtype)
p.forwardToUpstream(w, r)
return
}
// Extract hostname (remove suffix)
hostname := strings.TrimSuffix(name, p.config.DNSSuffix)
p.cacheMux.RLock()
record, found := p.cache[hostname]
p.cacheMux.RUnlock()
if !found {
// NXDOMAIN
m.SetRcode(r, dns.RcodeNameError)
log.Printf("Query %s (%s) - NXDOMAIN", name, qtype)
w.WriteMsg(m)
return
}
switch q.Qtype {
case dns.TypeA:
if record.IPv4 != "" {
rr := &dns.A{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: uint32(p.config.DNSTTL),
},
A: net.ParseIP(record.IPv4),
}
m.Answer = append(m.Answer, rr)
log.Printf("Query %s (A) -> %s", name, record.IPv4)
} else {
// NODATA (host exists but no A record)
m.Ns = append(m.Ns, p.getSOARecord())
log.Printf("Query %s (A) - NODATA", name)
}
case dns.TypeAAAA:
if record.IPv6 != "" {
// Parse and validate IPv6 again before returning
ip := net.ParseIP(record.IPv6)
if ip != nil && isValidIPv6ForDNS(ip) {
rr := &dns.AAAA{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: uint32(p.config.DNSTTL),
},
AAAA: ip,
}
m.Answer = append(m.Answer, rr)
log.Printf("Query %s (AAAA) -> %s", name, record.IPv6)
} else {
// IPv6 is invalid (link-local etc)
m.Ns = append(m.Ns, p.getSOARecord())
log.Printf("Query %s (AAAA) - NODATA (invalid IPv6 in cache)", name)
}
} else {
// NODATA (host exists but no AAAA record)
m.Ns = append(m.Ns, p.getSOARecord())
log.Printf("Query %s (AAAA) - NODATA", name)
}
}
}
w.WriteMsg(m)
}
func (p *ProxmoxDNS) getSOARecord() dns.RR {
domain := strings.TrimPrefix(p.config.DNSSuffix, ".")
soa := &dns.SOA{
Hdr: dns.RR_Header{
Name: domain + ".",
Rrtype: dns.TypeSOA,
Class: dns.ClassINET,
Ttl: 300,
},
Ns: "ns." + domain + ".",
Mbox: "admin." + domain + ".",
Serial: uint32(time.Now().Unix()),
Refresh: 3600,
Retry: 600,
Expire: 86400,
Minttl: 300,
}
return soa
}
func (p *ProxmoxDNS) forwardToUpstream(w dns.ResponseWriter, r *dns.Msg) {
c := new(dns.Client)
c.Timeout = 2 * time.Second
resp, _, err := c.Exchange(r, p.config.UpstreamDNS)
if err != nil {
log.Printf("Failed to forward to upstream: %v", err)
m := new(dns.Msg)
m.SetRcode(r, dns.RcodeServerFailure)
w.WriteMsg(m)
return
}
w.WriteMsg(resp)
}
func (p *ProxmoxDNS) startCacheRefresher() {
ticker := time.NewTicker(time.Duration(p.config.CacheTTL) * time.Second)
defer ticker.Stop()
// Initial refresh
p.refreshCache()
for {
select {
case <-ticker.C:
p.refreshCache()
case <-p.stopChan:
return
}
}
}
func (p *ProxmoxDNS) Start() error {
// Start cache refresher
go p.startCacheRefresher()
// Start DNS server
dns.HandleFunc(".", p.handleDNSRequest)
addr := fmt.Sprintf("%s:%d", p.config.DNSHost, p.config.DNSPort)
log.Printf("Starting Proxmox DNS server on %s", addr)
log.Printf("Base domain: %s", p.config.DNSSuffix)
log.Printf("Upstream DNS: %s", p.config.UpstreamDNS)
server := &dns.Server{Addr: addr, Net: "udp"}
return server.ListenAndServe()
}
func main() {
configPath := flag.String("c", "/etc/proxmox-dns/config.yaml", "Path to configuration file")
flag.Parse()
server, err := NewProxmoxDNS(*configPath)
if err != nil {
log.Fatalf("Failed to create DNS server: %v", err)
}
log.Printf("Proxmox DNS Server starting...")
if err := server.Start(); err != nil {
log.Fatalf("Failed to start DNS server: %v", err)
}
}
Binary file not shown.
-27
View File
@@ -1,27 +0,0 @@
[Unit]
Description=Proxmox DNS Server
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/proxmox-dns
ExecStart=/usr/bin/python3 /opt/proxmox-dns/proxmox_dns.py -c /etc/proxmox-dns/config.yaml
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
+20 -4
View File
@@ -389,7 +389,23 @@ The retired Crucial leaves a stale entry in `ceph device ls`. Remove it with
cold rebuild is broken independently of this work.
- Manual Woodpecker pipelines are broken (issue #582), so anything gated on
`PIPELINE == "..."` must be run another way.
- Cilium's eBPF datapath silently drops VLAN-tagged traffic unless
`bpf.vlanBypass` is set. Both `mgmt0` and `ens19` are VLAN interfaces on a
converted node. Not observed on jupiter, but it is the first place to look if
the v30iot macvlan workloads misbehave there.
- Cilium's eBPF datapath drops VLAN-tagged traffic unless `bpf.vlanBypass` is
set. **This happened** — every v30iot macvlan workload on a converted node
stayed in sandbox creation until `vlanBypass: [30]` was added (issue #592).
Only `ens19` is affected, and the reason is worth knowing: Cilium attaches to
devices that carry an address. `cilium-dbg status --verbose` lists `mgmt0`
among its devices but not `ens19`, because `ens19` is deliberately
address-less. Cilium therefore owns `mgmt0` and sees VLAN 40 frames already
untagged by the 8021q layer, while VLAN 30 frames only ever meet the filter
on the shared parent `enp2s0f0np0`, still tagged. Any future address-less
VLAN carrying pod traffic will need the same treatment.
The symptom does not look like a VLAN problem. A tc-layer drop reaches an
AF_PACKET sender as ENOBUFS, so the CNI dhcp plugin reports `no buffer space
available` and `network is down` rather than a DHCP timeout, and the pod sits
in `ContainerCreating` behind `plugin type="macvlan" failed (add): error
calling DHCP.Allocate: no more tries`. Confirm it with
`cilium_drop_count_total{reason=~"VLAN.*"}` per node rather than by reading
the CNI logs, and check `node_network_receive_packets_total{device="ens19"}`
— it reads 0 on an affected node.
+1 -16
View File
@@ -19,7 +19,7 @@ metadata:
# Opaque-label map (maintainer reference only — NOT served publicly):
# services group infrastructure group
# Web Service 1 = Authentik Infrastructure 1 = PBS 2 (pbs2.renner.uno)
# Web Service 2 = Gitea Infrastructure 2 = Proxmox VE (pve.renner.uno)
# Web Service 2 = Gitea Infrastructure 2 = retired (was Proxmox VE)
# Web Service 3 = Immich Infrastructure 3 = UDM DNS (10.2.40.1)
# Web Service 4 = Woodpecker CI Infrastructure 4 = Mosquitto MQTT broker
# Web Service 5 = Grafana
@@ -206,21 +206,6 @@ data:
conditions:
- "[STATUS] == any(200, 301, 302)"
- name: Infrastructure 2
group: infrastructure
url: https://pve.renner.uno
interval: 60s
client:
insecure-skip-verify: true
ui:
hide-hostname: true
hide-url: true
hide-port: true
hide-conditions: true
hide-errors: true
conditions:
- "[STATUS] == any(200, 301, 302)"
- name: Infrastructure 3
group: infrastructure
url: 10.2.40.1
@@ -2,7 +2,6 @@
# backend serving HTTPS with a self-signed / hostname-mismatched cert:
#
# apps/file — NAS web UI on a self-signed cert
# apps/pve — PVE on :8006 with per-node self-signed cert
#
# Replaces the nginx `backend-protocol: HTTPS` + `proxy-ssl-verify: off`
# annotation pair. Each Ingress that wants this behaviour sets
-1
View File
@@ -7,7 +7,6 @@ resources:
- file/
- gitea/
- ical-proxy/
- pve/
# woodpecker/ moved to its own dedicated `woodpecker` Kustomization
# (clusters/homelab/woodpecker.yaml) so its wp-pg CNPG cluster's barman
# dependency + Cluster health-gating don't contaminate this shared apps KS.
@@ -83,9 +83,6 @@ configMapGenerator:
- name: dashboard-power-flow
files:
- power-flow.json
- name: dashboard-proxmox-cluster
files:
- proxmox-cluster.json
- name: dashboard-pv-system-roi
files:
- pv-system-roi.json
@@ -1,239 +0,0 @@
{
"annotations": {"list": []},
"description": "Proxmox VE Cluster Overview",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 3}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 0},
"id": 1,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_up{id=~\"node/.*\"} == 1))", "refId": "A"}],
"title": "Online Nodes",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "blue", "value": null}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 0},
"id": 2,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_guest_info{type=\"qemu\", template=\"0\"}))", "refId": "A"}],
"title": "Total VMs",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "purple", "value": null}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
"id": 3,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_guest_info{type=\"lxc\"}))", "refId": "A"}],
"title": "Total LXC Containers",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 5, "x": 15, "y": 0},
"id": 4,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(count by (id) (pve_up{id=~\"(qemu|lxc)/.*\"} == 1))", "refId": "A"}],
"title": "Running Guests",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [{"options": {"0": {"color": "green", "index": 0, "text": "All Backed Up"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 1}, {"color": "red", "value": 5}]}},
"overrides": []
},
"gridPos": {"h": 4, "w": 4, "x": 20, "y": 0},
"id": 12,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max(pve_not_backed_up_total)", "refId": "A"}],
"title": "Not Backed Up",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 60}, {"color": "red", "value": 80}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 6, "w": 8, "x": 0, "y": 4},
"id": 5,
"options": {"displayMode": "lcd", "minVizHeight": 10, "minVizWidth": 0, "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showUnfilled": true},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_cpu_usage_ratio{id=~\"node/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Node CPU Usage",
"type": "bargauge"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 70}, {"color": "red", "value": 85}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 6, "w": 8, "x": 8, "y": 4},
"id": 6,
"options": {"displayMode": "lcd", "minVizHeight": 10, "minVizWidth": 0, "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showUnfilled": true},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_memory_usage_bytes{id=~\"node/.*\"}) / max by (id) (pve_memory_size_bytes{id=~\"node/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Node Memory Usage",
"type": "bargauge"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"mappings": [],
"max": 100,
"min": 0,
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 70}, {"color": "red", "value": 85}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 6, "w": 8, "x": 16, "y": 4},
"id": 7,
"options": {"displayMode": "lcd", "minVizHeight": 10, "minVizWidth": 0, "orientation": "horizontal", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showUnfilled": true},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_disk_usage_bytes{id=~\"storage/.*\"}) / max by (id) (pve_disk_size_bytes{id=~\"storage/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Storage Usage",
"type": "bargauge"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "percent"
},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 10},
"id": 8,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_cpu_usage_ratio{id=~\"node/.*\"}) * 100", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Node CPU Over Time",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "bytes"
},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 10},
"id": 9,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_memory_usage_bytes{id=~\"node/.*\"})", "legendFormat": "{{id}}", "refId": "A"}],
"title": "Node Memory Usage Over Time",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}
},
"overrides": [
{"matcher": {"id": "byName", "options": "Status"}, "properties": [{"id": "custom.cellOptions", "value": {"type": "color-background"}}, {"id": "mappings", "value": [{"options": {"0": {"color": "red", "index": 1, "text": "Stopped"}, "1": {"color": "green", "index": 0, "text": "Running"}}, "type": "value"}]}]},
{"matcher": {"id": "byName", "options": "CPU %"}, "properties": [{"id": "unit", "value": "percent"}, {"id": "custom.cellOptions", "value": {"mode": "gradient", "type": "gauge"}}, {"id": "min", "value": 0}, {"id": "max", "value": 100}]},
{"matcher": {"id": "byName", "options": "Memory %"}, "properties": [{"id": "unit", "value": "percent"}, {"id": "custom.cellOptions", "value": {"mode": "gradient", "type": "gauge"}}, {"id": "min", "value": 0}, {"id": "max", "value": 100}]}
]
},
"gridPos": {"h": 10, "w": 24, "x": 0, "y": 18},
"id": 10,
"options": {"footer": {"fields": "", "reducer": ["sum"], "show": false}, "showHeader": true, "sortBy": [{"desc": false, "displayName": "Name"}]},
"targets": [
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id, name, node, type) (pve_guest_info)", "format": "table", "instant": true, "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_cpu_usage_ratio{id=~\"(qemu|lxc)/.*\"}) * 100", "format": "table", "instant": true, "refId": "B"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_memory_usage_bytes{id=~\"(qemu|lxc)/.*\"}) / max by (id) (pve_memory_size_bytes{id=~\"(qemu|lxc)/.*\"}) * 100", "format": "table", "instant": true, "refId": "C"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (pve_up{id=~\"(qemu|lxc)/.*\"})", "format": "table", "instant": true, "refId": "D"}
],
"title": "Guest Status",
"transformations": [
{"id": "seriesToColumns", "options": {"byField": "id"}},
{"id": "organize", "options": {"excludeByName": {"Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "Time 4": true, "Value #A": true}, "indexByName": {"id": 0, "name": 1, "node": 2, "type": 3, "Value #D": 4, "Value #B": 5, "Value #C": 6}, "renameByName": {"Value #B": "CPU %", "Value #C": "Memory %", "Value #D": "Status", "id": "ID", "name": "Name", "node": "Node", "type": "Type"}}}
],
"type": "table"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}},
"mappings": [],
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 28},
"id": 11,
"options": {"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (rate(pve_disk_read_bytes_total{id=~\"(qemu|lxc)/.*\"}[5m]))", "legendFormat": "{{id}} read", "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "max by (id) (rate(pve_disk_written_bytes_total{id=~\"(qemu|lxc)/.*\"}[5m]))", "legendFormat": "{{id}} write", "refId": "B"}
],
"title": "Guest Disk I/O",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["homelab", "proxmox", "virtualization"],
"templating": {"list": []},
"time": {"from": "now-1h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "Proxmox VE Cluster",
"uid": "proxmox-cluster",
"version": 1,
"weekStart": ""
}
@@ -93,68 +93,8 @@ spec:
resources:
requests:
storage: 50Gi
# Scrape existing Proxmox infrastructure targets
# Additional scrape targets not covered by ServiceMonitors.
additionalScrapeConfigs:
# pve-exporter is a blackbox-style exporter: Prometheus passes
# ?target=<host> via relabel, and one in-cluster exporter
# instance queries each PVE node's API on the user's behalf.
# The targets list is the PVE node addresses; the actual
# scrape goes to pve-exporter.monitoring.svc:9221.
#
# Cluster collectors (pve_up, pve_guest_info, …) return the SAME
# cluster-wide resource set no matter which node answers, so they
# are scraped from a single node only. Scraping them from all three
# (the pre-2026-07 config) ingested every node/guest series 3x —
# identical `id`, differing `instance` — a real TSDB cardinality
# cost that dashboards had to paper over with max by (id).
#
# Target the pve Service (apps ns) rather than a hardcoded node IP:
# its EndpointSlice load-balances the PVE API across all three nodes,
# so cluster metrics keep flowing as long as ANY node is up, instead
# of pausing whenever .201 (jupiter) specifically is down. The
# exporter reaches the API on 8006 (verify_ssl:false, so the LB'd
# cert hostname mismatch is a non-issue).
- job_name: pve
static_configs:
- targets:
- "pve.apps.svc.cluster.local"
metrics_path: /pve
params:
cluster: ["1"]
node: ["0"]
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: pve-exporter.monitoring.svc.cluster.local:9221
# Node collectors (pve_version_info, replication status) are
# node-local and genuinely distinct per target, so every node is
# scraped — this is the split recommended by prometheus-pve-exporter
# upstream to avoid the cluster-collector duplication above.
# Targets are the node FQDNs, not raw IPs: the instance label then
# reads jupiter/neptune/saturn.renner.uno, and a renumbered node is
# picked up from DNS without editing this file.
- job_name: pve-node
static_configs:
- targets:
- "jupiter.renner.uno"
- "neptune.renner.uno"
- "saturn.renner.uno"
metrics_path: /pve
params:
cluster: ["0"]
node: ["1"]
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: pve-exporter.monitoring.svc.cluster.local:9221
# Gitea exposes /metrics anonymously on its main HTTP port. The
# `gitea_*` metrics power the gitea-overview dashboard. Gitea now runs
# in-cluster (StatefulSet in apps ns; LXC 114 retired), so scrape the
@@ -3,8 +3,6 @@ kind: Kustomization
resources:
- grafana-oauth-externalsecret.yaml
- alertmanager-smtp-externalsecret.yaml
- pve-exporter-config-externalsecret.yaml
- pve-exporter-deployment.yaml
- helmrelease-prometheus.yaml
- helmrelease-loki.yaml
- helmrelease-alloy-syslog.yaml
@@ -1,17 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: pve-exporter-config
namespace: monitoring
spec:
# 10m (was 1h) so an automated token rotation propagates within ~10m; Reloader
# then restarts pve-exporter. See kubernetes/infrastructure/openbao/rotation/pve-exporter.yaml.
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: pve-exporter-config # keeps the old Secret's name — consumers unchanged
dataFrom:
- extract:
key: apps/monitoring/pve-exporter-config
@@ -1,84 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: pve-exporter
namespace: monitoring
annotations:
# Reloader restarts pve-exporter when pve-exporter-config changes (token
# rotation) so it reloads pve.yml. See
# kubernetes/infrastructure/openbao/rotation/pve-exporter.yaml.
secret.reloader.stakater.com/reload: pve-exporter-config
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: pve-exporter
template:
metadata:
labels:
app.kubernetes.io/name: pve-exporter
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: pve-exporter
image: prompve/prometheus-pve-exporter:3.10.0 # {"$imagepolicy": "flux-system:pve-exporter"}
# Image ENTRYPOINT is already `pve_exporter`; pass flags only.
args:
- --config.file=/etc/pve-exporter/pve.yml
- --web.listen-address=0.0.0.0:9221
ports:
- name: http
containerPort: 9221
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 30
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/pve-exporter
readOnly: true
# Python needs a writable temp dir; the rootfs is read-only.
- name: tmp
mountPath: /tmp
volumes:
- name: config
secret:
secretName: pve-exporter-config
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
---
apiVersion: v1
kind: Service
metadata:
name: pve-exporter
namespace: monitoring
labels:
app.kubernetes.io/name: pve-exporter
spec:
selector:
app.kubernetes.io/name: pve-exporter
ports:
- name: http
port: 9221
targetPort: http
@@ -25,8 +25,11 @@ spec:
# replaces the former hostNetwork attachment (see
# network-attachment.yaml) so the NODE no longer holds an address
# on the compromised IoT VLAN; MA gets its own leased IoT IP on
# net1 instead. master: ens19 is the v30iot NIC on every k8s-{1,2,3}/edge
# worker.
# net1 instead. master: ens19 resolves on every k8s-{1,2,3}/edge
# worker, though it is no longer the same kind of device everywhere:
# on the bare-metal control-plane nodes it is a VLAN 30 sub-interface
# of enp2s0f0np0 rather than a NIC, which is why the datapath needs
# bpf.vlanBypass to carry it (issue #592).
k8s.v1.cni.cncf.io/networks: music-assistant-iot
spec:
enableServiceLinks: false
-33
View File
@@ -1,33 +0,0 @@
# PVE node IPs are hardcoded here despite the general "IPs aren't static"
# rule for the homelab: corosync requires stable cluster member identity,
# so jupiter/neptune/saturn keep .201/.202/.203 across reboots and PVE
# upgrades. If a node is permanently renumbered, edit this file.
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: pve-nodes
namespace: apps
labels:
kubernetes.io/service-name: pve
app.kubernetes.io/name: pve
addressType: IPv4
ports:
- name: https
port: 8006
protocol: TCP
endpoints:
- addresses:
- 10.2.40.201
hostname: jupiter
conditions:
ready: true
- addresses:
- 10.2.40.202
hostname: neptune
conditions:
ready: true
- addresses:
- 10.2.40.203
hostname: saturn
conditions:
ready: true
-48
View File
@@ -1,48 +0,0 @@
# PVE (Proxmox cluster UI) on Traefik. Replaced the standard Ingress
# pattern because PVE's backend is per-node HTTPS on :8006 with a
# self-signed cert that has no IP SAN — Traefik's kubernetesIngress
# provider's service.serverstransport annotation doesn't resolve the
# apps/insecure-backend ServersTransport CRD, so TLS verification ran
# and rejected. IngressRoute's native services[].serversTransport field
# binds directly with no provider-qualifier guessing.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: pve-tls
namespace: apps
labels:
app.kubernetes.io/name: pve
spec:
secretName: pve-tls
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- pve.renner.uno
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: pve
namespace: apps
labels:
app.kubernetes.io/name: pve
annotations:
# Traefik does not populate IngressRoute.status.loadBalancer, so
# external-dns-unifi (traefik-proxy source) has no implicit target.
# Pin to the traefik Service VIP (kubernetes/infrastructure/traefik
# service.annotations io.cilium/lb-ipam-ips).
external-dns.alpha.kubernetes.io/target: "10.2.99.86,fd99::86"
spec:
entryPoints:
- websecure
routes:
- match: Host(`pve.renner.uno`)
kind: Rule
services:
- name: pve
port: 8006
scheme: https
serversTransport: insecure-backend
tls:
secretName: pve-tls
-6
View File
@@ -1,6 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- service.yaml
- endpointslice.yaml
- ingressroute.yaml
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: pve
namespace: apps
labels:
app.kubernetes.io/name: pve
spec:
# No selector: endpoints managed manually via the sibling EndpointSlice
# so ingress-nginx fans out across the three Proxmox cluster members.
ports:
- name: https
port: 8006
targetPort: 8006
protocol: TCP
@@ -33,6 +33,24 @@ spec:
remediation:
retries: 3
values:
# Roll the agent DaemonSet when cilium-config changes. Cilium reads
# datapath options once, at agent start, and the chart puts no config
# checksum on the pod template — so a changed ConfigMap leaves every agent
# running the previous datapath until something restarts it. Nothing did.
#
# This is not hypothetical. vlan-bpf-bypass reached cilium-config and every
# v30iot macvlan workload stayed broken until the agents were deleted by
# hand, one node at a time (issue #592). policyMapMax has the same
# exposure, and its comment below says "takes effect on agent restart" —
# documentation standing in for automation.
#
# The trade: any cilium-config change now rolls the CNI cluster-wide,
# node by node. That is disruptive-ish, but it is what a datapath change
# already required — the difference is that it now happens on its own
# instead of being forgotten. Reloader is already deployed for exactly
# this pattern; named per-ConfigMap rather than blanket, as elsewhere.
annotations:
configmap.reloader.stakater.com/reload: cilium-config
# Which pod labels feed security-identity computation. Patterns are
# start-anchored regexes; a leading "!" excludes while keeping the default
# set. Woodpecker stamps every step pod with woodpecker-ci.org/task-uuid
@@ -73,6 +91,36 @@ spec:
# kubernetes/talos/cilium.yaml carries a third copy of the same key.
# Nothing enforces any of this; grep bpf-policy-map-max before changing it.
policyMapMax: 65536
# VLAN ids the eBPF datapath is allowed to pass. Without this it drops
# VLAN-tagged frames, accounted as "VLAN traffic disallowed by VLAN
# filter" in cilium_drop_count_total.
#
# 30 is v30iot, the macvlan parent for the shelly-manager, matter-server,
# otbr and music-assistant attachments. On bare metal it is a VLAN
# sub-interface of enp2s0f0np0; inside the former Proxmox guests it was
# an untagged virtio NIC, which is why this was not needed before.
#
# VLAN 40 (mgmt0) is deliberately NOT listed, and the asymmetry is the
# interesting part. Cilium attaches to devices that carry an address —
# `cilium-dbg status --verbose` lists enp87s0, enp2s0f0np0, enp2s0f1np1
# and mgmt0, but NOT ens19. mgmt0 holds the node IP, so Cilium owns it as
# a device and sees frames the 8021q layer has already untagged; nothing
# for a VLAN filter to match. ens19 is deliberately address-less (see
# talos/patches/k8s-1.yaml — host addresses on that L2 make node replies
# bypass the UDM), so Cilium never attaches to it and VLAN 30 frames only
# meet the filter on the shared parent, still tagged.
#
# So the fix belongs on 30 alone. Listing 40 would change nothing, and
# would touch the datapath carrying etcd and the API for no reason.
#
# A tc-layer drop surfaces to an AF_PACKET sender as ENOBUFS, so the
# symptom is the CNI dhcp plugin reporting "no buffer space available"
# rather than a DHCP timeout. See issue #592.
#
# Same three-copy problem as policyMapMax above: vlan-bpf-bypass in
# kubernetes/talos/cilium.yaml and kubernetes/talos/patches/cilium-cni.yaml
# must move with this list. Takes effect on agent restart.
vlanBypass: [30]
# Let pods reach the cluster's own LoadBalancer external IPs; Cilium
# redirects the socket directly to the backend pod instead of sending
# traffic out to the VIP and back (which fails with L2 announcements).
@@ -150,16 +150,3 @@ spec:
env:
- name: COLLECTIONS
value: "crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
config:
parsers:
s02-enrich:
pve-ui-polling-whitelist.yaml: |
name: krenner/pve-ui-polling-whitelist
description: "Suppress CrowdSec scenarios for the Proxmox UI's legitimate API polling on pve.renner.uno"
filter: "evt.Meta.log_type == 'http_access-log' && evt.Meta.target_fqdn == 'pve.renner.uno'"
whitelist:
reason: "Proxmox web UI polls /api2/json/cluster/* and /api2/json/nodes/<n>/{lxc,qemu}/<id>/status/current every ~2s; http-probing flagged it as scanning and banned the residential IPv6."
expression:
- "evt.Meta.http_path startsWith '/api2/json/cluster/'"
- "evt.Meta.http_path matches '^/api2/json/nodes/[^/]+/(lxc|qemu)/[0-9]+/status/current$'"
@@ -15,7 +15,6 @@ resources:
- netbird-ingress.yaml
- paperless-ngx.yaml
- protonmail-bridge.yaml
- pve-exporter.yaml
- mosquitto.yaml
- redis.yaml
- shelly-manager.yaml
@@ -1,23 +0,0 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: pve-exporter
namespace: flux-system
spec:
image: prompve/prometheus-pve-exporter
interval: 1h
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: pve-exporter
namespace: flux-system
spec:
imageRepositoryRef:
name: pve-exporter
filterTags:
pattern: '^(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
policy:
semver:
range: "3.x"
@@ -7,7 +7,7 @@ spec:
endpointSelector: {}
ingress:
# Intra-namespace — Prometheus ↔ Alertmanager ↔ Grafana ↔ Loki ↔
# alloy ↔ kube-state-metrics ↔ blackbox-exporter ↔ pve-exporter.
# alloy ↔ kube-state-metrics ↔ blackbox-exporter.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
@@ -1,53 +0,0 @@
# OpenBao policy: rotator-proxmox
#
# Class-B rotation for the pve-exporter Proxmox API token. Grants the
# proxmox-secret-rotator CronJob READ on its bootstrap credential (a PRIVILEGED
# Proxmox token allowed to delete/create tokens) and PATCH on the target
# pve-exporter-config path. Per-secret isolation (#213/#202).
#
# NOT applied by Flux. Apply ONCE with an admin token:
#
# bao policy write rotator-proxmox kubernetes/infrastructure/openbao/policies/rotator-proxmox.hcl
# bao write auth/kubernetes/role/rotator-proxmox \
# bound_service_account_names=proxmox-secret-rotator \
# bound_service_account_namespaces=openbao \
# policies=rotator-proxmox ttl=15m
#
# BOOTSTRAP CREDENTIAL (PROVISIONED — recorded here for rebuild/DR).
#
# A SEPARATE least-privilege Proxmox identity, NOT root@pam. PVE has no
# per-user ACL path (/access/users/<id> is rejected — see AccessControl.pm
# check_path), and the token endpoints authorise via a `userid-group` check.
# So the tightest possible scope is a group containing only the target user:
#
# pveum role add SecretRotator -privs "User.Modify"
# pveum user add secret-rotator@pve
# pveum group add secret-rotated
# pveum user modify pve-exporter@pve --groups secret-rotated
# pveum acl modify /access/groups/secret-rotated \
# --users secret-rotator@pve --roles SecretRotator
# pveum user token add secret-rotator@pve rotate --privsep 0
#
# This grants exactly one thing: managing API tokens of users in the
# secret-rotated group. Verified: no VM/storage visibility, and minting a
# root@pam token returns 403.
#
# bao kv put homelab/infrastructure/secret-rotators-bootstrap/proxmox \
# api-url=https://pve.apps.svc.cluster.local:8006 \
# auth='secret-rotator@pve!rotate=<secret>'
#
# api-url MUST be the in-cluster Service (backed by all three PVE nodes).
# The external name pve.renner.uno does NOT work from a pod — pods cannot
# hairpin to their own cluster's ingress LB and the Job hangs.
#
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
# Bootstrap: the privileged Proxmox token + API URL.
path "homelab/data/infrastructure/secret-rotators-bootstrap/proxmox" {
capabilities = ["read"]
}
# Target: the pve-exporter config (holds the pve.yml with the token_value).
path "homelab/data/apps/monitoring/pve-exporter-config" {
capabilities = ["read", "patch"]
}
@@ -52,7 +52,6 @@ resources:
- crowdsec-bouncer-key.yaml
- netbird-ingress.yaml
- cloudflare.yaml
- pve-exporter.yaml
- external-dns.yaml
- flux-git.yaml
- home-assistant.yaml
@@ -1,91 +0,0 @@
---
# Class-B rotation of the pve-exporter Proxmox API token via the secret-rotator
# binary (proxmox-token mode). The RISKIEST rotator: Proxmox has no in-place
# token roll, so the binary DELETEs and recreates pve-exporter@pve!monitoring
# (same id, privsep=0) and patches OpenBao with a rendered pve.yml. That leaves
# a brief window with NO valid token — pve-exporter metrics gap until ESO(10m)+
# Reloader restart. Quarterly, off-hours: acceptable.
#
# privsep=0 makes the recreated token inherit pve-exporter@pve's own perms
# (PVEAuditor ACL is on the USER, not the token — verified), so no ACL re-grant.
# The bootstrap 'auth' is the least-privilege secret-rotator@pve!rotate identity
# (User.Modify scoped to the secret-rotated group), NOT root.
#
# api-url is the in-cluster Service https://pve.apps.svc.cluster.local:8006 (all
# three PVE nodes back it → node-failover). TLS verification is disabled for the
# PVE calls: the nodes present valid Let's Encrypt certs that fail only on
# hostname mismatch (Service DNS vs node FQDN); ureq has no skip-hostname-only
# option, and this matches the previous python's CERT_NONE. Do NOT use
# pve.renner.uno (pod hairpin off the ingress LB) or a single node FQDN (SPOF).
apiVersion: v1
kind: ServiceAccount
metadata:
name: proxmox-secret-rotator
namespace: openbao
labels:
app.kubernetes.io/name: pve-exporter
app.kubernetes.io/component: secret-rotator
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: proxmox-secret-rotate
namespace: openbao
labels:
app.kubernetes.io/name: pve-exporter
app.kubernetes.io/component: secret-rotator
spec:
suspend: false
schedule: "0 4 1 1,4,7,10 *" # quarterly, Europe/Berlin
timeZone: Europe/Berlin
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 5
startingDeadlineSeconds: 3600
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 259200 # 3 d
template:
metadata:
labels:
app.kubernetes.io/name: pve-exporter
app.kubernetes.io/component: secret-rotator
spec:
serviceAccountName: proxmox-secret-rotator
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-07-30
args: ["proxmox-token"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: BAO_ADDR
value: http://openbao.openbao.svc.cluster.local:8200
- name: BAO_ROLE
value: rotator-proxmox
- name: BOOTSTRAP_PATH
value: homelab/infrastructure/secret-rotators-bootstrap/proxmox
- name: BAO_PATH
value: homelab/apps/monitoring/pve-exporter-config
- name: PVE_USER
value: pve-exporter@pve
- name: PVE_TOKENID
value: monitoring
resources:
requests:
cpu: 25m
memory: 16Mi
limits:
memory: 48Mi
+7
View File
@@ -310,6 +310,13 @@ data:
# policy map (per endpoint). Keep in lockstep with patches/cilium-cni.yaml and
# values.bpf.policyMapMax in kubernetes/infrastructure/cilium/helmrelease.yaml.
bpf-policy-map-max: "65536"
# VLAN ids the eBPF datapath may pass; without this it drops VLAN-tagged
# frames. 30 is v30iot, the macvlan parent for the IoT workloads, and a VLAN
# sub-interface of enp2s0f0np0 on bare metal. VLAN 40 (mgmt0) rides the same
# parent but passes without an entry, so it is deliberately not listed.
# Keep in lockstep with patches/cilium-cni.yaml and values.bpf.vlanBypass in
# kubernetes/infrastructure/cilium/helmrelease.yaml. See issue #592.
vlan-bpf-bypass: "30"
# bpf-policy-stats-map-max specifies the maximum number of entries in global
# policy stats map
bpf-policy-stats-map-max: "65536"
+10
View File
@@ -331,6 +331,16 @@ cluster:
# inline manifest, so a lower value here means a cold rebuild comes up with
# a map that overflows until Flux reconciles.
bpf-policy-map-max: "65536"
# VLAN ids the eBPF datapath may pass; without this it drops
# VLAN-tagged frames. 30 is v30iot, the macvlan parent for the IoT
# workloads, and a VLAN sub-interface of enp2s0f0np0 on bare metal.
# VLAN 40 (mgmt0) rides the same parent but passes without an entry,
# so it is deliberately not listed. Keep in lockstep with
# values.bpf.vlanBypass in kubernetes/infrastructure/cilium/helmrelease.yaml
# and the third copy in kubernetes/talos/cilium.yaml — bootstrap installs
# from this inline manifest, so omitting it here breaks the IoT macvlan
# workloads until Flux reconciles. See issue #592.
vlan-bpf-bypass: "30"
# bpf-policy-stats-map-max specifies the maximum number of entries in global
# policy stats map
bpf-policy-stats-map-max: "65536"
+25
View File
@@ -5,6 +5,31 @@ machine:
- k8s-3.srv.renner.uno
- k8s.renner.uno
- 10.2.40.60
# Install from the Image Factory metal installer, not the stock
# ghcr.io/siderolabs/installer that `talosctl gen config` defaults to.
# Extensions live in the installer image, so with the stock one the schematic
# is only ever a property of the boot media and nothing lands on disk —
# `talosctl get extensions` returns empty on a node installed that way.
#
# What the schematic carries, and why each matters here:
#
# siderolabs/intel-ucode CPU microcode. On a VM the hypervisor supplied
# it; on bare metal nothing does except the BIOS.
# siderolabs/thunderbolt Inert until machine.kernel.modules asks for
# thunderbolt/thunderbolt_net. Present so enabling
# the MS-01 mesh later needs neither a new image
# nor a reinstall — which is exactly the property
# lost by installing without it.
#
# a21cec8f… is the bare-metal schematic described in RUNBOOK-cold-rebuild.md:
# same net.ifnames=1 as the Proxmox one (the predictable interface names the
# per-node VLAN parents match against), minus qemu-guest-agent.
#
# Only read at install and upgrade time. Changing it here does nothing to a
# running node until that node is upgraded. Bump the tag with the Talos
# version — it must match the running version, or the upgrade is a downgrade.
install:
image: factory.talos.dev/metal-installer/a21cec8f35ae32ab5e9a452e7b13500ca8776053930b8e182b9cb23eed186b64:v1.12.6
features:
rbac: true
stableHostname: true
-52
View File
@@ -1,52 +0,0 @@
#!/bin/bash
# Script to help with UniFi client migration
#
# NOTE: This script is NO LONGER RECOMMENDED
# Instead, follow the migration guide in MIGRATION_QUICKSTART.md
#
# This script removes unifi_client resources from state, but the recommended
# approach is to let 'tofu apply' handle the removal automatically.
echo "⚠️ WARNING: This script is deprecated"
echo ""
echo "Please use the recommended migration path instead:"
echo " 1. Deploy DNS server: tofu apply -target=module.proxmox_dns"
echo " 2. Configure UniFi DNS to use 10.2.40.53"
echo " 3. Run full apply: tofu apply"
echo ""
echo "See MIGRATION_QUICKSTART.md for details."
echo ""
read -p "Do you still want to remove resources from state? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Cancelled. See MIGRATION_QUICKSTART.md for recommended approach."
exit 0
fi
set -e
echo "Removing unifi_client resources from OpenTofu state..."
echo "Note: VMs will keep their current network configuration"
echo ""
# List of all unifi_client resources to remove
RESOURCES=(
"module.cdns_srv_renner_uno.unifi_client.this[0]"
"module.fortress_renner_uno.unifi_client.this[0]"
"module.grafana_srv_renner_uno.unifi_client.this[0]"
"module.influxdb_srv_renner_uno.unifi_client.this[0]"
"module.kasm_srv_renner_uno.unifi_client.this[0]"
"module.mosquitto_srv_renner_uno.unifi_client.this[0]"
"module.netbox_srv_renner_uno.unifi_client.this[0]"
"module.ntp_srv_renner_uno.unifi_client.this[0]"
"module.paperless_srv_renner_uno.unifi_client.this[0]"
"module.uptime_kuma_srv_renner_uno.unifi_client.this[0]"
)
for resource in "${RESOURCES[@]}"; do
echo "Removing: $resource"
tofu state rm "$resource" || echo " (already removed or not found)"
done
echo ""
echo "Done! Run 'tofu plan' to verify."
-13
View File
@@ -1,13 +0,0 @@
locals {
talos_version = "v1.12.6"
# Image Factory schematic (predictable naming: net.ifnames=1). Keep in
# sync with TALOS_SCHEMATIC in .woodpecker/deploy-infrastructure.yml —
# that pipeline downloads the image, this local names it for VM import,
# and both MUST agree on the filename. The schematic hash is embedded in
# the filename so a schematic bump changes the name, forcing a fresh
# download instead of silently reusing a stale cached image (which would
# reintroduce net.ifnames=0 on the next VM (re)provision).
talos_schematic = "88e3752db032a08082972bd0adf16a80849e3870c3f95cf5b669936c5699e5ad"
talos_filename = "talos-${local.talos_version}-${substr(local.talos_schematic, 0, 12)}-nocloud-amd64.raw"
talos_image = "local:import/${local.talos_filename}"
}
-50
View File
@@ -1,50 +0,0 @@
module "k8s_3" {
source = "./modules/proxmox_vm"
name = "k8s-3.srv.renner.uno"
target_node = "neptune"
vmid = 152
memory = 49152
cores = 8
template_name = null
cloud_init = false
full_clone = false
bios = "ovmf"
machine = "q35"
boot = "order=virtio0"
agent_enabled = true
# Second NIC on v30iot so Cilium L2 announce can win ARP on the IoT
# VLAN from this node (previously only k8s-edge had v30iot, so any
# IoT-pool LB lease landing here was unreachable from devices).
extra_network_bridges = ["v30iot"]
disks = [
{
storage = "local-zfs"
size = "64G"
format = "raw"
slot = "virtio0"
file_id = local.talos_image
}
]
hostpci = [
{
mapping = "osd-ssd"
},
{
mapping = "x710-port1"
}
]
tags = "kubernetes;talos;controlplane"
ssh_public_keys = local.common_ssh_keys
vm_state = "running"
# Rebuild pipeline issues `-replace`; skip graceful shutdown so a
# non-responsive Talos guest can't hang the destroy for 30 minutes.
stop_on_destroy = true
}