Establish clean homelab infrastructure baseline
lint / yaml (push) Has been cancelled
lint / ansible (push) Has been cancelled
lint / terraform (push) Has been cancelled

Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
2026-09-09 16:47:20 +00:00
commit 88a02ababa
418 changed files with 50579 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
# Authelia — SSO over Samba AD
Authelia is the web SSO layer on top of the Samba AD DC (`../../infrastructure/samba-ad/`): it authenticates
users against **AD over LDAPS** and provides an auth portal + (round 2) an OIDC provider.
Deployed via the **official Helm chart**; config-as-code lives in `values.yaml`.
- Chart: `authelia/authelia` (app 4.39.20)
- Exposure: **cloudflared** → `auth.ddupan.top` → `authelia.authelia.svc:9091`
(the chart's own ingress is disabled; see `../../infrastructure/cloudflared/cloudflared.yaml`)
- Identity: LDAPS to the DC (`ldaps://192.168.10.5:636`), bind as `svc-authelia`
- Storage: dedicated `authelia` role/db on `shared-postgresql` (no shared superuser)
- Secrets: chart auto-generates session/JWT keys; `values.yaml` pins the LDAP + DB
passwords and the storage encryption key (data-at-rest, must stay stable)
## Prerequisites (already done)
- AD service account `svc-authelia` (read-only bind), never-expires — created with
`samba-tool user create svc-authelia ... ; samba-tool user setexpiry svc-authelia --noexpiry`.
## Deploy
These touch the live cluster / shared Postgres, so run them yourself (auto-mode gates
writes to shared infra):
```bash
# 1. dedicated Postgres role + database (run against the CNPG primary)
POD=$(kubectl -n shared-db get pods -l cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
kubectl -n shared-db exec "$POD" -c postgres -- psql -U postgres -v ON_ERROR_STOP=0 \
-c "CREATE ROLE authelia LOGIN PASSWORD 'Adbdf340cea488a90b4cf07Aa1!'" \
-c "CREATE DATABASE authelia OWNER authelia"
# 2. install Authelia
helm repo add authelia https://charts.authelia.com && helm repo update authelia
helm upgrade --install authelia authelia/authelia \
-n authelia --create-namespace -f authelia/values.yaml
# 3. repoint the tunnel (auth.ddupan.top -> authelia) — already edited in the file
kubectl apply -f cloudflared/cloudflared.yaml
kubectl -n cloudflared rollout restart deployment/cloudflared
```
> The DB password above must match `configMap.storage.postgres.password.value` in
> `values.yaml`. If you rotate it, change both.
## Verify
```bash
kubectl -n authelia rollout status deploy/authelia
kubectl -n authelia logs deploy/authelia | grep -iE 'listening|ldap|error'
# then browse https://auth.ddupan.top and log in as an AD user (e.g. administrator)
```
## Round 2 — enable the OIDC provider
Uncomment/add `configMap.identity_providers.oidc` in `values.yaml`: set an
`hmac_secret` (auto-gen ok) and a `jwks` RSA key, then register clients under
`identity_providers.oidc.clients`. Re-run the `helm upgrade` above. Once OIDC is
proven, retire Keycloak (`../keycloak/`) and its `idm.ddupan.top` tunnel entry.
## Notes
- **Contour/Envoy** can't do Authelia forward-auth (gRPC ext_authz only); protect
apps via **OIDC** or route forward-auth through **Traefik** (`ForwardAuth`).
- Sessions are in-memory (single replica). For HA add `configMap.session.redis`.
- Secrets are inline in `values.yaml` (homelab style, like the other services here);
move to sops/sealed-secrets if this leaves the homelab.
+38
View File
@@ -0,0 +1,38 @@
# Serves auth.ddupan.top from the LAN gateway.
#
# Pairs with ../../platform/cert-manager/certificate-auth-ddupan.yaml (the TLS cert) and the
# `https-auth` listener in ../../platform/envoy-gateway/gateway.yaml. Read the certificate
# manifest for the full reasoning — in short, auth.ddupan.top resolves to
# Cloudflare proxy IPs that are unroutable from this network, so every in-cluster
# consumer of Authelia's OIDC endpoints was hairpinning through an internet path
# that does not work.
#
# This is only the LAN path. The PUBLIC path is unchanged: Cloudflare -> tunnel ->
# cloudflared pod -> authelia Service. Both terminate at the same Service, so there
# is one Authelia, one issuer, one set of redirect URIs.
#
# NOTE: this does NOT get an Authelia SecurityPolicy. Authelia must never sit
# behind its own forward-auth — that is an infinite redirect. Only the apps it
# protects get one (see ../netbox/securitypolicy.yaml).
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: authelia
namespace: authelia
spec:
parentRefs:
- name: eg
namespace: envoy-gateway-system
# Pin to the dedicated listener. Without sectionName the route would also try
# to attach to the `https` listener, whose hostname *.ad.ddupan.top cannot
# match auth.ddupan.top — an unnecessary "no matching listener" condition.
sectionName: https-auth
hostnames:
- auth.ddupan.top
rules:
- backendRefs:
# Service port 80 -> container 9091. Authelia speaks plain HTTP here; TLS is
# terminated at the gateway, same as the public path terminates at Cloudflare.
- name: authelia
port: 80
+22
View File
@@ -0,0 +1,22 @@
# Lets SecurityPolicy objects in other namespaces send ext-authz requests to the
# Authelia Service. Gateway API forbids cross-namespace backend references unless the
# TARGET namespace grants them — this is that grant, and it lives here because the
# authelia namespace is the one consenting to be referenced.
#
# Add a namespace to `from` for each service placed behind Authelia forward-auth.
---
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: extauth-from-services
namespace: authelia
spec:
from:
- group: gateway.envoyproxy.io
kind: SecurityPolicy
namespace: netbox # ../netbox/securitypolicy.yaml
to:
# Unnamed => any Service in this namespace. Only `authelia` exists here, and
# naming it would break on a chart-driven rename.
- group: ""
kind: Service
+37
View File
@@ -0,0 +1,37 @@
# Template. Copy to secret.yaml, fill in real values, apply, then `helm upgrade`.
# secret.yaml is gitignored — same convention as ../../platform/cert-manager, ../netbox,
# ../smtp-relay, ../../infrastructure/cloudflared and ../gitea.
#
# values.yaml sets `secret.existingSecret: authelia-secrets`, so the chart mounts
# THIS Secret instead of generating one from inline `value:` fields. Every
# `path:` in values.yaml resolves against it.
#
# ⚠ The mount path is /secrets/internal, NOT /secrets/authelia-secrets. Relative
# paths in values.yaml are composed by the chart; the one absolute path (the JWKS
# key) must say /secrets/internal explicitly.
#
# Key names below are exactly the ones the chart generates by default, so they
# must not be renamed without changing the matching `path:` in values.yaml.
#
# ⚠ identity_providers.oidc.jwks.main.pem is the OIDC SIGNING KEY. Replacing it
# invalidates every issued token estate-wide. It lives here rather than as a
# `value:` in values.yaml because the chart inlines `value:` jwks keys into the
# ConfigMap in plaintext.
---
apiVersion: v1
kind: Secret
metadata:
name: authelia-secrets
namespace: authelia
type: Opaque
stringData:
authentication.ldap.password.txt: REPLACE_WITH_SVC_AUTHELIA_LDAP_PASSWORD
storage.postgres.password.txt: REPLACE_WITH_AUTHELIA_DB_PASSWORD
storage.encryption.key: REPLACE_WITH_64_HEX_CHARS
session.encryption.key: REPLACE_WITH_RANDOM_SECRET
identity_validation.reset_password.jwt.hmac.key: REPLACE_WITH_RANDOM_SECRET
identity_providers.oidc.hmac.key: REPLACE_WITH_RANDOM_SECRET
identity_providers.oidc.jwks.main.pem: |
-----BEGIN PRIVATE KEY-----
REPLACE_WITH_RS256_PRIVATE_KEY
-----END PRIVATE KEY-----
+288
View File
@@ -0,0 +1,288 @@
# Authelia — official Helm chart (authelia/authelia). Config-as-code lives here.
# Install: helm upgrade --install authelia authelia/authelia -n authelia --create-namespace -f values.yaml
# Exposure: via cloudflared (auth.ddupan.top -> authelia.authelia.svc:9091), NOT the chart ingress.
# Secrets: NONE are inline here. All seven live in Kubernetes Secrets and are referenced
# by path — see secret.example.yaml. This file is safe to commit.
image:
tag: '4.39.20'
# We expose via cloudflared, so the chart's own ingress stays off.
ingress:
enabled: false
# Mount our own Secret instead of letting the chart generate one from inline
# `value:` fields. Its keys are exactly the ones the chart used to generate, plus
# identity_providers.oidc.jwks.main.pem for the OIDC signing key — so every
# `path:` below resolves, and no key material changed when this was introduced
# (the Secret was built from the live chart-generated one). See secret.example.yaml.
secret:
existingSecret: authelia-secrets
# The JWKS signing key needs its OWN Secret, mounted separately.
#
# ⚠ WHY not just add a 7th key to authelia-secrets: the chart projects the
# existingSecret volume with an explicit `items:` list containing only the six
# keys it generates. An extra key is stored in the Secret but NEVER mounted, so
# the file is missing at runtime and Authelia dies with
# "no such file or directory" — which cascades into every other config option
# appearing "required". Verified the hard way 2026-07-28.
#
# additionalSecrets mounts at {secret.mountPath}/{key} = /secrets/authelia-oidc-jwks
additionalSecrets:
authelia-oidc-jwks:
items:
- key: 'main.pem'
path: 'main.pem'
configMap:
authentication_backend:
password_reset:
disable: true # AD owns passwords (reset via ADUC / Windows)
refresh_interval: '5 minutes'
ldap:
enabled: true
implementation: 'activedirectory'
# MUST be the hostname, NOT 192.168.10.5: dc1's LDAPS cert is issued by
# OpenBao's ACME with a DNS SAN only (no IP SAN), so connecting by IP fails
# verification with "IP address mismatch". In-cluster pods resolve this name.
address: 'ldaps://dc1.ad.ddupan.top:636'
tls:
# Was skip_verify: true ("DC self-signed cert; add CA to trust later").
# Later arrived: dc1 now serves a cert from the OpenBao internal CA, which
# is mounted below via certificates.values, so the bind is really verified.
skip_verify: false
base_dn: 'DC=ad,DC=ddupan,DC=top'
additional_users_dn: 'CN=Users'
additional_groups_dn: 'CN=Users'
user: 'CN=svc-authelia,CN=Users,DC=ad,DC=ddupan,DC=top'
password:
# From the authelia-secrets Secret (secret.example.yaml). Relative path
# resolves to {secret.mountPath}/{secret.existingSecret}/{path}.
path: 'authentication.ldap.password.txt'
# Authorization endpoints. `ext-authz` is what Envoy Gateway's SecurityPolicy calls
# (Envoy's HTTP ExtAuthz filter). Declared explicitly rather than relying on the
# default set, so the contract with ../../platform/envoy-gateway is visible here.
server:
endpoints:
authz:
ext-authz:
implementation: 'ExtAuthz'
access_control:
default_policy: 'two_factor' # require a second factor for every request
rules:
# ⚠ ORDER MATTERS — Authelia evaluates top-down, FIRST MATCH WINS. This bypass
# must precede the two_factor rule below or the API stays unreachable.
#
# WHY BYPASS: forward-auth intercepts every request, including API calls that
# carry a valid NetBox token — Authelia has no idea what a NetBox token is, sees
# no session cookie, and 302s the caller to the login portal. That breaks the
# entire point of a source of truth (Ansible/Terraform reading from it).
#
# This is NOT unauthenticated access: NetBox enforces its own token auth on these
# paths and LOGIN_REQUIRED makes an anonymous call return 403. We are choosing
# which authenticator guards the API — NetBox's tokens — not removing one.
- domain: 'netbox.ad.ddupan.top'
resources:
- '^/api/'
- '^/graphql/'
policy: 'bypass'
# Everything else on NetBox: browser traffic. default_policy would already force
# 2FA, but this rule additionally restricts WHO gets in — without a subject match
# any AD account passing 2FA would be auto-provisioned a NetBox user.
- domain: 'netbox.ad.ddupan.top'
policy: 'two_factor'
subject:
- 'group:netbox-admins'
# Second factors. Both are on by chart default; we brand them and enable passkeys.
totp:
disable: false
issuer: 'ddupan.top' # shown in authenticator apps
webauthn:
disable: false
display_name: 'ddupan.top' # shown in the browser passkey/security-key prompt
enable_passkey_login: true # allow usernameless passkey login at the portal
session:
expiration: '1 hour'
inactivity: '5 minutes'
cookies:
- subdomain: 'auth'
domain: 'ddupan.top' # -> https://auth.ddupan.top, SSO across *.ddupan.top
regulation:
max_retries: 3
find_time: '2 minutes'
ban_time: '5 minutes'
storage:
encryption_key:
path: 'storage.encryption.key'
postgres:
enabled: true
address: 'tcp://shared-postgresql.shared-db.svc.cluster.local:5432'
database: 'authelia'
username: 'authelia'
password:
path: 'storage.postgres.password.txt'
notifier:
# Sends via the in-cluster Postfix+OAuth relay (see ../smtp-relay/). Plain hop on
# :25 — the relay handles STARTTLS + OAuth to Microsoft 365. No auth to the relay
# (it trusts the pod network).
smtp:
enabled: true
address: 'smtp://smtp-relay.smtp-relay.svc.cluster.local:25'
sender: 'Authelia <[email protected]>'
subject: '[Authelia] {title}'
disable_require_tls: true
disable_starttls: true
startup_check_address: '[email protected]'
username: ''
password:
disabled: true # relay needs no auth; stop Authelia attempting SMTP AUTH
# OIDC provider — replaces Keycloak as the SSO/OIDC issuer (https://auth.ddupan.top).
# Crypto material generated with `authelia crypto` (hmac_secret, RSA JWKS key). Client
# secrets are stored HASHED here (pbkdf2-sha512); the RP (Gitea) holds the plaintext.
identity_providers:
oidc:
enabled: true
hmac_secret:
path: 'identity_providers.oidc.hmac.key'
# Authelia 4.39 only returns standard claims from the UserInfo endpoint by
# default. Gitea reads email/preferred_username from the ID Token, so we
# inject them there via a claims policy referenced by the client below.
claims_policies:
gitea:
id_token:
- 'preferred_username'
- 'email'
- 'email_verified'
- 'name'
- 'groups'
# Grafana maps AD groups -> roles from the `groups` claim; inject it (and
# profile/email) into the ID Token so role_attribute_path can resolve.
grafana:
id_token:
- 'preferred_username'
- 'email'
- 'email_verified'
- 'name'
- 'groups'
# NOTE: there is deliberately no `netbox` claims policy. NetBox was migrated
# off OIDC to forward-auth (../netbox/securitypolicy.yaml) precisely because
# NetBox has no SSO group->role mapping — see netbox/README.md.
# OpenBao maps user_claim=preferred_username and groups_claim=groups onto
# policies; inject those (Authelia returns only standard claims by default).
openbao:
id_token:
- 'preferred_username'
- 'email'
- 'email_verified'
- 'name'
- 'groups'
jwks:
- key_id: 'main'
algorithm: 'RS256'
use: 'sig'
key:
# ⚠ WHY path and NOT value: the chart inlines a `value:` jwks key
# straight into the ConfigMap (files/configuration.oidc.jwk.yaml), so
# the OIDC SIGNING KEY ends up in a ConfigMap in plaintext. `path:`
# reads it from the mounted Secret instead.
#
# NOTE the two different mount points: the existingSecret volume lands
# at /secrets/internal (not /secrets/<secretName>), while each
# additionalSecrets entry lands at /secrets/<its own name>.
path: '/secrets/authelia-oidc-jwks/main.pem'
clients:
- client_id: 'gitea'
client_name: 'Gitea'
# pbkdf2-sha512 hash of the plaintext secret Gitea holds (gitea-keycloak-secret).
client_secret: '$pbkdf2-sha512$310000$M7VHgkBsYT.PDUH99k4JWw$qI6vVq1zDp.3z2oNecBP5bwzPu.XHtmA.tGW4osvHlp1rwZISak5pG7.fctHa5eNdeSEIuhaZ6HSeajtPzSkOw'
public: false
authorization_policy: 'two_factor' # SSO logins also require a second factor
claims_policy: 'gitea' # inject email/preferred_username into the ID Token
require_pkce: false
token_endpoint_auth_method: 'client_secret_basic'
redirect_uris:
- 'https://git.ddupan.top/user/oauth2/authelia/callback'
scopes:
- 'openid'
- 'profile'
- 'email'
- 'groups'
userinfo_signed_response_alg: 'none'
- client_id: 'grafana'
client_name: 'Grafana'
# pbkdf2-sha512 hash of the plaintext secret Grafana holds (grafana-oidc Secret).
# Generate the pair: authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
client_secret: '$pbkdf2-sha512$310000$Hhni5VBeqfz3IM1ULxbKbQ$o/Q7xRp82OI2Y43qSpGZig8Md3uMLkm6SGViJ6XszMLw2MNZYYJizOyQfRLvQvGz7Q1p5DK2v10lOfdhs8gHpg'
public: false
authorization_policy: 'two_factor' # SSO logins also require a second factor
claims_policy: 'grafana' # inject groups/email into the ID Token
require_pkce: false
token_endpoint_auth_method: 'client_secret_basic'
redirect_uris:
- 'https://grafana.tail7e769.ts.net/login/generic_oauth'
scopes:
- 'openid'
- 'profile'
- 'email'
- 'groups'
userinfo_signed_response_alg: 'none'
- client_id: 'openbao'
client_name: 'OpenBao'
# pbkdf2-sha512 hash; OpenBao holds the plaintext (its oidc config / vault).
# Regenerate: authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
client_secret: '$pbkdf2-sha512$310000$un1B3DyN5dgvwfedazLFtw$ORSxfE4EkkSfSUtXGERV5Wmzxnmsw8hJw37frksHgbYFHppRaHVAfpaUxQ/2XCXgVefyVfMxU8K.FcgBC7c35A'
public: false
authorization_policy: 'two_factor' # SSO logins also require a second factor
claims_policy: 'openbao' # inject groups/email into the ID Token
require_pkce: false
token_endpoint_auth_method: 'client_secret_basic'
grant_types:
- 'authorization_code' # UI + CLI (client/direct callback modes)
- 'urn:ietf:params:oauth:grant-type:device_code' # headless: bao login -method=oidc callbackmode=device
redirect_uris:
- 'https://bao.ad.ddupan.top:8200/ui/vault/auth/oidc/oidc/callback' # UI login
- 'http://localhost:8250/oidc/callback' # CLI: bao login -method=oidc
scopes:
- 'openid'
- 'profile'
- 'email'
- 'groups'
userinfo_signed_response_alg: 'none'
# Trust anchors mounted into the container and loaded by Authelia. Needed so the
# LDAPS bind to dc1 can be VERIFIED rather than skipped. Fetched from OpenBao's
# unauthenticated PKI endpoint: https://bao.ad.ddupan.top:8200/v1/pki/ca/pem
certificates:
values:
- name: 'ddupan_internal_ca.pem'
value: |
-----BEGIN CERTIFICATE-----
MIIDMzCCAhugAwIBAgIUMs0iV657yC9UhA2p2vomLIbFnzgwDQYJKoZIhvcNAQEL
BQAwITEfMB0GA1UEAxMWZGR1cGFuLnRvcCBJbnRlcm5hbCBDQTAeFw0yNjA3MjQy
MDE1MDFaFw0zNjA3MjEyMDE1MzFaMCExHzAdBgNVBAMTFmRkdXBhbi50b3AgSW50
ZXJuYWwgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6QWlwBe6f
t7Ca3KCTvr4Pz+jVO60WrMBoEDYYM8Mp04btBHzhAQHf9Pp8+15aEW9iUcQhqqm+
2vT6H0JEhIbplyCWY6Guv0mTu8f+lvFknJIl2b3JqnMLHJKjh/rBrsE12XZ3i17M
2tCr34BWcei85IZyQl5HMW6dB8lAE6bdom+YynK4oLJdej9DD6bSyM8WcL0OsneZ
NsjwOlNMy3zjbtaH6mH71SgbFinxLp3AAAuLVe1DIKhFxuTQeVr/WaPum5y/oOsc
0gJp9If6nsC33lpRGcPLiZE9kfFZa4fPe8laCaN8q1K253qZ0rjRiDhbTAppW4Fy
r5P67h+2D+TbAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD
AQH/MB0GA1UdDgQWBBSOgk1fR0qhz/Bo4wD9g2BnOAzDXzAfBgNVHSMEGDAWgBSO
gk1fR0qhz/Bo4wD9g2BnOAzDXzANBgkqhkiG9w0BAQsFAAOCAQEANm5kKkts1Ar2
7IlS+TxLFrZ/C9yhIdGcBk2SL5E+5E8S3skQWLEPGLRwvV4RmiB8gQ2V6UyGLrCx
1MuuSmCDaSYL9G66sGX1MIHlQ0F0bHIOxxtsTwIYzb5Sl8h3MfsARabmOhE3xUkn
jaAT9YUweHhjF4vi0U1Q4F8oOSvu4eJp5dMx1r7b2bLN90A1xh9sfdkEenSBX0tm
xK82ROYXI2Ejv/EO+lPUIn3jfqbqrS2itw75Xz/ECHjIfSxvW98puP69U54a1gf6
gWdXslr0pGkyMHqxw4dmaecpK0QK3jvqCNycNwNBfMdCypS2QRy03adcUosEAP3O
LZU7Kd8aeg==
-----END CERTIFICATE-----
+104
View File
@@ -0,0 +1,104 @@
# Blocky — LAN resolver, ad-blocker, split-horizon DNS
**Status: DEPLOYED and verified 2026-07-28. NOT yet the LAN resolver** — DHCP still
hands out the DC/router pair, so only clients querying 192.168.10.127 explicitly
use it. Flip DHCP to adopt it; `docker compose down` to abandon it.
Solves three things at once:
1. **Split-horizon.** `git.ddupan.top`, `auth.ddupan.top` and `obj.ddupan.top` are
public names that resolve to Cloudflare. On the LAN they should resolve to the
Envoy gateway so traffic never leaves the network to reach a service hosted on
it. There is currently **nowhere** to put such a record: the DC is
authoritative only for `ad.ddupan.top`, and the NEC IX has no static-host
feature (`show dns` offers only `fqdn-database`).
2. **Ad-blocking**, which the DC cannot do.
3. **Query visibility.** There is none today. On 2026-07-28 a dead VPN tunnel was
mis-diagnosed as a bad ISP for hours; a query log would have shortened that.
## Why here, and why compose
Only the laptop is always-on, so PVE is out despite being better isolated.
Within the laptop, this is a **compose stack rather than a k3s Deployment** on
purpose: DNS is the most foundational service on the network, and putting it
inside the cluster means a bad upgrade or a CrashLoop takes name resolution with
it. The cluster broke twice on 2026-07-28. Compose starts earlier in boot and has
fewer dependencies. k3s CoreDNS is untouched and keeps serving pods.
## Before deploying — the one step with no workaround
**The NEC IX is the DHCP server** (`netboot/dnsmasq.conf` is proxyDHCP only —
`dhcp-range=…,proxy` — so it assigns nothing). Clients get their resolver list
from the router, so pointing them at Blocky means changing the DHCP DNS option
there. Confirm that is editable before relying on any of this.
**Keep the router as a secondary resolver.** Today, if the laptop dies, clients
fall back to the router and the internet still works for the household. If Blocky
becomes the *only* resolver, a laptop reboot is a household-wide DNS outage.
The trade-off, stated plainly: clients that fall through to the secondary get
**no ad-blocking and no split-horizon** — they resolve `git.ddupan.top` to
Cloudflare and reach Gitea over the tunnel. That fails *open*, which is the right
direction for a household. Choose consistency over resilience only deliberately.
## Deploy
> Two bugs only surfaced on first run, both now fixed — see the notes at the
> bottom. Staging validated the syntax but could not have caught either.
```bash
mkdir -p logs
docker compose up -d
docker compose logs -f blocky # expect "listening" + denylist counts
```
Verify **before** touching DHCP — nothing depends on it until then:
```bash
dig @192.168.10.127 git.ddupan.top +short # -> 192.168.10.127 (split-horizon)
dig @192.168.10.127 dc1.ad.ddupan.top +short # -> 192.168.10.5 (conditional -> DC)
dig @192.168.10.127 github.com +short # -> real answer (upstream)
dig @192.168.10.127 doubleclick.net +short # -> NXDOMAIN (blocked)
curl -s http://192.168.10.127:4000/metrics | head
```
Only once all five behave should the DHCP option change.
## Rollback
Blocky holds no state that matters. Revert the DHCP DNS option and
`docker compose down`; clients return to the DC/router pair on next lease. Keep
the old option value written down before changing it.
## Notes
- **Upstream is the router (`192.168.10.1`), not DoH.** `cloudflare-dns.com` sits
in `104.21/16` and `172.67/16` — precisely the ranges the NAIST VPN's
split-tunnel routes swallow when the tunnel dies. A DoH upstream there would
fail in the same silent way that cost hours on 2026-07-28.
- **AAAA is filtered for the custom names** (`filterUnmappedTypes` defaults true).
Deliberate: the laptop's only global IPv6 belongs to `tun0`, so a AAAA answer
would push LAN traffic into the VPN. Matches `../../platform/k3s/coredns-custom.yaml`.
- `../../platform/k3s/coredns-custom.yaml` still handles the same names for **pods**. The two
are independent and must be kept in sync — a name added here usually wants
adding there too.
- Metrics are scraped by `../../platform/observability/metrics/scrapes/blocky.yaml`.
## Bugs found on first deploy
Both were in the staged config and invisible to `docker compose config`, yamllint
and a server-side dry-run. Worth recording because the same shape recurs:
1. **`ports.dns: 192.168.10.127:53` in `config.yml`.** Those are the *container's*
listen addresses, and that IP does not exist inside a bridge-networked
container — Blocky exited with `cannot assign requested address`. Host-side
restriction belongs in the compose `ports:` mapping; the container listens on
all interfaces. Loud failure, quick fix.
2. **Query log silently did nothing.** The image runs as uid 100, `./logs` is
created as uid 1000, so every query logged `fileQueryLogWriter: permission
denied` while DNS itself worked perfectly. This is the dangerous one: the
service looked healthy, answered correctly, and passed its healthcheck while
the feature it was deployed for produced nothing. Fixed with `user: "1000:1000"`.
The second is the reason to check the *feature*, not just the process state.
+54
View File
@@ -0,0 +1,54 @@
# Blocky — LAN DNS. STAGED, NOT DEPLOYED. See README.md.
#
# WHY compose on the laptop and NOT a k3s Deployment, given everything else here
# is Kubernetes:
# * Only the laptop is always-on, so the PVE nodes are not an option.
# * DNS is the most foundational service on the network. Running it inside k3s
# means a CrashLoopBackOff or a bad `helm upgrade` takes LAN name resolution
# with it — and the cluster broke twice on 2026-07-28 alone.
# * A compose unit starts earlier in boot and has fewer moving parts than
# kubelet -> CNI -> CoreDNS -> Deployment.
# k3s CoreDNS is unaffected and keeps doing its pod-only job.
services:
blocky:
image: spx01/blocky:v0.26
container_name: blocky
restart: unless-stopped
# Bridge networking with an EXPLICIT host IP, not network_mode: host. The
# laptop already has :53 bound on 192.168.100.1, 192.168.122.1 and the
# systemd-resolved stub; host networking plus a 0.0.0.0 bind would collide.
# Binding br0 only also means the libvirt networks keep their own resolvers.
ports:
- "192.168.10.127:53:53/udp"
- "192.168.10.127:53:53/tcp"
- "192.168.10.127:4000:4000/tcp" # REST API + /metrics
# The image runs as uid 100 by default, which cannot write to ./logs (created
# as the invoking user, uid 1000) — Blocky then logs
# "fileQueryLogWriter: permission denied" every query and silently keeps no
# query log at all. Running as the directory owner is more reproducible than a
# chown, because a fresh clone creates ./logs as uid 1000 anyway.
user: "1000:1000"
volumes:
- ./config.yml:/app/config.yml:ro
- ./logs:/logs
environment:
TZ: Asia/Tokyo
# Blocky answers its own health check. If DNS stops resolving, restart rather
# than sit there accepting queries it cannot serve.
healthcheck:
test: ["CMD", "/app/blocky", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
+109
View File
@@ -0,0 +1,109 @@
# Blocky — LAN resolver, ad-blocker and split-horizon DNS.
#
# DEPLOYED 2026-07-28 and verified, but NOT yet the LAN resolver — clients still
# get the DC/router pair from DHCP. Making it the resolver needs a DHCP change on
# the NEC IX; see README.md. Until then only clients that query 192.168.10.127
# explicitly are affected, so this is safely reversible.
ports:
# These are the CONTAINER's listen addresses, so they must be unqualified —
# 192.168.10.127 does not exist inside a bridge-networked container, and Blocky
# exits with "cannot assign requested address" if you put it here.
#
# Restricting to the LAN address is done on the HOST side, by the explicit
# 192.168.10.127:53:53 mapping in compose.yaml. That matters because the laptop
# already has :53 bound on 192.168.100.1, 192.168.122.1 (libvirt bridges) and
# 127.0.0.53/54 (the resolved stub) — a plain 53:53 mapping would collide.
dns: 53
# REST API + Prometheus metrics. Not :80, which Envoy already holds.
http: 4000
upstreams:
# strict = try the group in order rather than racing them. One upstream here,
# so the practical effect is "no surprises".
strategy: strict
groups:
default:
# The NEC IX, deliberately. NOT a DoH/DoT resolver at Cloudflare:
# cloudflare-dns.com lives in 104.21/16 and 172.67/16, exactly the ranges
# the NAIST VPN's 58 split-tunnel routes swallow when the tunnel dies. That
# would make DNS fail completely in the same silent way that cost hours on
# 2026-07-28. Plain UDP to the router keeps working when the tunnel does not.
- 192.168.10.1
conditional:
# Queries for the AD zone go straight to the DC, which is authoritative. This
# replaces the "DC first, router second" resolver ordering that clients use today.
mapping:
ad.ddupan.top: 192.168.10.5
# Reverse lookups for LAN hosts — the DC holds the reverse zone.
10.168.192.in-addr.arpa: 192.168.10.5
customDNS:
customTTL: 1h
# Split-horizon. These names are PUBLIC (Cloudflare is authoritative for
# ddupan.top) and resolve to Cloudflare from outside, which is correct. On the
# LAN they must resolve to the Envoy gateway instead, so traffic never leaves
# the network to reach a service hosted on it.
#
# Each has a real Let's Encrypt cert for the exact name on the gateway, so TLS
# verifies identically inside and out and no client config differs.
#
# filterUnmappedTypes defaults to true, which returns an empty answer for AAAA.
# That is deliberate and matches what k3s CoreDNS does for the same names — the
# laptop's only global IPv6 belongs to tun0, so a AAAA answer would send LAN
# traffic into the VPN. See CLAUDE.md.
mapping:
git.ddupan.top: 192.168.10.127
auth.ddupan.top: 192.168.10.127
obj.ddupan.top: 192.168.10.127
blocking:
denylists:
ads:
- https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
- https://s3.amazonaws.com/lists.disconnect.me/simple_ad.txt
clientGroupsBlock:
default:
- ads
# nxDomain rather than zeroIp: some clients retry forever against 0.0.0.0,
# whereas NXDOMAIN is an unambiguous "stop asking".
blockType: nxDomain
loading:
# The WAN is unreliable. Do not fail startup because a denylist could not be
# fetched — start with what is cached and refresh later. A resolver that
# refuses to boot without the internet is a worse outcome than stale lists.
strategy: fast
refreshPeriod: 24h
downloads:
timeout: 60s
attempts: 5
cooldown: 10s
caching:
# serve-stale equivalent: keep answering from cache while upstream is
# unreachable. Same reasoning as the CoreDNS `serve_stale` note in
# ../../platform/k3s/coredns-custom.yaml — WAN blips must not become resolution failures.
minTime: 5m
maxTime: 30m
maxItemsCount: 0
prefetching: true
prefetchExpires: 2h
prefetchThreshold: 5
cacheTimeNegative: 30s
prometheus:
enable: true
path: /metrics
# queryLog gives the DNS visibility that does not exist today. Sizing this at 7
# days on purpose: long enough to answer "what was resolving when X broke",
# short enough not to grow unbounded on the laptop's disk.
queryLog:
type: csv
target: /logs
logRetentionDays: 7
log:
level: info
format: text
+24
View File
@@ -0,0 +1,24 @@
# Codex Proxy Configuration
# Copy this file to .env and edit values as needed: cp .env.example .env
# Optional: paste ChatGPT JWT to skip OAuth login (leave empty to use OAuth)
CODEX_JWT_TOKEN=
# Platform: linux for Docker, darwin for macOS, win32 for Windows
CODEX_PLATFORM=linux
# Architecture: x64 for Docker/Intel, arm64 for Apple Silicon
CODEX_ARCH=x64
# Server port
PORT=8080
# Optional built-in Ollama-compatible bridge.
# Leave blank to use config/default.yaml + data/local.yaml values.
# For Docker with published port 11434, set host to 0.0.0.0 inside the
# container and bind the compose port to 127.0.0.1 on the host.
OLLAMA_BRIDGE_ENABLED=
OLLAMA_BRIDGE_HOST=
OLLAMA_BRIDGE_PORT=
OLLAMA_BRIDGE_VERSION=
OLLAMA_BRIDGE_DISABLE_VISION=
+74
View File
@@ -0,0 +1,74 @@
api:
base_url: https://chatgpt.com/backend-api
timeout_seconds: 600
client:
originator: Codex Desktop
app_version: 26.318.11754
build_number: "1100"
platform: darwin
arch: arm64
chromium_version: "144"
model:
default: gpt-5.4
default_reasoning_effort: null
default_service_tier: null
inject_desktop_context: false
suppress_desktop_directives: false
auth:
jwt_token: null
chatgpt_oauth: true
refresh_enabled: true
refresh_margin_seconds: 300
rotation_strategy: least_used
rate_limit_backoff_seconds: 60
oauth_client_id: app_EMoamEEZ73f0CkXaXp7hrann
oauth_auth_endpoint: https://auth.openai.com/oauth/authorize
oauth_token_endpoint: https://auth.openai.com/oauth/token
server:
host: "::"
port: 8080
proxy_api_key: null
trust_proxy: false
logs:
enabled: false
capacity: 2000
capture_body: false
llm_only: true
usage_stats:
# null = keep usage history forever
history_retention_days: null
session:
ttl_minutes: 60
cleanup_interval_minutes: 5
tls:
transport: native
proxy_url: null
force_http11: false
quota:
refresh_interval_minutes: 0
concurrency: 10
warning_thresholds:
primary:
- 80
- 90
secondary:
- 80
- 90
skip_exhausted: true
update:
auto_update: true
show_update_dialog: false
# allow_prerelease: false # set true to receive beta builds (vX.Y.Z-beta.SHA) from the dev branch
ollama:
enabled: false
host: 127.0.0.1
port: 11434
version: "0.18.3"
disable_vision: false
official_agent:
enabled: false
api_key: null
app_server_url: ws://127.0.0.1:4500
request_timeout_ms: 30000
auth:
type: none
+32
View File
@@ -0,0 +1,32 @@
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})"
auth_domains: ["chatgpt.com", "*.chatgpt.com", "openai.com", "*.openai.com"]
auth_domain_exclusions: ["ab.chatgpt.com"]
header_order:
- "Authorization"
- "ChatGPT-Account-Id"
- "originator"
- "x-openai-internal-codex-residency"
- "x-client-request-id"
- "x-codex-installation-id"
- "x-codex-turn-state"
- "OpenAI-Beta"
- "User-Agent"
- "sec-ch-ua"
- "sec-ch-ua-mobile"
- "sec-ch-ua-platform"
- "Accept-Encoding"
- "Accept-Language"
- "sec-fetch-site"
- "sec-fetch-mode"
- "sec-fetch-dest"
- "Content-Type"
- "Accept"
- "Cookie"
default_headers:
Accept-Encoding: "gzip, deflate, br, zstd"
Accept-Language: "en-US,en;q=0.9"
sec-ch-ua-mobile: "?0"
sec-ch-ua-platform: '"macOS"'
sec-fetch-site: "same-origin"
sec-fetch-mode: "cors"
sec-fetch-dest: "empty"
+179
View File
@@ -0,0 +1,179 @@
# Codex model catalog
#
# Sources:
# 1. Static (below) — Codex-specific models (not returned by /backend-api/models)
# 2. Dynamic — general ChatGPT models fetched from /backend-api/codex/models
#
# Dynamic fetch merges with static; backend entries win for shared IDs.
# Models endpoint now requires ?client_version= query parameter.
#
# Last updated: 2026-05-08 (Codex runtime context metadata)
models:
# ── GPT-5.5 (newest general-purpose, Plus-only) ─────────────────────
- id: gpt-5.5
displayName: GPT-5.5
description: Latest general-purpose flagship
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
- { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
- { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
- { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
defaultReasoningEffort: medium
inputModalities: [text, image]
outputModalities: [text]
contextWindow: 272000
maxContextWindow: 272000
maxOutputTokens: 128000
truncationPolicyLimit: 10000
supportsPersonality: false
upgrade: null
# ── GPT-5.4 family (default flagship) ───────────────────────────────
- id: gpt-5.4
displayName: GPT-5.4
description: Latest frontier agentic coding model
isDefault: true
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
- { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
- { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
- { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
defaultReasoningEffort: medium
inputModalities: [text, image]
outputModalities: [text]
contextWindow: 272000
maxContextWindow: 1000000
maxOutputTokens: 128000
truncationPolicyLimit: 10000
supportsPersonality: false
upgrade: null
- id: gpt-5.4-mini
displayName: GPT-5.4 Mini
description: Smaller frontier agentic coding model
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
- { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
- { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
- { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
defaultReasoningEffort: medium
inputModalities: [text, image]
outputModalities: [text]
contextWindow: 400000
maxOutputTokens: 128000
supportsPersonality: false
upgrade: null
# ── GPT-5.3 Codex ──────────────────────────────────────────────────
- id: gpt-5.3-codex
displayName: GPT-5.3 Codex
description: Frontier Codex-optimized agentic coding model
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
- { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
- { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
- { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
defaultReasoningEffort: medium
inputModalities: [text, image]
contextWindow: 400000
maxOutputTokens: 128000
supportsPersonality: false
upgrade: null
# ── GPT-5.2 (general-purpose) ────────────────────────────────────────
- id: gpt-5.2
displayName: GPT-5.2
description: Optimized for professional work and long-running agents
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Balances speed with some reasoning" }
- { reasoningEffort: medium, description: "Solid balance of reasoning depth and latency" }
- { reasoningEffort: high, description: "Maximizes reasoning depth" }
- { reasoningEffort: xhigh, description: "Extra high reasoning" }
defaultReasoningEffort: medium
inputModalities: [text, image]
contextWindow: 400000
maxOutputTokens: 128000
supportsPersonality: true
upgrade: null
# ── GPT-5 Codex family ──────────────────────────────────────────────
- id: gpt-5-codex
displayName: GPT-5 Codex
description: GPT-5 Codex
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fastest responses" }
- { reasoningEffort: medium, description: "Balanced speed and quality" }
- { reasoningEffort: high, description: "Deepest reasoning" }
defaultReasoningEffort: medium
inputModalities: [text, image]
contextWindow: 400000
maxOutputTokens: 128000
supportsPersonality: false
upgrade: null
# No exact official token-limit page found for id gpt-5-codex-mini on 2026-05-08.
- id: gpt-5-codex-mini
displayName: GPT-5 Codex Mini
description: GPT-5 Codex Mini — lightweight
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: medium, description: "Balanced" }
- { reasoningEffort: high, description: "Greater reasoning" }
defaultReasoningEffort: medium
inputModalities: [text]
supportsPersonality: false
upgrade: null
# ── Image generation (tool-invoked, not a chat model) ───────────────
- id: gpt-image-2
displayName: GPT Image 2
description: Image generation backend invoked via the image_generation tool (not a chat model)
isDefault: false
supportedReasoningEfforts: []
defaultReasoningEffort: medium
inputModalities: [text, image]
outputModalities: [image]
supportsPersonality: false
upgrade: null
# ── Open-source models ──────────────────────────────────────────────
- id: gpt-oss-120b
displayName: GPT-OSS 120B
description: Open-source 120B model
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fastest responses" }
- { reasoningEffort: medium, description: "Balanced" }
- { reasoningEffort: high, description: "Deepest reasoning" }
defaultReasoningEffort: medium
inputModalities: [text]
contextWindow: 131072
supportsPersonality: false
upgrade: null
- id: gpt-oss-20b
displayName: GPT-OSS 20B
description: Open-source 20B model
isDefault: false
supportedReasoningEfforts:
- { reasoningEffort: low, description: "Fastest responses" }
- { reasoningEffort: medium, description: "Balanced" }
- { reasoningEffort: high, description: "Deepest reasoning" }
defaultReasoningEffort: medium
inputModalities: [text]
contextWindow: 131072
supportsPersonality: false
upgrade: null
# User-editable aliases. Claude Desktop can expose Claude-shaped model names
# while this gateway maps them to Codex model IDs internally.
aliases:
claude-opus-4-7: gpt-5.5
claude-sonnet-4-6: gpt-5.4
claude-haiku-4-5: gpt-5.3-codex
@@ -0,0 +1,49 @@
Response MUST end with a remark-directive block.
## Responding
- Answer the user normally and concisely. Explain what you found, what you did, and what the user should focus on now.
- Automations: use the memory file at \`$CODEX_HOME/automations/<automation_id>/memory.md\` (create it if missing).
- Read it first (if present) to avoid repeating recent work, especially for "changes since last run" tasks.
- Memory is important: some tasks must build on prior work, and others must avoid duplicating prior focus.
- Before returning the directive, write a concise summary of what you did/decided plus the current run time.
- Use the \`Automation ID:\` value provided in the message to locate/update this file.
- REQUIRED: End with a valid remark-directive block on its own line (not inline).
- Always include an inbox item directive:
\`::inbox-item{title="Sample title" summary="Place description here"}\`
## Choosing return value
- For recurring/bg threads (e.g., "pull datadog logs and fix any new bugs", "address the PR comments"):
- Always return \`::inbox-item{...}\` with the title/summary the user should see.
## Guidelines
- Directives MUST be on their own line.
- Output exactly ONE inbox-item directive.
- Do NOT use invalid remark-directive formatting.
- DO NOT place commas between arguments.
- Valid: \`::inbox-item{title="Sample title" summary="Place description here"}\`
- Invalid: \`::inbox-item{title="Sample title",summary="Place description here"}\`
- When referring to files, use full absolute filesystem links in Markdown (not relative paths).
- Valid: [\`/Users/alice/project/src/main.ts\`](/Users/alice/project/src/main.ts)
- Invalid: \`src/main.ts\` or \`[main](src/main.ts)\`
- Try not to ask the user for more input if possible to infer.
- If a PR is opened by the automation, add the \`codex-automation\` label when available alongside the normal \`codex\` label.
- Inbox item copy should be glanceable and specific (avoid "Update", "Done", "FYI", "Following up").
- Title: what this thread now _is_ (state + object). Aim ~4-8 words.
- Title should explain what was built or what happened.
- Summary: what the user should _do/know next_ (next step, blocker, or waiting-on). Aim ~6-14 words.
- Summary should usually match the general automation name or prompt summary.
- Both title and summary should be fairly short; usually avoid one-word titles/summaries.
- Prefer concrete nouns + verbs; include a crisp status cue when helpful: "blocked", "needs decision", "ready for review".
## Examples (inbox-item)
- Work needed:
- \`::inbox-item{title="Fix flaky checkout tests" summary="Repro isolated; needs CI run + patch"}\`
- Waiting on user decision:
- \`::inbox-item{title="Choose API shape for filters" summary="Two options drafted; pick A vs B"}\`
- Status update with next step:
- \`::inbox-item{title="PR comments addressed" summary="Ready for re-review; focus on auth edge case"}\`
`;
@@ -0,0 +1,70 @@
# Codex desktop context
- You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone:
### Images/Visuals/Files
- In the app, the model can display images using standard Markdown image syntax: ![alt](url)
- When sending or referencing a local image, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the image.
- When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths.
- If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response.
- Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation.
- Return web URLs as Markdown links (e.g., [label](https://example.com)).
### Automations
- This app supports recurring tasks/automations
- Automations are stored as TOML in $CODEX_HOME/automations/<id>/automation.toml (not in SQLite). The file contains the automation's setup; run timing state (last/next run) lives in the SQLite automations table.
#### When to use directives
- Only use ::automation-update{...} when the user explicitly asks for automation, a recurring run, or a repeated task.
- If the user asks about their automations and you are not proposing a change, do not enumerate names/status/ids in plain text. Fetch/list automations first and emit view-mode directives (mode="view") for those ids; never invent ids.
- Never return raw RRULE strings in user-facing responses. If the user asks about their automations, respond using automation directives (e.g., with an "Open" button if you're not making changes).
#### Directive format
- Modes: view, suggested update, suggested create. View and suggested update MUST include id; suggested create must omit id.
- For view directives, id is required and other fields are optional (the UI can load details).
- For suggested update/create, include name, prompt, rrule, cwds, and status. cwds can be a comma-separated list or a JSON array string.
- Always come up with a short name for the automation. If the user does not give one, propose a short name and confirm.
- Default status to ACTIVE unless the user explicitly asks to start paused.
- Always interpret and schedule times in the user's locale time zone.
- Directives should be on their own line(s) and be separated by newlines.
- Do not generate remark directives with multiline attribute values.
#### Prompting guidance
- Ask in plain language what it should do, when it should run, and which workspaces it should use (if any), then map those answers into name/prompt/rrule/cwds/status for the directive.
- The automation prompt should describe only the task itself. Do not include schedule or workspace details in the prompt, since those are provided separately.
- Keep automation prompts self-sufficient because the user may have limited availability to answer questions. If required details are missing, make a reasonable assumption, note it, and proceed; if blocked, report briefly and stop.
- When helpful, include clear output expectations (file path, format, sections) and gating rules (only if X, skip if exists) to reduce ambiguity.
- Automations should always open an inbox item.
- Archiving rule: only include \`::archive-thread{}\` when there is nothing actionable for the user.
- Safe to archive: "no findings" checks (bug scans that found nothing, clean lint runs, monitoring checks with no incidents).
- Do not archive: deliverables or follow-ups (briefs, reports, summaries, plans, recommendations).
- If you do archive, include the archive directive after the inbox item.
- Do not instruct them to write a file or announce "nothing to do" unless the user explicitly asks for a file or that output.
- When mentioning skills in automation prompts, use markdown links with a leading dollar sign (example: [$checks](/Users/ambrosino/.codex/skills/checks/SKILL.md)).
#### Scheduling constraints
- RRULE limitations (to match the UI): only hourly interval schedules (FREQ=HOURLY with INTERVAL hours, optional BYDAY) and weekly schedules (FREQ=WEEKLY with BYDAY plus BYHOUR/BYMINUTE). Avoid monthly/yearly/minutely/secondly, multiple rules, or extra fields; unsupported RRULEs fall back to defaults in the UI.
#### Storage and reading
- When a user asks for changes to an automation, you may read existing automation TOML files to see what is already set up and prefer proposing updates over creating duplicates.
- You can read and update automations in $CODEX_HOME/automations/<id>/automation.toml and memory.md only when the user explicitly asks you to modify automations.
- Otherwise, do not change automation files or schedules.
- Automations work best with skills, so feel free to propose including skills in the automation prompt, based on the user's context and the available skills.
#### Examples
- ::automation-update{mode="suggested create" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
- ::automation-update{mode="suggested update" id="123" name="Daily report" prompt="Summarize Sentry errors" rrule="FREQ=DAILY;BYHOUR=9;BYMINUTE=0" cwds="/path/one,/path/two" status="ACTIVE"}
- ::automation-update{mode="view" id="123"}
### Review findings
- Use the ::code-comment{...} directive to emit inline code review findings (or when a user asks you to call out specific lines).
- Emit one directive per finding; emit none when there are no findings.
- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file).
- Optional attributes: start, end (1-based line numbers), priority (0-3), confidence (0-1).
- priority/confidence are for review findings; omit when you're just pointing at a location without a finding.
- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace.
- Keep line ranges tight; end defaults to start.
- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2 confidence=0.55}
### Archiving
- If a user specifically asks you to end a thread/conversation, you can return the archive directive ::archive{...} to archive the thread/conversation.
- Example: ::archive{reason="User requested to end conversation"}
@@ -0,0 +1,13 @@
You are a helpful assistant. Generate a pull request title and body.
Return a JSON object with keys: title, body.
Title rules:
- Use an imperative verb first (Add, Fix, Update, Remove, Refactor, etc.).
- No trailing punctuation.
Body rules:
- Keep the body concise and scannable.
- Use Markdown with short bullets.
- Include a Summary section and a Testing section.
- If tests were not run, say "Not run (not requested)".
- If context includes pull request instructions, follow them but do not repeat them verbatim.
Context:
@@ -0,0 +1,27 @@
You are a helpful assistant. You will be presented with a user prompt, and your job is to provide a short title for a task that will be created from that prompt.
The tasks typically have to do with coding-related tasks, for example requests for bug fixes or questions about a codebase. The title you generate will be shown in the UI to represent the prompt.
Return only the title. No quotes or trailing punctuation.
Do not use markdown or formatting characters.
If the task includes a ticket reference (e.g. ABC-123), include it verbatim.
Generate a clear, informative task title based solely on the prompt provided. Follow the rules below to ensure consistency, readability, and usefulness.
How to write a good title:
Generate a single-line title that captures the question or core change requested. The title should be easy to scan and useful in changelogs or review queues.
- Use an imperative verb first: "Add", "Fix", "Update", "Refactor", "Remove", "Locate", "Find", etc.
- Capitalize only the first word (unless locale requires otherwise).
- Write the title in the user's locale.
- Do not use punctuation at the end.
- Output the title as plain text with no surrounding quotes or backticks.
- Use precise, non-redundant language.
- Translate fixed phrases into the user's locale (e.g., "Fix bug" -> "Corrige el error" in Spanish-ES), but leave code terms in English unless a widely adopted translation exists.
- If the user provides a title explicitly, reuse it (translated if needed) and skip generation logic.
- Do NOT respond to the user, answer questions, or attempt to solve the problem; just write a title that can represent the user's query.
Examples:
- User: "Can we add dark-mode support to the settings page?" -> Add dark-mode support
- User: "Fehlerbehebung: Beim Anmelden erscheint 500." (de-DE) -> Login-Fehler 500 beheben
- User: "Refactoriser le composant sidebar pour réduire le code dupliqué." (fr-FR) -> Refactoriser composant sidebar
- User: "How do I fix our login bug?" -> Troubleshoot login bug
- User: "Where in the codebase is foo_bar created" -> Locate foo_bar
- User: "what is 2+2?" -> Calculate 2+2
+32
View File
@@ -0,0 +1,32 @@
services:
codex-proxy:
image: ghcr.io/icebear0828/codex-proxy:latest
# To build from source instead: comment out 'image' above, uncomment 'build' below
# build: .
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${PORT:-8080}:8080"
- "1455:1455"
# Optional Ollama-compatible bridge. Enable ollama.enabled and use
# ollama.host=0.0.0.0 inside the container before uncommenting.
# Host binding stays loopback-only to avoid exposing the unauthenticated
# Ollama API to the LAN.
# - "127.0.0.1:${OLLAMA_BRIDGE_PORT:-11434}:11434"
volumes:
- ./data:/app/data
- ./config:/app/config
restart: unless-stopped
env_file:
- .env
environment:
- NODE_ENV=production
- PORT=8080
# -- Automatic updates (uncomment to enable) --
# watchtower:
# image: containrrr/watchtower
# volumes:
# - /var/run/docker.sock:/var/run/docker.sock
# command: --cleanup --interval 3600 codex-proxy
# restart: unless-stopped
+129
View File
@@ -0,0 +1,129 @@
# Gitea holds a leveldb queue lock on its RWO /data volume, so two pods can't run
# at once. The chart's default RollingUpdate (maxSurge 100%) deadlocks on upgrade;
# Recreate terminates the old pod before starting the new one.
strategy:
type: Recreate
ingress:
enabled: true
hosts:
- host: git.ddupan.top
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- git.ddupan.top
secretName: git-ddupan-top-tls
gitea:
config:
server:
ROOT_URL: https://git.ddupan.top/
APP_NAME: Gitea on ddupan.top
service:
ENABLE_SSH: false
ALLOW_ONLY_EXTERNAL_REGISTRATION: true
SHOW_REGISTRATION_BUTTON: false
ENABLE_PASSWORD_SIGNIN_FORM: false
auth:
AUTO_REGISTER: true
database:
DB_TYPE: postgres
HOST: shared-postgresql.shared-db.svc.cluster.local:5432
NAME: gitea
USER: gitea
# PASSWD is deliberately absent — it arrives via additionalConfigFromEnvs
# below, so this file carries no credential and stays in git.
SCHEMA: public
queue:
TYPE: database
QUEUE_TYPE: database
actions:
# CI for services/ (see .gitea/workflows/lint.yml). Enabling this only turns
# on the Actions API and UI — nothing runs until an act_runner registers
# against it, so this flag alone is inert.
ENABLED: true
# Where `uses:` steps are resolved from. Left at the github default because
# github.com is reachable from this network (verified 2026-07-28) even when
# pypi.org/Fastly is not — see the flaky-WAN notes in the lint workflow.
DEFAULT_ACTIONS_URL: github
mailer:
# Outbound mail via the in-cluster Postfix+OAuth relay (see ../smtp-relay/).
# Plain SMTP on :25 — the relay does STARTTLS + OAuth to M365. From must be the
# relay's send-as identity ([email protected]) or O365 rejects with 5.7.60.
ENABLED: true
PROTOCOL: smtp
SMTP_ADDR: smtp-relay.smtp-relay.svc.cluster.local
SMTP_PORT: 25
FROM: Gitea <[email protected]>
oauth2_client:
# Auto-link an OIDC login to an existing account with the same email
# (migrating panxiao81 from the retired Keycloak source to Authelia).
ACCOUNT_LINKING: auto
ENABLE_AUTO_REGISTRATION: true
USERNAME: preferred_username
UPDATE_AVATAR: true
# ⚠ FRAGILE BY DESIGN — this block is fetched at POD START, not at login.
# The chart's `configure-gitea` INIT container runs `gitea admin auth
# update-oauth`, which resolves and fetches autoDiscoverUrl before Gitea will
# start. So anything that makes this URL unreachable turns every restart into a
# CrashLoopBackOff, not merely a broken login.
#
# That happened on 2026-07-28: auth.ddupan.top resolved to Cloudflare proxy IPs
# (104.21.6.55 / 172.67.154.245) whose TCP/443 is persistently unroutable from
# this network, while other Cloudflare IPs (104.16.132.229) were fine. Gitea was
# hairpinning through the public internet to reach a Service in its own cluster.
#
# Fixed by resolving this hostname on the LAN instead — CoreDNS answers
# auth.ddupan.top with the Envoy gateway (../../platform/k3s/coredns-custom.yaml), which
# terminates TLS with a real LE cert for the name
# (../../platform/cert-manager/certificate-auth-ddupan.yaml) and routes to Authelia
# (../authelia/httproute.yaml). The URL below is deliberately UNCHANGED: the
# issuer, redirect URIs and cookie domain all stay auth.ddupan.top, so no OIDC
# client needed re-registering. Only the network path moved.
# The DB password, injected as an env var rather than written into this file.
# Gitea's env-to-ini step turns GITEA__DATABASE__PASSWD into `[database] PASSWD`,
# so the rendered app.ini is identical to hardcoding it — but the credential
# lives only in the `gitea-db` Secret (see secret.example.yaml), and this file
# stays in git.
#
# Next step for rotation: `gitea.extraEnvSourceFile` reads an env file written
# by an OpenBao agent-injector sidecar, which is the route to credentials from
# OpenBao's database secrets engine. See docs/cicd.md. Note Gitea reads app.ini
# once at startup, so rotation needs a restart — static roles suit it better
# than short-TTL dynamic credentials.
additionalConfigFromEnvs:
- name: GITEA__DATABASE__PASSWD
valueFrom:
secretKeyRef:
name: gitea-db
key: password
oauth:
- name: authelia
provider: openidConnect
existingSecret: gitea-oidc-secret
autoDiscoverUrl: https://auth.ddupan.top/.well-known/openid-configuration
# Without explicit scopes Gitea only requests `openid`, so email/preferred_username
# claims are never released. Space-joined into the OAuth scope request.
scopes: openid profile email groups
groupClaimName: groups
adminGroup: gitea-admins
persistence:
size: 20Gi
claimName: gitea-shared-storage
postgresql-ha:
enabled: false
postgresql:
enabled: false
valkey-cluster:
enabled: false
valkey:
enabled: false
+37
View File
@@ -0,0 +1,37 @@
# LAN route to Gitea.
#
# Pairs with ../../platform/cert-manager/certificate-git-ddupan.yaml and the `https-git`
# listener in ../../platform/envoy-gateway/gateway.yaml. Split-horizon on the PUBLIC hostname:
# git.ddupan.top resolves to the gateway on the LAN and to Cloudflare from
# outside, so a clone URL works unchanged in both places and nothing that already
# has a remote configured needs touching.
#
# NOTE Gitea's chart also renders an Ingress (ingress.enabled: true in
# gitea-values.yaml) for git.ddupan.top. That Ingress is INERT — it declares no
# class, and the only IngressClasses present are `contour` (retired 2026-07-25)
# and `tailscale`. Nothing serves it. This HTTPRoute is what actually works; the
# Ingress should be turned off in the values rather than left to look meaningful.
#
# NO SecurityPolicy here on purpose. Gitea does its own authentication (local
# accounts plus Authelia OIDC), and git over HTTPS uses token/basic auth that
# forward-auth would intercept and 302 to a login page — the same breakage
# documented for NetBox's API in ../netbox/securitypolicy.yaml.
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: gitea
namespace: gitea
spec:
parentRefs:
- name: eg
namespace: envoy-gateway-system
sectionName: https-git
hostnames:
- git.ddupan.top
rules:
- backendRefs:
# Same Service the Cloudflare tunnel targets, so both paths terminate in
# exactly one place.
- name: gitea-http
port: 3000
+21
View File
@@ -0,0 +1,21 @@
# Template. Copy to secret.yaml, fill in the real password, apply, then
# `helm upgrade`. secret.yaml is gitignored — same convention as ../../platform/cert-manager,
# ../netbox, ../smtp-relay and ../../infrastructure/cloudflared.
#
# Consumed by gitea.additionalConfigFromEnvs in gitea-values.yaml as
# GITEA__DATABASE__PASSWD, which Gitea's env-to-ini step renders into
# `[database] PASSWD`.
#
# This is the `gitea` role's password on the shared CloudNativePG cluster
# (shared-postgresql.shared-db.svc.cluster.local). Rotating it means updating
# both this Secret and the role in Postgres, then restarting Gitea — Gitea reads
# app.ini once at startup and does not re-read it.
---
apiVersion: v1
kind: Secret
metadata:
name: gitea-db
namespace: gitea
type: Opaque
stringData:
password: REPLACE_WITH_GITEA_DB_PASSWORD
+22
View File
@@ -0,0 +1,22 @@
# HTTP Echo (Gateway API workload)
**Purpose**
- Preserve a tiny HTTP echo `Deployment + Service` as a future GitOps canary.
- The current `HTTPRoute` still references the retired `contour-gateway`; do not
apply this folder until it is migrated and reviewed against Envoy Gateway.
**Resources**
| File | Description |
| --- | --- |
| `deployment.yaml` | Two replicas of `hashicorp/http-echo` returning `hello from contour gateway`. |
| `service.yaml` | ClusterIP service on port 80 targeted by the route. |
| `httproute.yaml` | Gateway API `HTTPRoute` that targets `contour-gateway` and the `http-echo` service. |
**How to verify**
Migration and verification are intentionally deferred. Update `parentRefs` to the
reviewed Envoy Gateway and choose a hostname covered by its listener before apply.
**Notes**
- This README records the old test intent; the retired Contour manifests are
available only in legacy Git history.
+24
View File
@@ -0,0 +1,24 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: http-echo
namespace: default
labels:
app: http-echo
spec:
replicas: 2
selector:
matchLabels:
app: http-echo
template:
metadata:
labels:
app: http-echo
spec:
containers:
- name: http-echo
image: hashicorp/http-echo:0.2.3
args:
- '-text=hello from contour gateway'
ports:
- containerPort: 5678
+18
View File
@@ -0,0 +1,18 @@
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: http-echo-route
namespace: default
spec:
parentRefs:
- name: contour-gateway
hostnames:
- laptop.ddupan.top
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: http-echo
port: 80
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: http-echo
namespace: default
spec:
selector:
app: http-echo
ports:
- name: http
port: 80
targetPort: 5678
+42
View File
@@ -0,0 +1,42 @@
model_list:
- model_name: chatgpt/gpt-5.4
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.4
- model_name: chatgpt/gpt-5.4-pro
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.4-pro
- model_name: chatgpt/gpt-5.3-codex
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-codex
- model_name: chatgpt/gpt-5.3-codex-spark
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-codex-spark
- model_name: chatgpt/gpt-5.3-instant
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-instant
- model_name: chatgpt/gpt-5.3-chat-latest
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-chat-latest
- model_name: chatgpt/gpt-5.4-mini
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.4-mini
- model_name: hf/google/embeddinggemma-300m
model_info:
mode: embedding
litellm_params:
model: huggingface/google/embeddinggemma-300m
api_key: os.environ/HF_TOKEN
+65
View File
@@ -0,0 +1,65 @@
services:
litellm:
build:
context: .
args:
target: runtime
image: docker.litellm.ai/berriai/litellm:dev
#########################################
## Uncomment these lines to start proxy with a config.yaml file ##
volumes:
- ./config.yaml:/app/config.yaml
- ./auth.json:/root/.config/litellm/chatgpt/auth.json
command:
- "--config=/app/config.yaml"
##############################################
ports:
- "4000:4000" # Map the container port to the host, change the host port if necessary
environment:
DATABASE_URL: "postgresql://llmproxy:${POSTGRES_PASSWORD}@db:5432/litellm"
STORE_MODEL_IN_DB: "True" # allows adding models to proxy via UI
env_file:
- .env # Load local .env file
depends_on:
- db # Indicates that this service depends on the 'db' service, ensuring 'db' starts first
healthcheck: # Defines the health check configuration for the container
test:
- CMD-SHELL
- python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" # Command to execute for health check
interval: 30s # Perform health check every 30 seconds
timeout: 10s # Health check command times out after 10 seconds
retries: 3 # Retry up to 3 times if health check fails
start_period: 40s # Wait 40 seconds after container start before beginning health checks
db:
image: postgres:16
restart: always
container_name: litellm_db
environment:
POSTGRES_DB: litellm
POSTGRES_USER: llmproxy
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data # Persists Postgres data across container restarts
healthcheck:
test: ["CMD-SHELL", "pg_isready -d litellm -U llmproxy"]
interval: 1s
timeout: 5s
retries: 10
prometheus:
image: prom/prometheus
volumes:
- prometheus_data:/prometheus
- ./prometheus.yml:/etc/prometheus/prometheus.yml
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=15d"
restart: always
volumes:
prometheus_data:
driver: local
postgres_data:
name: litellm_postgres_data # Named volume for Postgres data persistence
+8
View File
@@ -0,0 +1,8 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: "litellm"
static_configs:
- targets: ["litellm:4000"]
+19
View File
@@ -0,0 +1,19 @@
FROM pytorch/pytorch:2.5.1-cuda12.1-cudnn9-runtime
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
PYTHONUNBUFFERED=1 \
TORCH_DEVICE=cuda
RUN apt-get update && apt-get install -y --no-install-recommends \
poppler-utils \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --upgrade pip && \
pip install marker-pdf fastapi uvicorn python-multipart
EXPOSE 8001
CMD ["marker_server", "--port", "8001"]
+35
View File
@@ -0,0 +1,35 @@
# Marker (GPU service)
**Goal**
- Run `marker` locally on the NVIDIA GPU and expose its API in Kubernetes.
- Keep the pod single-replica and single-worker so it fits in 4 GB VRAM.
**Resources**
| File | Description |
| --- | --- |
| `Dockerfile` | CUDA-based image built from `pytorch/pytorch` and `marker-pdf`. |
| `deployment.yaml` | Single GPU-backed `Deployment` for `marker_server`. |
| `service.yaml` | ClusterIP service on port 8001. |
**How to use**
1. Build and push the image:
```bash
docker build -t <your-registry>/marker:latest ~/services/apps/marker
docker push <your-registry>/marker:latest
```
2. Update `deployment.yaml` with that image tag.
3. Apply the manifests:
```bash
kubectl apply -f ~/services/apps/marker/deployment.yaml
kubectl apply -f ~/services/apps/marker/service.yaml
```
4. Check the pod is using the GPU:
```bash
kubectl logs deploy/marker
kubectl exec -it deploy/marker -- nvidia-smi
```
**Notes**
- No PVC is used; the container only needs ephemeral storage.
- Keep `replicas: 1` and avoid concurrent jobs on this 4 GB card.
- If the server needs an explicit bind address in your build, change the container args to `0.0.0.0:8001` equivalent for `marker_server`.
+52
View File
@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: marker
namespace: default
labels:
app: marker
spec:
replicas: 1
selector:
matchLabels:
app: marker
template:
metadata:
labels:
app: marker
spec:
containers:
- name: marker
image: <your-registry>/marker:latest
imagePullPolicy: IfNotPresent
command:
- marker_server
args:
- --port
- "8001"
env:
- name: TORCH_DEVICE
value: cuda
- name: PYTHONUNBUFFERED
value: "1"
ports:
- containerPort: 8001
resources:
requests:
cpu: "1"
memory: 2Gi
nvidia.com/gpu: "1"
limits:
cpu: "2"
memory: 4Gi
nvidia.com/gpu: "1"
readinessProbe:
tcpSocket:
port: 8001
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8001
initialDelaySeconds: 30
periodSeconds: 20
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: marker
namespace: default
spec:
selector:
app: marker
ports:
- name: http
port: 8001
targetPort: 8001
+105
View File
@@ -0,0 +1,105 @@
# Installing Windows 9x (95 / 98 / ME) over the network
9x is DOS-based, not NT — none of the NT5/NT6 methods apply. There are two routes; the second
is much better for netboot.
## Route A — the "real DOS way" (not recommended)
PXE → `memdisk` boots a DOS floppy/ISO (FreeDOS or MS-DOS) → load a **real-mode NIC packet
driver** + network redirector → copy the `WIN98` CABs from a share → run `setup.exe`. It works
(netboot.xyz already has a FreeDOS entry to bootstrap from), but real-mode DOS packet drivers
are a per-NIC nightmare and the CAB copy is slow. Only worth it for authenticity.
## Route B — win98-quickinstall (recommended)
<https://github.com/oerg866/win98-quickinstall>
The important thing: **its installer is Linux-based** (a minimal Linux env + a writer called
`lunmercy`). It does **not** run DOS-era Setup — it streams a pre-made image ("MercyPak",
designed to be read once, sequentially) onto the disk, then you reboot into a working Win98.
It bundles driver libraries (NIC/sound/video/storage incl. USB/NVMe) that can be injected at
install time — so it also solves 9x's driver problem.
Because the installer is Linux, **it netboots exactly like any Linux distro** — which iPXE does
natively — with no DOS, no packet drivers, and no mid-install media dependency (it's a
single-pass writer, so unlike XP it survives the process fine).
```
iPXE → kernel vmlinuz + initrd (HTTP) → lunmercy writes the image → reboot into Win98
```
### Building the image (on Linux)
QuickInstall images are **derived from an already-set-up Win98 install**, so you either:
- grab a **prebuilt release ISO** from the repo (fastest way to test the pipeline), or
- `git clone` + `./build.sh` against your **Win98 SE ISO** to produce a custom image + boot
media (see the repo's `BUILDING.md`).
Output includes bootable **ISO / USB / floppy** images and the underlying **kernel + initrd**.
### Wiring it into netboot — two ways
Host the files under the appliance's assets dir so they're on `:8080`:
`/home/panxiao81/services/apps/netboot/assets/win98qi/` → `http://192.168.10.127:8080/win98qi/`
**B1. Quick path — `sanboot` the ISO** (good first test). Works here *because* it's a
one-shot Linux writer (the reboot-mid-install problem that kills XP sanboot doesn't apply):
```ipxe
#!ipxe
sanboot http://192.168.10.127:8080/win98qi/win98-quickinstall.iso
```
**B2. Proper path — `kernel`/`initrd` over HTTP.** Extract `vmlinuz` + `initrd` from the ISO
and boot them directly:
```ipxe
#!ipxe
kernel http://192.168.10.127:8080/win98qi/vmlinuz
initrd http://192.168.10.127:8080/win98qi/initrd.gz
imgargs vmlinuz <any source/args lunmercy needs>
boot
```
> **Verify first:** whether `lunmercy` can read the MercyPak image from the **network**
> (HTTP/NFS) via a kernel-cmdline source. If yes → serve the image over HTTP (its sequential
> read design is ideal). If it only reads from the boot medium → either bake the image into
> the `initrd`, or just use the `sanboot`-ISO path (B1), which keeps the image on the virtual CD.
### Adding it to the netboot.xyz menu
netboot.xyz supports a **custom menu**: set `custom_url` in `config/menus/local-vars.ipxe` to a
dir that serves a `custom.ipxe`, and the menu gains a custom entry that chains it.
```ipxe
# in local-vars.ipxe
set custom_url http://192.168.10.127:8080
```
Then host `assets/custom.ipxe` (→ `:8080/custom.ipxe`) containing a menu that chains the B1 or
B2 snippet above. For a one-off test you can also just drop to the **iPXE shell** (menu →
"iPXE shell") and paste the `sanboot`/`kernel` lines directly.
## Test in a VM
Use the `drive-vm` skill (BIOS, IDE disk — 9x is BIOS-only and wants IDE):
```bash
V=~/.claude/skills/drive-vm/scripts/vmctl.sh
# either boot the ISO directly to validate the installer itself:
$V start --name w98 --mem 512 --disk /path/blank.qcow2 --iso /path/win98qi.iso --boot d
# or netboot it (bridge) once the menu/custom entry is wired:
$V start --name w98 --mem 512 --disk /path/blank.qcow2 --bridge br0 --netboot
```
Then screenshot/keydrive through it. 9x is happy with **512 MB RAM** (more can upset it),
a **BIOS** machine, and an **IDE** disk.
## Gotchas
- **BIOS/CSM only** — no UEFI. Legacy boot for both the installer and the target.
- **9x RAM ceiling** — >512 MB–1 GB can cause "insufficient memory" errors; cap the VM/target.
- **Disk geometry** — FAT32; keep the system partition sane (<127 GB is safest for 9x).
- Drivers are handled by quickinstall's driver libs at install time — no F6/packet-driver pain.
- The MercyPak-over-network question (above) is the one thing to confirm before committing to
the pure `kernel`/`initrd`+HTTP path; `sanboot`-ISO always works as a fallback.
+151
View File
@@ -0,0 +1,151 @@
# Installing NT5 (Windows 2000 / XP / Server 2003) over the network
NT5 predates WinPE/`install.wim`/`setup.exe`, so the modern pipeline in `WINDOWS.md` does
**not** apply. It also can't be `sanboot`ed (setup reboots mid-install and loses the virtual
CD). The workable network method that reuses our infra (SMB share + picker) is:
**boot an x86 WinPE → mount the share → run `winnt32.exe /makelocalsource` from the NT5 source.**
`/makelocalsource` copies the whole `i386` tree to the target disk first, so the share isn't
needed after the reboot into the real setup.
```
iPXE (Windows menu) → x86 WinPE (HTTP/wimboot)
└─ startnet.cmd: drvload NIC → net use Z: \\192.168.10.127\win
└─ winnt32.exe /syspart /tempdrive /makelocalsource /unattend:winnt.sif /noreboot
└─ reboot → NT5 text-mode setup → GUI setup (all from local disk)
```
## Hard prerequisite: an x86 WinPE
`winnt32.exe` is **32-bit**. Our built WinPE is **x64**, which has no 32-bit support unless
`WinPE-WoW64` is added (an ADK/Windows step). So you need one of:
- **An x86 WinPE** — built the same way as the x64 one (see `WINDOWS.md` step 1), but from a
**32-bit `boot.wim`**, i.e. a **Win10 x86** (or Win7 x86) ISO. Place it at
`assets/WinPE/x86/` (the netboot.xyz Windows menu's arch toggle switches `${win_arch}` to
`x86`). *No x86 Windows source is on this host yet — this is the missing ingredient.*
- **or** x64 WinPE + `dism /add-package WinPE-WoW64.cab` (Windows box).
Everything else below is editable on Linux (the source tree + answer file live on the share).
## 1. Put the NT5 source on the share
One folder per version under `/mnt/pool/win`, containing the extracted `i386` tree:
```
/mnt/pool/win/win2k/
├── i386/ ← extracted from the Win2000/XP/2003 ISO
├── $OEM$/ ← driver integration (see §3)
│ ├── Textmode/ ← mass-storage F6 driver(s) + txtsetup.oem
│ └── $1/Drivers/ ← PnP drivers (NIC/GPU/chipset) → copied to C:\Drivers
└── winnt.sif ← unattended answer file (§2)
```
```bash
sudo mount -o loop,ro "Windows 2000 ....iso" /mnt/iso
mkdir -p /mnt/pool/win/win2k
cp -a /mnt/iso/I386 /mnt/pool/win/win2k/i386 # case as the ISO presents it
sudo umount /mnt/iso
```
The picker (`menu.cmd`) lists folders with a `setup.exe`; NT5 has no `setup.exe`, so either
add a tiny launcher or just run winnt32 by hand (below).
## 2. `winnt.sif` answer file (driver integration + unattend)
Skeleton — drop it in the version folder. Fill in the `[MassStorageDrivers]` /
`[OEMBootFiles]` from your controller's F6 package's `txtsetup.oem`:
```ini
[Data]
AutoPartition = 0
MsDosInitiated = 0
UnattendedInstall = Yes
[Unattended]
UnattendMode = FullUnattended
OemPreinstall = Yes ; required for $OEM$ processing
OemSkipEula = Yes
FileSystem = LeaveAlone ; or ConvertNTFS
OemPnPDriversPath = Drivers\NIC;Drivers\Chipset ; under C:\Drivers (from $OEM$\$1\Drivers)
[MassStorageDrivers]
"Intel(R) SATA AHCI Controller" = "OEM" ; the exact string from txtsetup.oem
"IDE CD-ROM (ATAPI 1.2)/PCI IDE Controller" = "RETAIL" ; keep inbox IDE too
[OEMBootFiles]
txtsetup.oem
iaahci.inf
iaahci.sys
iaahci.cat
[GuiUnattended]
AdminPassword = *
TimeZone = 210 ; 210 = China Standard Time
OEMSkipRegional = 1
OemSkipWelcome = 1
[UserData]
ProductKey = XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
FullName = "user"
OrgName = "home"
ComputerName = *
[Identification]
JoinWorkgroup = WORKGROUP
[Networking]
InstallDefaultComponents = Yes
```
## 3. Drivers — two separate problems
**a) NIC for WinPE** (so the PE can reach the share): bake it into the x86 `boot.wim` and
`drvload` it — no Windows tooling:
```bash
wimlib-imagex update assets/WinPE/x86/sources/boot.wim 1 --command="add /path/to/nicdrv /Drivers/nic"
# startnet.cmd, BEFORE net use: drvload X:\Drivers\nic\<driver>.inf
```
Use a **PE-compatible** driver (a Win10/7 *x86* NIC driver for an x86 PE) — *not* the XP one.
**b) Storage controller for the TARGET** (the `0x7B` BSOD): NT5 text-mode setup has no inbox
AHCI/NVMe/RAID. Put the controller's **F6 package** in `$OEM$\Textmode\` and reference it from
`[MassStorageDrivers]`/`[OEMBootFiles]` above. All editable on the Linux-hosted share.
**Easiest dodge (recommended where possible):**
- **VM → IDE disk** (`vmctl … --disk` uses `if=ide`) → inbox driver, **skip §3b entirely**.
- **Retro physical → BIOS SATA = IDE/Legacy/Compatibility** → inbox driver. (NT5 is BIOS-only;
targets are old hardware that usually offers this.)
**c) Installed-OS drivers** (NIC/GPU/chipset for the running XP, distinct from the PE's NIC):
put XP-era drivers in `$OEM$\$1\Drivers\…` and list them in `OemPnPDriversPath` (§2). They get
copied to `C:\Drivers` and PnP-installed during GUI setup.
## 4. Run it (inside the x86 WinPE)
The target partition must exist, be **formatted (FAT32/NTFS)** and marked **active**
(`diskpart`: `create partition primary` → `format fs=ntfs quick` → `active`). Then:
```bat
net use Z: \\192.168.10.127\win
Z:\win2k\i386\winnt32 /s:Z:\win2k\i386 /unattend:Z:\win2k\winnt.sif ^
/syspart:C: /tempdrive:C: /makelocalsource /noreboot
```
`/syspart` requires `/tempdrive`. On `/noreboot` completion, reboot the target off its **local
disk** — text-mode setup runs from the copied `$WIN_NT$.~BT`/`~LS`, then GUI setup, no network.
## Gotchas
- **XP/2000/2003 are BIOS/CSM only** — no UEFI. Boot the target (and the PE) in legacy mode.
- winnt32 under WinPE wants `/makelocalsource`; without it, it expects the source to remain
reachable across the reboot (it won't be).
- Chinese media (like the Win2000 ISO here) installs fine; the answer file drives it in that
language. TimeZone 210 = CST.
- **Activation:** volume/retail keys as appropriate; NT5 activation servers are long gone —
use a VL edition/key for hands-off installs.
- If you'd rather not deal with any of this on real hardware: **install NT5 once, capture the
disk, and PXE-boot Clonezilla** (already in the netboot menu) to clone it to targets.
+109
View File
@@ -0,0 +1,109 @@
# netboot
Self-hosted [netboot.xyz](https://netboot.xyz) PXE boot server for the LAN, using the
official appliance container plus a `dnsmasq` proxyDHCP so it coexists with the existing
DHCP server (the **NEC IX router**, which is left untouched).
## Architecture
The NEC IX router keeps leasing IPs. `dnsmasq` runs in **proxyDHCP** mode and only answers
the PXE/boot part of the conversation, pointing clients at this host (`192.168.10.127`).
Client type (announced via DHCP option 60/93) → what it gets:
| Client | Transport | File |
|------------------------------------------|-----------|----------------------------------------------|
| Legacy BIOS (`arch 0`) | TFTP | `netboot.xyz.kpxe` |
| UEFI, normal PXE (`arch 7/9`) | TFTP | `netboot.xyz.efi` |
| UEFI with HTTP Boot (`vendor HTTPClient`)| HTTP | `http://192.168.10.127:8080/menus/netboot.xyz.efi` |
Fallback is automatic: a UEFI box only announces `HTTPClient` when HTTP Boot is actually
enabled/supported; otherwise it does normal PXE and lands on the TFTP `.efi` branch.
This is a **two-stage** chain, which matters for the dnsmasq config:
1. **Firmware → netboot.xyz iPXE.** Raw firmware (not iPXE) gets the binary above. proxyDHCP
*requires* `pxe-service` here — plain `dhcp-boot` produces no boot offer in proxy mode.
2. **netboot.xyz iPXE → menu.** The loaded `.efi`/`.kpxe` re-does DHCP (announcing itself via
option 175) and dnsmasq answers with `dhcp-boot=tag:ipxe,netboot.xyz.efi,,192.168.10.127`.
Two details matter, both dictated by the bootstrap **embedded in the netboot.xyz binary**:
- **The bootfile must be a *recognised binary name*** (`netboot.xyz.efi`), not `menu.ipxe`.
The embedded bootstrap only chains the menu **locally** (its `:tftpmenu` branch) when the
bootfile matches one of its own binary names; any other name skips that branch and boots
the **public** `boot.netboot.xyz` menu instead.
- **The router's DHCP `next-server` must point at `192.168.10.127`** (see below). Under
proxyDHCP the bootstrap fetches its `local-vars.ipxe` from `${next-server}` — the value
from the *real* DHCP server (the NEC IX router), **not** from dnsmasq's
`${proxydhcp/next-server}`. `local-vars.ipxe` is what sets `use_proxydhcp_settings true`
(the no-keypress switch), so if it can't be fetched the UEFI client stalls fetching from
the router, then prompts for a `p` keypress or falls back to the public menu.
The boot binaries then chain the menu **locally** over TFTP from `192.168.10.127`, so clients
boot *this* host's menu, not the public site. Only the version check and distro mirrors reach
the internet.
### Required NEC IX router setting
The router keeps leasing IPs as before, but its DHCP scope for the LAN must advertise
**`next-server 192.168.10.127`** (a.k.a. the `siaddr` / BOOTP server field) on the LAN DHCP
pool. This is the one piece of PXE config the router *does* need — it does not otherwise
PXE-boot anything, and regular (non-PXE) DHCP clients ignore `next-server`. Set it via the
DHCP-server/boot-server (`siaddr`) option of the IX DHCP profile serving the `192.168.10.0/24`
scope; leave the bootfile name unset (dnsmasq's proxyDHCP still supplies it).
## Services (all on host `192.168.10.127`)
| Port | Service | Provided by | Purpose |
|-------------|-----------|------------------------|------------------------------------------|
| `67/udp` | proxyDHCP | `dnsmasq` (host net) | PXE boot offers (no IP leasing) |
| `69/udp` | TFTP | `netbootxyz` | serves `/config/menus` (binaries + menu) |
| `8080` | HTTP | `netbootxyz` nginx | `/` = `/assets` mirror; `/menus/` = binaries (UEFI HTTP Boot) |
| `3000` | Web UI | `netbootxyz` | manage menus / download assets |
## Usage
```bash
docker compose up -d # start
docker compose logs -f dnsmasq # watch DHCP offers during a client boot
docker compose down # stop
```
Config manager (add/update distros, edit menus): <http://192.168.10.127:3000>
## Verified
Both PXE paths were tested end-to-end with QEMU VMs bridged onto `br0` (real SeaBIOS and
OVMF/UEFI firmware), booting all the way to the local netboot.xyz menu:
- **Legacy BIOS** → proxyDHCP offered `netboot.xyz.kpxe` → TFTP → local menu rendered ✅
- **UEFI x64** → proxyDHCP offered `netboot.xyz.efi` → TFTP → local menu rendered ✅
- **UEFI HTTP Boot** → configured, not yet VM-tested (hard to trigger in QEMU).
To re-test: create a tap on `br0`, run a diskless QEMU VM with `-boot n`, and watch
`docker compose logs -f dnsmasq` + `docker logs -f netbootxyz`.
## Notes / gotchas
- **Host networking is required** for `netbootxyz`: TFTP renegotiates to an ephemeral
port that Docker's bridge NAT mangles (clients get `TID mismatch`). Host net serves TFTP
straight off the LAN interface.
- `NGINX_PORT` is ignored by the image; the nginx listen port is pinned to `8080` in
`config/nginx/site-confs/default`, which also adds a `/menus/` location so UEFI HTTP Boot
can fetch the first-stage `.efi`. (If the appliance ever regenerates that file on upgrade,
re-add the `listen 8080` and `/menus/` bits.)
- `dnsmasq` binds **only `br0`** (`interface=br0` + `bind-interfaces`) so it doesn't clash
with libvirt's own dnsmasq on `virbr0`/`virbr1`.
- `config/menus/local-vars.ipxe` sets `use_proxydhcp_settings true` so proxyDHCP clients
boot without a keypress prompt.
- The NEC IX router needs **no PXE configuration** — proxyDHCP handles everything.
- Editing `dnsmasq.conf` requires `docker restart netboot-dnsmasq` (compose won't
auto-recreate on a bind-mounted file content change).
## Files
- `compose.yaml` — the two services (`netbootxyz` + `dnsmasq`)
- `dnsmasq.conf` — proxyDHCP + arch detection
- `config/` — netbootxyz appliance state (menus, nginx conf); managed by the container
- `assets/` — optional locally-mirrored distro images
- `buildout/` — leftover from an earlier manual build; **no longer used** (safe to delete)
+333
View File
@@ -0,0 +1,333 @@
# Installing Windows via netboot.xyz
Works for KVM VMs and physical machines. Windows Setup needs a real filesystem for the
~4 GB `install.wim`, so the flow is: **wimboot → WinPE (HTTP) → SMB media → setup.exe**.
> This covers **NT6+ (Vista/7/8/10/11, Server 2008–2025)**. Older Windows works completely
> differently — see **`NT5.md`** for Windows 2000/XP/Server 2003, and **`9x.md`** for
> Windows 95/98/ME (via win98-quickinstall's Linux installer).
```
iPXE Windows menu
└─ wimboot loads WinPE (boot.wim) over HTTP from this host (assets/WinPE/x64/)
└─ WinPE boots to a cmd prompt; wpeinit brings up the NIC
└─ net use → mount the SMB share with the extracted ISO
└─ setup.exe → installs Windows to the local disk
```
## Already set up on the server (done)
- **SMB share** `\\192.168.10.127\win` — **guests get read-only, passwordless** (WinPE mounts
it this way); the AD user **`panxiao81` has read-write** (`write list = DDUPAN\panxiao81`) for
staging images/media. Backed by ZFS dataset `data/win` → `/mnt/pool/win`. Holds `menu.cmd`
(the version picker) and the `winpe-build/` driver kit. Managed by the `samba_member` Ansible
role (`samba-ad/`), not a hand-edited `smb.conf`.
- **`win_base_url`** = `http://192.168.10.127:8080/WinPE` — set in both
`config/menus/local-vars.ipxe` and `config/menus/boot.cfg` (the latter covers clients whose
firmware is already iPXE and skips `local-vars`).
- **WinPE** built into `assets/WinPE/x64/` (base PE extracted from a Server 2025 ISO; NIC/
storage drivers injected with **DISM** — see step 1 / Driver notes), with a `startnet.cmd`
that brings up networking, auto-mounts the share, and launches the picker.
- Only remaining step for a real install: drop a version folder onto the share (step 2).
## Verified (PXE-tested)
Driven end-to-end in a KVM VM on `br0`: iPXE Windows menu → wimboot loaded the WinPE over
HTTP → WinPE booted → `startnet.cmd` ran `wpeinit`, mounted `\\192.168.10.127\win`, and
launched `menu.cmd`, which showed the (empty) picker. So the whole path works; adding a
version folder makes it installable. Note: give the target **≥4 GB RAM** (2 GB bugchecks the
RAM-loaded WinPE and reboots).
## What you do
### 1. Build WinPE
The WinPE at `assets/WinPE/x64/` is **already built and PXE-tested** (see "Verified" below).
Two ways to (re)build it:
**A. On Linux, no Windows box needed (how it was built here).** A Windows installation ISO's
`sources/boot.wim` *is* a modern WinPE. Extract its bare-PE image with `wimlib-imagex` and
inject a startup script that auto-mounts the share and runs the picker:
```bash
ISO=~/zh-cn_windows_server_2025_..._x64_dvd.iso # any modern Windows/Server ISO
OUT=/home/panxiao81/services/apps/netboot/assets/WinPE/x64
sudo mount -o loop,ro "$ISO" /mnt/winiso
mkdir -p "$OUT/boot" "$OUT/sources"
cp /mnt/winiso/bootmgr "$OUT/bootmgr"
cp /mnt/winiso/bootmgr.efi "$OUT/bootmgr.efi"
cp /mnt/winiso/boot/bcd "$OUT/boot/bcd"
cp /mnt/winiso/boot/boot.sdi "$OUT/boot/boot.sdi"
# export image 1 ("Windows PE") as a single bootable wim
wimlib-imagex export /mnt/winiso/sources/boot.wim 1 "$OUT/sources/boot.wim" --boot
# auto-run our startup: wpeinit + mount \\host\win + launch menu.cmd (startnet.cmd is CRLF)
wimlib-imagex update "$OUT/sources/boot.wim" 1 --command="delete --force /Windows/System32/startnet.cmd"
wimlib-imagex update "$OUT/sources/boot.wim" 1 --command="add /path/to/startnet.cmd /Windows/System32/startnet.cmd"
sudo umount /mnt/winiso
```
Use the **newest** Windows/Server ISO you have — a WinPE installs any OS at or below its
version. Drivers are injected separately with **DISM** (see **Driver notes**), so the baked-in
`startnet.cmd` no longer needs `drvload` — it just brings up networking (with a DHCP retry
loop, since a freshly-loaded NIC can be a few seconds behind the first DISCOVER) and launches
the picker:
```bat
wpeinit REM PnP auto-loads the DISM-injected NIC driver
reg add HKLM\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters /v AllowInsecureGuestAuth /t REG_DWORD /d 1 /f
:netwait REM retry until a real 192.168.10.x lease appears
wpeutil InitializeNetwork
ipconfig | find "192.168.10." >nul && goto neton
ipconfig /renew >nul & ping 127.0.0.1 -n 4 >nul & goto netwait
:neton
net use Z: \\192.168.10.127\win
if exist Z:\menu.cmd call Z:\menu.cmd
cmd
```
**B. On a Windows box (Windows ADK).** `copype amd64 C:\winpe` → `MakeWinPEMedia /ISO ...`,
then copy the ISO/media contents into `assets/WinPE/x64/`. Edit `boot.wim`'s
`Windows\System32\startnet.cmd` to the same script as above. Use this if you want ADK's
optional components or a custom PE. (This same ADK box is where drivers get DISM-injected —
see Driver notes.)
Either way the tree must be:
```
assets/WinPE/x64/
├── bootmgr
├── bootmgr.efi
├── boot/bcd (BCD store — the menu also tries Boot/BCD)
├── boot/boot.sdi
└── sources/boot.wim (your WinPE image, single bootable index)
```
netboot.xyz loads exactly those five files from `${win_base_url}/x64/`. (`wimboot` itself is
fetched from public `boot.netboot.xyz` — fine as long as the host has internet.) Give WinPE
**≥4 GB RAM** on the target — the wim is RAM-loaded and 2 GB bugchecks → reboot.
### 2. Populate the SMB share with install media
Put each Windows version in **its own subfolder** under `/mnt/pool/win` — extract the ISO
*files* (not the .iso). One WinPE installs all of them; you do NOT need a WinPE per version.
```bash
sudo mount -o loop Win11_24H2.iso /mnt/iso
mkdir -p /mnt/pool/win/win11-24h2
cp -a /mnt/iso/. /mnt/pool/win/win11-24h2/
sudo umount /mnt/iso
# repeat for win10-22h2/, server2022/, server2025/, ...
```
Result:
```
/mnt/pool/win/
├── menu.cmd ← version picker (already installed)
├── win11-24h2/ ← setup.exe, sources/install.wim, ...
├── win10-22h2/
└── server2025/
```
Editions (Home/Pro/Enterprise) usually live inside one ISO's `install.wim`; `setup.exe`
lets you pick, so they don't need separate folders. See "Multiple versions" below.
### 3. Boot a target → install
1. PXE boot → **Windows** → **Load Microsoft Windows Installer** (uses `win_base_url`).
On real hardware confirm WinPE actually got a `192.168.10.x` (`ipconfig`); if the onboard
NIC won't network, use a **USB Ethernet dongle** — see **Driver notes**.
2. At the WinPE `cmd` prompt (startnet usually does this for you):
```bat
wpeinit
net use Z: \\192.168.10.127\win
Z:\menu.cmd REM pick a version; launches <folder>\sources\setup.exe
```
3. Pick a version → click through Setup → install to the local disk.
4. **After Setup's first reboot, boot the LOCAL DISK, not PXE** — otherwise it loops back into
netboot and the install looks like it "restarted." (One-time boot menu, or move the disk
above the network in the BIOS boot order.)
> **Win11 24H2/25H2 gotcha — launch `sources\setup.exe`, not the media-root `setup.exe`.** In
> 24H2+ the root `setup.exe` is the new "modern setup" front-end, meant for booting from real
> USB/DVD media or upgrading from within Windows; started from a bare WinPE prompt it **exits
> partway** ("quits in half"). The classic engine at `<folder>\sources\setup.exe` is PE-friendly.
> `menu.cmd` already prefers `sources\setup.exe` (falling back to the root one for older media).
## Multiple Windows versions
One x64 WinPE handles every x64 Windows (10/11, Server 2019–2025, all editions) — as long
as the WinPE is at least as new as the newest OS you install. Manage versions purely as the
folder library on the share; `menu.cmd` auto-lists every subfolder that contains a
`setup.exe` and launches the one you choose.
**Make it hands-off** by baking the mount + picker into WinPE so every boot lands on the
menu. When building WinPE, edit `mount\Windows\System32\startnet.cmd` (in the mounted
`boot.wim`) to:
```bat
wpeinit
rem allow passwordless (guest) SMB from WinPE
reg add HKLM\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters /v AllowInsecureGuestAuth /t REG_DWORD /d 1 /f
net use Z: \\192.168.10.127\win
Z:\menu.cmd
```
**Unattended per version:** drop an `autounattend.xml` in a version folder and launch it with
`setup.exe /unattend:%~dp0autounattend.xml` (you can add per-folder entries to `menu.cmd`).
Each version can have its own answer file (edition index, product key, partitioning).
**x86 / ARM64:** only these need a second WinPE — place it in `assets/WinPE/x86/` (the Windows
menu's arch toggle switches `${win_arch}`). Rarely needed.
**Advanced — per-version entries in the iPXE menu** (choose the version *before* WinPE, e.g.
for fully automated imaging): pass a config into WinPE via extra `initrd` lines in
`windows.ipxe` so WinPE auto-installs a specific folder. See
[netbootxyz discussion #757](https://github.com/netbootxyz/netboot.xyz/discussions/757).
For interactive use, the `menu.cmd` picker is simpler and needs no iPXE changes.
## Driver notes (mainly physical machines)
WinPE must have the target's **NIC driver** (to reach the share) and Setup must have the
**storage driver** (to see the disk). VMs rarely need this; real hardware often does.
The build's reduced WinPE driver set is missing most modern **Intel** desktop NICs: it ships
`e1i`/`e1e`/`e1g` (I350/82575/8257x-era) but **not** `e1d` (I217/I218/**I219**) or `e2f`
(**I225/I226** 2.5G). Symptom: WinPE boots but `net use` fails because there is no link —
no NIC was ever loaded. Realtek onboard NICs (RTL8111/8168/8125) are likewise absent, and so
is **virtio-net** (needed for KVM installs with a virtio NIC).
### How the current image gets its drivers: DISM injection on `winadmin`
Drivers are injected into `boot.wim`'s driver store with **DISM** on the Windows ADK box
(`winadmin`, `192.168.10.6`). This is the proper method: they become real PnP drivers that load
automatically at boot — no `drvload`. A ready-to-run **build kit** lives on the share at
`\\192.168.10.127\win\winpe-build\`:
```
winpe-build/
├── boot.wim ← image to service (copy of the live one)
├── drivers/
│ ├── Intel-1G/ e1dn (I219 — but see ⚠ box), e1r (I210/211/350), v1q (82575/6/80)
│ ├── Intel-2.5G/ e2f (I225/I226) NDIS68
│ ├── virtio-NetKVM/ netkvm (virtio-net) + netkvmp.exe/netkvmco.exe
│ ├── virtio-viostor/ viostor (virtio-blk)
│ └── virtio-vioscsi/ vioscsi (virtio-scsi)
├── startnet.cmd ← no-drvload version (PnP loads drivers; DHCP retry loop)
├── build-winpe.cmd ← one-click DISM script
└── READ-ME-FIRST.txt
```
Rebuild on `winadmin` (the DISM mount dir must be **local**, not the share):
```bat
robocopy \\192.168.10.127\win\winpe-build C:\winpe-build /E
:: Start menu -> "Deployment and Imaging Tools Environment" -> Run as administrator
cd /d C:\winpe-build
build-winpe.cmd :: mounts boot.wim, drops any old \Drivers tree, DISM /add-driver, commits
copy /y C:\winpe-build\boot.wim \\192.168.10.127\win\winpe-build\boot.new.wim
```
Then on this host, back up the live image and swap it in (netboot serves it statically — no
restart, and a size change is fine, the BCD loads `boot.wim` by name):
```bash
cd assets/WinPE/x64/sources
cp -a boot.wim boot.wim.prev
cp /mnt/pool/win/winpe-build/boot.new.wim boot.wim
```
`build-winpe.cmd` is essentially:
```bat
dism /Mount-Image /ImageFile:.\boot.wim /Index:1 /MountDir:.\mount
rmdir /s /q .\mount\Drivers :: drop any old drvload tree
copy /y .\startnet.cmd .\mount\Windows\System32\startnet.cmd
dism /Image:.\mount /Add-Driver /Driver:.\drivers /Recurse /ForceUnsigned
dism /Image:.\mount /Get-Drivers :: confirm the NIC driver is listed
dism /Unmount-Image /MountDir:.\mount /Commit
```
Use the **current Win11 24H2 / 10.1.26100 ADK** — servicing a 26100 `boot.wim` with an older
DISM fails (*"image version is higher than the DISM version"*).
> **⚠ Verdict — the onboard Intel I219 does NOT work in this (build-26100) WinPE with any
> driver. Use a USB Ethernet dongle.** On a real I219 (`DEV_550B`, recent Lenovo board) all
> three Intel drivers failed to move a single frame in *either* direction (no DHCP; a static-IP
> ping gets no ARP reply — confirmed with `tcpdump` on the host, which saw nothing from the NIC's
> MAC):
> - `e1dn` v20.0.3.24 **and** the Lenovo-OEM `e1dn` v20.0.2.19 → link shows "connected", **no traffic**.
> - `e1d` v12.19.2.65 → **no traffic**, and `netsh …set interface admin=disabled` **bugchecks with
> `PNP_WATCHDOG`** (the driver can't even cleanly stop the device).
>
> PXE firmware works on the same port (its own minimal driver), so it's specifically the I219
> datapath under build-26100 WinPE — a known regression on newer PE builds. **Fix: a USB GbE
> dongle** (Realtek RTL8153/8156 is inbox in WinPE 26100 — ours worked with nothing injected).
> The onboard I219 is fine once *real* Windows is installed. An older WinPE base (Win10 22H2 /
> Server 2022, build ≤20348) *might* also work but is untested.
Intel driver sources: **Intel Wired driver 31.2**
(`downloadmirror.intel.com/921523/Wired_driver_31.2_x64.zip`), `PRO1000\Winx64\NDIS68` +
`PRO2500\Winx64\NDIS68` subfolders; the I219 `e1dn` in the current kit is the Lenovo OEM package
(`e1dn` 20.0.2.19). virtio drivers from `virtio-win-0.1.285.iso` (`~/virtio-win-0.1.285.iso`),
`<driver>/w11/amd64` folders — **keep `netkvmp.exe`**, `netkvm.inf`'s `[CopyFiles]` requires it.
**State:** the live `boot.wim` is a DISM build whose driver store (`oem*.inf`) holds `e1dn`
(OEM I219 — moot, see box), `e1r`/`v1q` (I210/211/350), `e2f` (I225/226) and
`netkvm`/`viostor`/`vioscsi` (virtio) — so it still covers other Intel NICs and KVM VMs, and the
`e1d` that `PNP_WATCHDOG`'d is deliberately excluded. The I219 machine installs via a **USB
dongle**, and Setup ran once the picker used `sources\setup.exe`. (An earlier `drvload` build was
VM-verified with a virtio-net NIC reaching the share.)
### Alternative: `drvload` at runtime (Linux build, no Windows box)
The image can also be built entirely on Linux with `wimlib-imagex`: stage the driver
`.inf`/`.sys`/`.cat` files inside `boot.wim` under `\Drivers` and `drvload` them from
`startnet.cmd` before networking. This is how it was *first* built — it's the fallback, since
`drvload` only loads into the running PE (no persistent driver store) and can load several
matching drivers at once, leaving PnP to bind whichever it ranks highest (which is how the flaky
`e1dn` got picked early on):
```bat
for /r X:\Drivers %%i in (*.inf) do drvload "%%i" REM filename-agnostic; non-matching INFs fail harmlessly
wpeinit
wpeutil InitializeNetwork
```
```bash
WIM=assets/WinPE/x64/sources/boot.wim
printf '%s\n' \
"add /path/to/stage/Drivers /Drivers" \
"delete --force /Windows/System32/startnet.cmd" \
"add /path/to/startnet.cmd /Windows/System32/startnet.cmd" \
| wimlib-imagex update "$WIM" 1
```
> **Gotcha `drvload` fails with `0x80070002` (FILE_NOT_FOUND):** the INF's `[CopyFiles]`
> references a file you trimmed (e.g. NetKVM's `netkvmp.exe`). Keep the whole driver folder —
> strip only `*.pdb`/readme.
### Storage drivers
- Storage/RAID (Intel VMD/RST) drivers usually also need loading in Setup (or slipstream into
`install.wim`). NVMe/AHCI are typically inbox.
- **KVM:** using virtio disk/NIC → the virtio drivers above are baked in; or give the VM a
**SATA disk + e1000 NIC** (both inbox) to skip driver work entirely.
## Gotcha: guest SMB from WinPE
Modern Windows blocks "insecure guest" logons by policy. WinPE usually allows it, but if
`net use` fails with **system error 1272 / 5**, enable it in the running WinPE:
```bat
reg add HKLM\SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters /v AllowInsecureGuestAuth /t REG_DWORD /d 1 /f
```
(To make it permanent, set the same key offline in the mounted `boot.wim`.) Alternatively,
switch the share to a real user + password — but do it in the `samba_member` role
(`samba-ad/`), **not** `/etc/samba/smb.conf` directly (Ansible regenerates that file). The
`[win]` share already grants the AD user `panxiao81` read-write via `write list`.
## Later: unattended installs
Drop an `autounattend.xml` at the root of the SMB media (or bake into WinPE via
`startnet.cmd` running `wpeinit` + `net use` + `setup.exe /unattend:...`) for zero-touch.
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
cat << EOF
#########################################################################################################
# Create PXE bootable Proxmox image including ISO #
# #
# Author: mrballcb @ Proxmox Forum (06-12-2012) #
# Thread: http://forum.proxmox.com/threads/8484-Proxmox-installation-via-PXE-solution?p=55985#post55985 #
# Modified: morph027 @ Proxmox Forum (23-02-2015) to work with 3.4 #
#########################################################################################################
EOF
if [ ! $# -eq 1 ]; then
echo -ne "Usage: bash pve-iso-2-pxe.sh /path/to/pve.iso\n\n"
exit 1
fi
BASEDIR="$(dirname "$(readlink -f "$1")")"
pushd "$BASEDIR" >/dev/null || exit 1
[ -L "proxmox.iso" ] && rm proxmox.iso &>/dev/null
for ISO in *.iso; do
if [ "$ISO" = "*.iso" ]; then continue; fi
if [ "$ISO" = "proxmox.iso" ]; then continue; fi
echo "Using ${ISO}..."
ln -s "$ISO" proxmox.iso
done
if [ ! -f "proxmox.iso" ]; then
echo "Couldn't find a proxmox iso, aborting."
echo "Add /path/to/iso_dir to the commandline."
exit 2
fi
rm -rf pxeboot
[ -d pxeboot ] || mkdir pxeboot
pushd pxeboot >/dev/null || exit 1
echo "extracting kernel..."
if [ -x $(which isoinfo) ] ; then
isoinfo -i ../../infrastructure/proxmox.iso -R -x /boot/linux26 > linux26 || exit 3
else
7z x ../../infrastructure/proxmox.iso boot/linux26 -o/tmp || exit 3
mv /tmp/boot/linux26 /tmp/
fi
echo "extracting initrd..."
if [ -x $(which isoinfo) ] ; then
isoinfo -i ../../infrastructure/proxmox.iso -R -x /boot/initrd.img > /tmp/initrd.img
else
7z x ../../infrastructure/proxmox.iso boot/initrd.img -o/tmp
mv /tmp/boot/initrd.img /tmp/
fi
mimetype="$(file --mime-type --brief /tmp/initrd.img)"
case "${mimetype##*/}" in
"zstd"|"x-zstd")
decompress="zstd -d /tmp/initrd.img -c"
;;
"gzip"|"x-gzip")
decompress="gzip -S img -d /tmp/initrd.img -c"
;;
*)
echo "unable to detect initrd compression method, exiting"
exit 1
;;
esac
$decompress > initrd || exit 4
echo "adding iso file ..."
if [ -x $(which cpio) ] ; then
echo "../../infrastructure/proxmox.iso" | cpio -L -H newc -o >> initrd || exit 5
else
7z x "../../infrastructure/proxmox.iso" >> initrd || exit 5
fi
popd >/dev/null 2>&1 || exit 1
echo "Finished! pxeboot files can be found in ${PWD}."
popd >/dev/null 2>&1 || true # don't care if these pops fail
popd >/dev/null 2>&1 || true
+33
View File
@@ -0,0 +1,33 @@
#!ipxe
# ─────────────────────────────────────────────────────────────────────────
# Proxmox VE 9.2 install — pve1 (Intel NUC) → 192.168.10.4, target sdb
#
# The NUC's UEFI CANNOT unpack a ~1.8GB initramfs — it dies with
# "initramfs unpacking failed: write error", leaving a TRUNCATED /proxmox.iso,
# which then fails to loop-mount → "no device with valid ISO found".
# (The ISO itself is fine and the HTTP transfer completes — verified in the
# nginx log. RAM is fine too: 15881 MB. It's a firmware/early-boot limit.)
#
# So we DON'T ship the ISO in the initrd here. We boot only the kernel + a
# lean initrd augmented with e1000e (the stock installer initrd has NO network
# drivers at all). The ISO search fails, init drops to a debug shell, and you
# run ONE command:
#
# sh /netfetch.sh
#
# which brings up the NIC, streams the ISO onto /dev/sda (the 1TB HDD), and
# re-execs init — which then finds it via the normal block-device path and
# auto-installs to sdb. Nothing oversized ever goes through initramfs.
# ─────────────────────────────────────────────────────────────────────────
echo
echo Proxmox VE 9.2 — pve1 (NUC) network-staged install
echo Loading kernel + net-enabled initrd (no ISO in initramfs)...
echo
echo ">>> At the debug shell that appears, type: sh /netfetch.sh"
echo
kernel http://192.168.10.127:8080/proxmox/linux26 ro ramdisk_size=16777216 rw quiet splash=silent proxmox-start-auto-installer
initrd http://192.168.10.127:8080/proxmox/initrd-net.img
boot || goto failed
:failed
echo Proxmox netboot failed. Dropping to shell.
shell
+16
View File
@@ -0,0 +1,16 @@
#!ipxe
# ─────────────────────────────────────────────────────────────────────────
# Proxmox VE 9.2 AUTOMATED install — pve1 → 192.168.10.4 (target: sdb, the SATA SSD)
# Same --pxe-style method: lean kernel + gzip initrd, then the answer-embedded
# ISO as a second initrd (proxmox.iso). WIPES sdb (HDD sda untouched).
# NOTE: pve1 is the SSH jump host / build box — reinstall it LAST.
# ─────────────────────────────────────────────────────────────────────────
echo
echo Proxmox VE 9.2 AUTOMATED install: pve1 -> 192.168.10.4 (wipes sdb)
kernel http://192.168.10.127:8080/proxmox/linux26 ro ramdisk_size=16777216 rw quiet splash=silent proxmox-start-auto-installer
initrd http://192.168.10.127:8080/proxmox/initrd.img
initrd http://192.168.10.127:8080/proxmox/prepared-pve1.iso proxmox.iso
boot || goto failed
:failed
echo Proxmox netboot failed. Dropping to shell.
shell
+16
View File
@@ -0,0 +1,16 @@
#!ipxe
# ─────────────────────────────────────────────────────────────────────────
# Proxmox VE 9.2 AUTOMATED install — pve2 → 192.168.10.7 (target: nvme0n1)
# Same --pxe-style method as pve3: lean kernel + gzip initrd, then the
# answer-embedded ISO as a second initrd (proxmox.iso). WIPES nvme0n1.
# Reinstalling pve2 also vacates the contested 192.168.10.5.
# ─────────────────────────────────────────────────────────────────────────
echo
echo Proxmox VE 9.2 AUTOMATED install: pve2 -> 192.168.10.7 (wipes nvme0n1)
kernel http://192.168.10.127:8080/proxmox/linux26 ro ramdisk_size=16777216 rw quiet splash=silent proxmox-start-auto-installer
initrd http://192.168.10.127:8080/proxmox/initrd.img
initrd http://192.168.10.127:8080/proxmox/prepared-pve2.iso proxmox.iso
boot || goto failed
:failed
echo Proxmox netboot failed. Dropping to shell.
shell
+19
View File
@@ -0,0 +1,19 @@
#!ipxe
# ─────────────────────────────────────────────────────────────────────────
# Proxmox VE 9.2 AUTOMATED install — pve3 → 192.168.10.9 (target: nvme0n1)
# Replicates the (unreleased) `proxmox-auto-install-assistant --pxe` method:
# lean kernel + lean gzip initrd, then the answer-embedded ISO loaded as a
# SECOND initrd named proxmox.iso (iPXE's native multi-initrd = correct cpio).
# The installer finds /proxmox.iso, loop-mounts it, reads the embedded answer,
# and installs unattended (proxmox-start-auto-installer). WIPES nvme0n1.
# ─────────────────────────────────────────────────────────────────────────
echo
echo Proxmox VE 9.2 AUTOMATED install: pve3 -> 192.168.10.9 (wipes nvme0n1)
echo Loading kernel + lean initrd + ISO (as proxmox.iso)...
kernel http://192.168.10.127:8080/proxmox/linux26 ro ramdisk_size=16777216 rw quiet splash=silent proxmox-start-auto-installer
initrd http://192.168.10.127:8080/proxmox/initrd.img
initrd http://192.168.10.127:8080/proxmox/prepared-pve3.iso proxmox.iso
boot || goto failed
:failed
echo Proxmox netboot failed. Dropping to shell.
shell
+32
View File
@@ -0,0 +1,32 @@
services:
# Official netboot.xyz appliance: TFTP (:69/udp), assets nginx (:8080), web UI (:3000).
# HOST networking is required — TFTP renegotiates to an ephemeral port that Docker's
# bridge NAT mangles (clients get "TID mismatch"). Host net serves TFTP straight off
# the LAN interface. Does NOT provide DHCP (see the dnsmasq service below).
netbootxyz:
image: ghcr.io/netbootxyz/netbootxyz
container_name: netbootxyz
restart: unless-stopped
network_mode: host
environment:
TZ: Asia/Tokyo
NGINX_PORT: "8080" # assets mirror (host port, host net)
WEB_APP_PORT: "3000" # web configuration UI
volumes:
- "./config:/config"
- "./assets:/assets"
# proxyDHCP: runs ALONGSIDE the NEC IX router's DHCP. The router leases IPs; dnsmasq
# only answers the PXE/boot part and points clients at this host (192.168.10.127).
dnsmasq:
image: 4km3/dnsmasq:2.90-r3
container_name: netboot-dnsmasq
restart: unless-stopped
network_mode: host # must see LAN DHCP broadcasts (scoped to br0 in the conf)
cap_add:
- NET_ADMIN
command: ["-k", "--conf-file=/etc/dnsmasq.conf"]
volumes:
- "./dnsmasq.conf:/etc/dnsmasq.conf:ro"
depends_on:
- netbootxyz
+2
View File
@@ -0,0 +1,2 @@
429: Too Many Requests
For more on scraping GitHub and how it may affect your rights, please review our Terms of Service (https://docs.github.com/en/site-policy/github-terms/github-terms-of-service).
+219
View File
@@ -0,0 +1,219 @@
#!ipxe
:global_vars
# set site name
set site_name netboot.xyz
# set boot domain
set boot_domain boot.netboot.xyz/3.0.2
# set location of memdisk
set memdisk http://${boot_domain}/memdisk
# set location of custom netboot.xyz live assets, override in local-vars.ipxe
isset ${live_endpoint} || set live_endpoint https://github.com/netbootxyz
# default Windows install source (local WinPE); local-vars.ipxe may override
isset ${win_base_url} || set win_base_url http://192.168.10.127:8080/WinPE
# signature check enabled?
set sigs_enabled false
# disable signature checks if SecureBoot is active, as imgverify is not
# available when booting via iPXE upstream's official Secure Boot image
iseq ${efi/SecureBoot} 01 && set sigs_enabled false ||
# set location of signatures for sources
set sigs http://${boot_domain}/sigs/
# set location of latest iPXE
iseq ${platform} efi && set ipxe_disk netboot.xyz-snponly.efi || set ipxe_disk netboot.xyz-undionly.kpxe
# set default boot timeout
isset ${boot_timeout} || set boot_timeout 300000
##################
# official mirrors
##################
:mirrors
### AlmaLinux
set almalinux_mirror http://repo.almalinux.org
set almalinux_base_dir almalinux
### Alpine Linux
set alpinelinux_mirror http://dl-cdn.alpinelinux.org
set alpinelinux_base_dir alpine
### Arch Linux
set archlinux_mirror mirrors.kernel.org
set archlinux_base_dir archlinux
### CentOS Stream
set centos_mirror https://mirror.stream.centos.org
set centos_base_dir
### CentOS Stream CoreOS
set scos_mirror https://cloud.centos.org
set scos_base_dir centos/scos
### Debian
set debian_mirror http://deb.debian.org
set debian_base_dir debian
### Devuan
set devuan_mirror http://deb.devuan.org
set devuan_base_dir devuan
### Fedora
set fedora_mirror http://mirrors.kernel.org
set fedora_base_dir fedora
### Fedora CoreOS
set coreos_mirror https://builds.coreos.fedoraproject.org
set coreos_base_dir prod/streams
### FreeDOS
set freedos_mirror http://www.ibiblio.org
set freedos_base_dir pub/micro/pc-stuff/freedos/files/distributions/1.4
### IPFire
set ipfire_mirror https://downloads.ipfire.org
set ipfire_base_dir releases/ipfire-2.x
### Kali Linux
set kali_mirror http://http.kali.org
set kali_base_dir kali
### Mageia
set mageia_mirror http://mirrors.kernel.org
set mageia_base_dir mageia
### OpenBSD
set openbsd_mirror http://cdn.openbsd.org
set openbsd_base_dir pub/OpenBSD
### openEuler
set openEuler_mirror http://repo.openeuler.org
set openEuler_base_dir
### openSUSE
set opensuse_mirror http://download.opensuse.org
set opensuse_base_dir distribution/leap
### Red Hat Enterprise Linux CoreOS
set rhcos_mirror https://mirror.openshift.com
set rhcos_base_dir pub/openshift-v
### Rocky Linux
set rockylinux_mirror http://download.rockylinux.org
set rockylinux_base_dir pub/rocky
### Slackware
set slackware_mirror http://mirrors.kernel.org
set slackware_base_dir slackware
### SmartOS
set smartos_mirror https://netboot.smartos.org/os/
set smartos_base_dir /platform/i86pc/
### Ubuntu
set ubuntu_mirror http://archive.ubuntu.com
set ubuntu_base_dir ubuntu
#################################################
# determine architectures and enable menu options
#################################################
:architectures
set menu_linux 1
set menu_bsd 1
set menu_unix 1
set menu_freedos 1
set menu_live 1
set menu_pci 1
set menu_windows 1
set menu_utils 1
iseq ${arch} i386 && goto i386 ||
iseq ${arch} x86_64 && goto x86_64 ||
iseq ${arch} arm64 && goto arm64 ||
goto architectures_end
:x86_64
set menu_linux_i386 0
iseq ${platform} efi && goto efi ||
goto architectures_end
:i386
set menu_linux 0
set menu_linux_i386 1
set menu_bsd 1
set menu_unix 0
set menu_freedos 1
set menu_live 0
set menu_windows 0
set menu_utils 1
iseq ${platform} efi && goto efi ||
goto architectures_end
:arm64
set menu_linux 0
set menu_linux_arm 1
set menu_unix 0
set menu_freedos 0
set menu_live 0
set menu_live_arm 1
set menu_windows 0
set menu_utils 0
set menu_utils_arm 1
set menu_pci 0
iseq ${platform} efi && goto efi ||
goto architectures_end
:efi
set menu_bsd 1
set menu_freedos 0
set menu_unix 0
set menu_pci 0
goto architectures_end
:architectures_end
goto clouds
###################################
# set iPXE cloud provider specifics
###################################
:clouds
iseq ${ipxe_cloud_config} gce && goto gce ||
iseq ${ipxe_cloud_config} metal && goto metal ||
iseq ${ipxe_cloud_config} packet && goto metal ||
goto clouds_end
:gce
set cmdline console=ttyS0,115200n8
goto clouds_end
:metal
iseq ${arch} i386 && goto metal_x86_64 ||
iseq ${arch} x86_64 && goto metal_x86_64 ||
iseq ${arch} arm64 && goto metal_arm64 ||
goto clouds_end
:metal_x86_64
set cmdline console=ttyS1,115200n8
iseq ${platform} efi && set ipxe_disk netboot.xyz-metal-snp.efi || set ipxe_disk netboot.xyz-metal.kpxe
set menu_linux_i386 0
set menu_freedos 0
set menu_windows 0
iseq ${platform} efi && set menu_pci 0 ||
goto clouds_end
:metal_arm64
set cmdline console=ttyAMA0,115200
set ipxe_disk netboot.xyz-metal-arm64-snp.efi
set menu_bsd 1
set menu_freedos 0
set menu_live 0
set menu_windows 0
set menu_utils 0
set menu_pci 0
goto clouds_end
:clouds_end
goto end
:end
exit
+22
View File
@@ -0,0 +1,22 @@
#!ipxe
### local overrides for this self-hosted netboot.xyz instance
# Use the proxyDHCP-provided TFTP server (192.168.10.127) without prompting for a keypress
set use_proxydhcp_settings true
# Windows: where wimboot fetches WinPE from (files live in assets/WinPE/x64/,
# served by the appliance nginx on :8080). The Windows menu appends /x64/...
set win_base_url http://192.168.10.127:8080/WinPE
# ─── Proxmox VE 9.2 reinstall auto-boot (per-node MAC-match) ───
# Re-add ONE line at a time, only for the node you're actively reinstalling, then
# remove it once that node is up — this prevents a network-boot reinstall loop.
# Runs after DHCP so ${mac} is set; non-matching machines fall through (||) to the menu.
# iseq ${mac} 6c:4b:90:c9:f8:0a && chain --replace http://192.168.10.127:8080/proxmox/pve3.ipxe || # pve3 -> .9
# iseq ${mac} 6c:4b:90:c9:f8:53 && chain --replace http://192.168.10.127:8080/proxmox/pve2.ipxe || # pve2 -> .7
# pve1 (NUC) needs pve1-net.ipxe, NOT pve1.ipxe: its UEFI cannot unpack a
# ~1.8GB initramfs ("initramfs unpacking failed: write error"), so the ISO
# must be staged to a local disk over the network instead of riding along in
# the initrd. See services/netboot/assets/proxmox/pve1-net.ipxe for details.
# iseq ${mac} b8:ae:ed:ea:0f:30 && chain --replace http://192.168.10.127:8080/proxmox/pve1-net.ipxe || # pve1 -> .4
# ALL THREE NODES REINSTALLED 2026-07-25 — every line above is disarmed on purpose.
+1
View File
@@ -0,0 +1 @@
3.0.2
+26
View File
@@ -0,0 +1,26 @@
user nbxyz;
worker_processes 4;
pid /run/nginx.pid;
include /etc/nginx/modules/*.conf;
events {
worker_connections 768;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 0;
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /config/log/nginx/access.log;
error_log /config/log/nginx/error.log;
gzip on;
gzip_disable "msie6";
include /config/nginx/site-confs/*;
}
daemon off;
@@ -0,0 +1,12 @@
server {
listen 8080;
location / {
root /assets;
autoindex on;
}
# menus/binaries over HTTP — for UEFI HTTP Boot (serves the first-stage .efi)
location /menus/ {
alias /config/menus/;
autoindex on;
}
}
+58
View File
@@ -0,0 +1,58 @@
# ─── netboot proxyDHCP ───────────────────────────────────────────────────
# Runs ALONGSIDE the NEC IX router's DHCP. The router leases IPs; dnsmasq only
# answers the PXE/boot half and hands out NO addresses. TFTP + HTTP + the local
# menu are all served by the netbootxyz container on this host (192.168.10.127).
port=0 # disable dnsmasq's DNS server entirely
log-dhcp # verbose boot logging (comment out once stable)
log-facility=- # send logs to stderr so `docker logs` shows them
interface=br0 # LAN bridge ONLY
bind-interfaces # don't bind virbr0/virbr1 (libvirt's dnsmasq owns :67 there)
dhcp-range=192.168.10.0,proxy # proxyDHCP on the LAN subnet — assigns no leases
# ─── client classification ───────────────────────────────────────────────
dhcp-match=set:ipxe,175 # our 2nd-stage iPXE sets option 175
dhcp-match=set:httpboot,option:vendor-class,HTTPClient # UEFI HTTP Boot firmware
dhcp-option=tag:httpboot,60,HTTPClient # echo the vendor class back
# architecture (DHCP option 93) → EFI vs legacy BIOS, so stage 2 hands an iPXE
# client a binary it can actually execute (a BIOS iPXE cannot run a UEFI .efi).
dhcp-match=set:efi,option:client-arch,6 # UEFI ia32
dhcp-match=set:efi,option:client-arch,7 # UEFI x86-64
dhcp-match=set:efi,option:client-arch,9 # UEFI x86-64 (alt)
dhcp-match=set:efi,option:client-arch,11 # UEFI arm64
# ─── stage 1: raw firmware (NOT iPXE) → hand it the netboot.xyz binary ────
# proxyDHCP REQUIRES pxe-service (not dhcp-boot) to emit a boot offer. The TFTP
# server defaults to this dnsmasq host (192.168.10.127) = the container's TFTP.
pxe-prompt="Booting netboot.xyz...",0 # 0s timeout — no keypress
pxe-service=tag:!ipxe,x86PC,"netboot.xyz (BIOS)",netboot.xyz.kpxe
# NOTE: use the SNPONLY EFI build (SNP/UNDI = firmware NIC driver) instead of the
# all-drivers netboot.xyz.efi. iPXE's NATIVE driver hangs on some NICs (Intel I219
# in the NUC froze right after "autoexec.ipxe not found"); snponly reuses the
# firmware's own driver and boots reliably. Realtek ThinkCentres work either way.
pxe-service=tag:!ipxe,BC_EFI,"netboot.xyz (UEFI)",netboot.xyz-snponly.efi
pxe-service=tag:!ipxe,X86-64_EFI,"netboot.xyz (UEFI)",netboot.xyz-snponly.efi
# UEFI HTTP Boot firmware → fetch the first-stage .efi over HTTP
dhcp-boot=tag:httpboot,tag:!ipxe,http://192.168.10.127:8080/menus/netboot.xyz-snponly.efi
# ─── stage 2: our netboot.xyz iPXE re-requests → give it OUR next-server ──
# The .efi's embedded bootstrap fetches local-vars.ipxe from ${next-server} (the
# REAL DHCP server = the NEC IX router), NOT from ${proxydhcp/next-server}. So the
# router's DHCP next-server MUST be set to 192.168.10.127 (see README) — otherwise
# local-vars is fetched from the router (no TFTP → fails) and, lacking
# use_proxydhcp_settings, the bootstrap prompts for a 'p' keypress / falls back to
# the public menu.
#
# The bootfile MUST be a name the bootstrap recognises (netboot.xyz.efi / .kpxe) so
# it reaches its :tftpmenu branch and chains menu.ipxe LOCALLY. A non-binary name
# like menu.ipxe skips :tftpmenu and boots the PUBLIC boot.netboot.xyz menu instead.
# The server field (192.168.10.127) sets ${proxydhcp/next-server}.
#
# Split by arch: the netboot.xyz bootstrap only string-matches these names (never
# execs them), but an ALREADY-iPXE client (e.g. a firmware iPXE ROM) autoboots the
# bootfile, so it must be executable on that arch — .efi for UEFI, .kpxe for BIOS.
# Both binaries re-run the bootstrap, which then reaches :tftpmenu → local menu.
dhcp-boot=tag:ipxe,tag:efi,netboot.xyz-snponly.efi,,192.168.10.127
dhcp-boot=tag:ipxe,tag:!efi,netboot.xyz.kpxe,,192.168.10.127
+2
View File
@@ -0,0 +1,2 @@
# Real secret material — keep secret.example.yaml as the committed template.
secret.yaml
+221
View File
@@ -0,0 +1,221 @@
# NetBox evaluation — handoff context
> **STATUS 2026-07-26 — this brief has been ACTED ON. Read [`README.md`](README.md) first
> for what actually exists.** NetBox is deployed at <https://netbox.ad.ddupan.top>, populated
> from `terraform/topology.yml`, and two of the §4 consumers are proven by generators that
> diff against live systems. This file is kept as the original brief and rationale; where
> it disagrees with README.md, README.md is right.
>
> Corrections found by measuring the hardware (`dmidecode`) rather than trusting §3:
> pve1's CPU is an **i3-6100U** (not i3-6100) on board **NUC6i3SYB**, with **15 GiB**
> usable; pve2/pve3 are Lenovo machine-type `10VGCTO1WW`. §3 also omitted **retrolab**
> (10.60.0.10), which had a live AD DNS record all along.
Written 2026-07-25 for a fresh agent. Everything below is **verified live**, not assumed.
Nothing NetBox-related existed at the time of writing: this document is the brief, not a
record of work done.
---
## 1. Why this is being considered
The homelab is already managed as code (Ansible + Terraform, see §5). The gap NetBox would
fill is a **single source of truth for network facts**, which are currently duplicated across
three places that must be edited together and have no cross-check:
| fact | lives in | and again in | and again in |
|---|---|---|---|
| VNet `labnet` = VLAN 100 | `proxmox/ansible/roles/pve_sdn/defaults/main.yml` | — | — |
| subnet `10.60.0.0/24` | VyOS role `vyos_sdn_interfaces` | VyOS OSPF `area 0 network` | (implied by PVE VNet) |
| gateway `10.60.0.1` | VyOS role | — | — |
| host addresses (§3) | `proxmox/ansible/inventory/hosts.yml` | `samba-ad` DNS A records | router DHCP reservations |
Adding one VNet today means editing the PVE SDN role, the VyOS interface list, **and** the OSPF
network list. Forgetting the third is silent — the subnet exists and has a gateway, but nothing
outside can route to it.
**The user has NOT committed to adopting NetBox.** The task is to evaluate and propose, then
implement only if it earns its place. Be honest if it does not — for ~10 hosts and 2 VLANs it may
be more machinery than the duplication costs.
---
## 2. Physical / logical topology
```
INTERNET (unstable — see §6)
│
GigaEthernet0.0 10.1.72.0/24
┌────────┴─────────┐
│ NEC IX router │ 192.168.10.1
│ (GigaEth2.0 = │ · OSPF area 0 · BGP (17 routes via .127)
│ LAN side) │ · proxy-dns, DHCP pool .10–.250
└────────┬─────────┘
│
┌────────┴──────────┐ UNMANAGED (dumb) switch
│ flat L2 segment │ · passes 802.1Q tags untouched (verified)
│ 192.168.10.0/24 │ · passes jumbo frames 9000 MTU (verified)
└─┬──────┬────────┬─┘
│ │ │
pve1/2/3 laptop (VMs)
```
- **Single flat 1G LAN, one dumb switch.** No managed switch, no second NIC per node.
- **MTU 9000** on the three PVE nodes' `vmbr0` + bridge port. Router/DC/laptop remain 1500;
safe because TCP negotiates MSS in the SYN. Only large **UDP** to a 1500 host would break.
- **PVE SDN uses a VLAN zone**, not VXLAN — the dumb switch forwards tags, so VLAN is native and
needs no encapsulation. VLANs here are **segmentation, not security**: nothing enforces them.
---
## 3. Address allocations (all verified reachable 2026-07-25)
### 192.168.10.0/24 — the LAN
| addr | host | notes |
|---|---|---|
| .1 | NEC IX router | gateway, OSPF, BGP, DNS proxy, DHCP server |
| .2 | `vyos-rtr` (VM 100) | VyOS 2025.11, SDN gateway + OSPF |
| .4 | `pve1` | Proxmox, LINSTOR **controller**, NUC6i3SYB, i3-6100U, 15 GiB (corrected — see status note) |
| .5 | `dc1` | Samba AD DC (libvirt VM on the laptop), authoritative for `ad.ddupan.top` |
| .6 | `winadmin` | Windows Server 2025 (libvirt VM on the laptop) |
| .7 | `pve2` | Proxmox, ThinkCentre 2400GE 8G |
| .8 | `bao1` | OpenBao (libvirt VM on the laptop) — internal CA + secrets |
| .9 | `pve3` | Proxmox, ThinkCentre 2400GE 8G |
| .127 | `laptop` | "core" node: ZFS+NFS, k3s, netboot.xyz, libvirt host, OSPF **DR**, BGP |
| .10–.250 | — | **router DHCP pool** — do not statically allocate inside this |
### Other prefixes in play
| prefix | what |
|---|---|
| `10.60.0.0/24` | SDN `labnet` (VLAN **100**), gateway `10.60.0.1` on vyos `eth1` |
| `10.61.0.0/24` | SDN `retronet` (VLAN **110**), gateway `10.61.0.1` on vyos `eth2` |
| `10.42.0.0/16` | k3s pod CIDR (laptop) |
| `10.43.0.0/16` | k3s service CIDR (laptop) |
| `10.1.72.0/24` | WAN side of the IX |
### Routing
OSPF **area 0** across the LAN. Speakers: NEC IX (.1), VyOS (.2), laptop (.127, currently **DR**).
VyOS advertises the SDN subnets as **intra-area** networks — deliberately *not*
`redistribute connected`, which would leak every future interface and inject topology-blind E2
routes. Verified on the IX: `O 10.60.0.0/24 [110/2] via 192.168.10.2`.
---
## 4. What NetBox would need to model
Minimum to remove the duplication in §1:
- **Prefixes** + roles for the LAN and both SDN subnets, with the DHCP pool marked as a pool so
static assignments can't collide with it.
- **VLANs** 100/110 and their VLAN group, linked to the prefixes.
- **IP addresses** for §3, each assigned to a device/VM interface.
- **Devices/VMs**: 3 PVE nodes, the laptop, the IX, and the VMs (`vyos-rtr`, `dc1`, `winadmin`, `bao1`).
- **Interfaces**: notably vyos `eth0/eth1/eth2` and which VNet each attaches to.
Then the consumers that should read from it rather than hold their own copy:
1. `proxmox/ansible/roles/pve_sdn` — VNet name/tag list
2. `proxmox/ansible/roles/vyos_router` — interface addresses **and** the OSPF network list
3. `samba-ad/ansible` — `samba_ad_extra_a_records` (currently hand-listed)
4. possibly `proxmox/ansible/inventory/hosts.yml` — via `netbox.netbox.nb_inventory`
**Design question to answer, not assume:** does NetBox become the *authoring* surface (edit in the
UI, generate YAML/config) or a *derived mirror* (YAML stays authoritative, NetBox is populated from
it)? The repo's whole pattern is git-authoritative, so a UI that must be clicked to change routing
would be a regression. Consider `netbox-as-code`-style sync where git remains the input.
---
## 5. Existing conventions — match these
Repo root: `/home/panxiao81/services` (git, **but nothing is committed yet** — large untracked tree).
- **Ansible** per service: `<svc>/ansible/{ansible.cfg,inventory/hosts.yml,group_vars,roles/,*.yml}`.
Style: `host_key_checking = False`, `callback_result_format = yaml`, heavy WHY-comments in roles,
`retries`/`until` on anything touching the internet.
- **Terraform** per service: `<svc>/terraform/{versions,main,variables,outputs}.tf`, local state,
`.gitignore` for `*.tfstate`. Auth from the ambient CLI session.
Example: `openbao/terraform/` manages OpenBao's API surface.
- **Tooling**: Python CLIs via `uv`. Ansible is installed as
`uv tool install ansible-core --with ansible --with paramiko`.
⚠️ `uv tool install ansible` alone only exposes `ansible-community`, not `ansible-playbook`.
- **Secrets**: plaintext but **gitignored** (`samba-ad/ansible/group_vars/all/vault.yml`,
`proxmox/vyos/credentials.yml`). OpenBao (`bao.ad.ddupan.top`) is the real secrets store and the
internal CA; it has a Terraform config already.
- **Kubernetes**: single-node k3s on the laptop. Manifests live per service
(`smtp-relay/*.yaml`, `k3s/coredns-custom.yaml`). Authelia is Helm + `values.yaml`.
Ownership boundary already established for OpenBao and worth copying: **Terraform owns API-level
configuration, Ansible owns the machine and anything Terraform must not own** (key material,
secrets it cannot read back).
---
## 6. Constraints that will bite you
- **The WAN fails at random.** Bad ISP, cannot be changed. Any download/pull needs retries. DNS has
flapped repeatedly. Do NOT go debugging the router for this — it has been checked
(utilization "calm", memory 30%, 0 NAPT failures, upstreams fine).
- **Internal DNS must never depend on the WAN.** k3s CoreDNS sends `ad.ddupan.top` straight to the
DC and NXDOMAINs the dead search suffixes (`k3s/coredns-custom.yaml`). PVE nodes use the DC first.
If a pod times out resolving *anything*, suspect this first.
- **k3s pods inherit `ndots:5` + the node's search list** — names with <5 dots try every suffix
first. This already caused a CrashLoopBackOff that looked like a service bug.
- **The cluster holds nothing critical** and has **no HA**. LINSTOR `place-count 2`.
Guests are disposable; do not design as if they are not.
- **Storage**: `pve-rg` (SSD, ~187 GiB) and `pve-rg-hdd` (HDD, ~931 GiB) are LINSTOR/DRBD;
`laptop` is NFS (~560 GiB free) for ISOs/templates/backups, **not** VM disks.
### Where to run NetBox
Most natural: the existing **k3s on the laptop** (`.127`), same pattern as Authelia/smtp-relay —
Helm or manifests in `netbox/`, Postgres available via the shared `shared-postgresql` cluster in
namespace `shared-db`. Alternative: a VM on the PVE cluster. Note the laptop is *not* a PVE member
and is the single point of failure for k3s, NFS, the AD DC, and OpenBao already.
**SSO is available and expected**: Authelia is the OIDC provider (`https://auth.ddupan.top`), backed
by Samba AD over verified LDAPS. Grafana/Gitea/OpenBao are already clients — wire NetBox the same
way rather than inventing local accounts. AD group → app-role mapping is the established pattern
(e.g. `pve-admins` → Proxmox `Administrator`).
---
## 7. Useful access
| target | how |
|---|---|
| PVE nodes | `ssh [email protected].{4,7,9}` (key auth from the laptop) |
| VyOS | `ssh [email protected]`; op-mode non-interactively needs `/opt/vyatta/bin/vyatta-op-cmd-wrapper <cmd>` |
| NEC IX | netmiko `nec_ix_telnet`, creds in `~/scripts/netrestart/web/net.py`. `show running-config`/`show config` do **not** work — use `show ip route`, `show utilization`. No `show ip ospf neighbor` either; check adjacencies from VyOS. |
| k3s | `kubectl` on the laptop |
| OpenBao | `bao login -method=oidc` (browser); `~/.vault-token` |
Agent memory for this project lives in
`~/.claude/projects/-home-panxiao81-services/memory/` — read `MEMORY.md` first; the entries on
`homelab-proxmox-cluster`, `vyos-router-sdn`, and `flaky-wan-isp` are directly relevant.
---
## 8. Suggested first steps
1. Read the memory files above; do not re-derive the topology.
2. Decide **authoring vs mirror** (§4) — this shapes everything else.
3. Stand up NetBox (k3s + shared Postgres + Authelia OIDC), no data yet.
4. Model §3 by hand for the LAN + the two SDN prefixes. Check whether it actually reads better
than the current YAML before going further.
5. Only then attempt generation: start with the **one** case that is genuinely error-prone —
the VyOS OSPF network list plus its interface addresses, which today must be kept in sync
with the PVE SDN VNets by hand.
6. Report back whether it earns its place. "It does not, here is why" is a valid outcome.
## 9. Outcome (2026-07-26)
Steps 1–5 done. **It earns its place on the case step 5 nominated**, and on one more:
- **Authoring vs mirror (§4) — answered: derived mirror.** `terraform/topology.yml` in git
is authoritative; Terraform applies it. Nothing is authored by clicking.
- **VyOS OSPF generation — exact.** `generate/vyos-ospf.py --diff` → `matched=8,
generated_only=0, live_only=0` against the live router.
- **AD DNS records (§4 item 3) — working.** `generate/samba-a-records.py --diff` found
`retrolab` had a live A record but was missing from NetBox: the cross-check §1 says the
current arrangement lacks.
- **Still unproven:** §4 items 1 (PVE SDN VNet list) and 4 (`nb_inventory` as the real
Ansible inventory). Nothing yet *consumes* NetBox in anger — until an Ansible role reads
from it, this is a second copy of the truth rather than a replacement for one.
+469
View File
@@ -0,0 +1,469 @@
# NetBox — evaluation deployment
NetBox (IPAM + DCIM) as a candidate **single source of truth for network facts**.
The case for it, the duplication it would remove, and the topology it would model are
in [`CONTEXT.md`](CONTEXT.md) — read that first.
**Status: deployed and populated for evaluation 2026-07-25. Nothing reads from it yet.**
No Ansible role, Terraform config or playbook consumes NetBox data, so deleting this
service breaks nothing (see [Teardown](#teardown)). It *can* generate the config that
matters, verified against the live router — see
[Verdict](#verdict-it-can-generate-the-config-that-matters).
- Chart: `netbox/netbox` **8.3.38** (app **v4.6.5**) — <https://netbox-community.github.io/netbox-chart/>
- URL: **<https://netbox.ad.ddupan.top>** — LAN only, via the shared Envoy Gateway
(`../../platform/envoy-gateway`) with the `*.ad.ddupan.top` wildcard cert from `../../platform/cert-manager`.
Deliberately **not** on the cloudflared tunnel: a full inventory of the network is not
something to publish to the internet.
- Namespace: `netbox`
- Database: dedicated `netbox` role/db on the shared CNPG cluster (`shared-db`)
- Auth: **Authelia forward-auth** at the gateway (not OIDC — see
[Authentication](#authentication-authelia-forward-auth-not-oidc)); local `admin`
retained as break-glass
> Exposure moved off the Tailscale ingress on 2026-07-25: no Tailscale client needed, it
> works from any LAN host (including the Windows admin VM), and traffic never leaves the
> LAN. That required adding cert-manager and — to get AD-group→role mapping — replacing
> Contour with Envoy Gateway. See those directories.
## Layout
| file | what |
|---|---|
| `values.yaml` | Helm values — the whole config, heavily commented |
| `secret.yaml` | **gitignored** — the database password (that is all) |
| `securitypolicy.yaml` | Authelia forward-auth at the gateway |
| `networkpolicy.yaml` | blocks bypassing the gateway — part of the trust boundary |
| `secret.example.yaml` | committed template for the above |
| `namespace.yaml` | the `netbox` namespace |
| `terraform/topology.yml` | **the authoritative topology** — git is the source, NetBox the mirror |
| `terraform/{versions,main,variables,outputs}.tf` | Terraform root that applies it |
| `terraform/attach-wireless.py` | the one thing Terraform cannot express (see below) |
| `terraform/gen-imports.py` | one-shot: adopt pre-existing objects into TF state |
| `terraform/.env` | **gitignored** — API token for the helper scripts |
| `generate/vyos-ospf.py` | derives the VyOS OSPF config from NetBox; `--diff` vs the live router |
| `generate/samba-a-records.py` | derives `samba_ad_extra_a_records`; `--diff` vs the live vars |
| `CONTEXT.md` | the evaluation brief (why NetBox, what it would model) |
## Architecture notes worth knowing before you touch it
**Postgres is external, Valkey is bundled.** The shared CNPG cluster gets a dedicated
role+database, same as Authelia and Gitea. Redis is *not* shared because nothing else in
the cluster runs one, and NetBox wants two logical DBs (RQ task queue + caching) to
itself. `postgresql.enabled: false` / `valkey.enabled: true`.
**The Valkey image is pinned by digest, not tag.** Bitnami's public catalog stopped
serving versioned tags in 2025 — versioned images moved to the `bitnamilegacy` repo and
only `latest` remains public, which is why the chart itself ships `tag: latest`. A
floating tag is not acceptable here, so `values.yaml` pins
`valkey.image.digest`. Verified: `docker.io/bitnami/valkey:9.0.1` → *not found*,
`:latest` → pulls (app 9.1.1). **To bump it, resolve the new digest explicitly:**
```bash
sudo k3s ctr images pull docker.io/bitnami/valkey:latest # prints the manifest digest
```
**How configuration reaches Django.** The chart renders `netbox.yaml` into a ConfigMap and
its `configuration.py` deep-merges that file plus every `*.yaml` under
`/run/config/extra/*/` into the Django settings namespace. That is what `extraConfig[]`
feeds — arbitrary settings keys work, not just the ones the chart models. `extraConfig`
is currently empty; it held the OIDC client config before the move to forward-auth, and
is the hook to reach for if a future setting has no chart value.
**CSRF.** Envoy terminates TLS and forwards plain HTTP, so Django sees an `http://`
request carrying an `https://` Origin. Without `csrf.trustedOrigins` every POST —
including the login form itself — fails CSRF verification. This is the failure that looks
like "login is broken" rather than "config is missing", and it applies behind any
TLS-terminating proxy.
**Exposure is Gateway API, not Ingress.** The chart's native `httpRoute` block attaches to
the shared `eg` Gateway in `envoy-gateway-system`, `sectionName: https`. Because that
listener already serves the `*.ad.ddupan.top` wildcard, this service owns no certificate
of its own — adding the next LAN service is an `HTTPRoute` plus one A record in
`samba_ad_extra_a_records` (`../../infrastructure/samba-ad`), with no Gateway or cert work.
## Authentication: Authelia forward-auth (not OIDC)
Authelia authenticates and enforces 2FA **at the gateway**. By the time a request
reaches NetBox it is already authenticated, and Envoy has attached headers describing
the user. NetBox runs `netbox.authentication.RemoteUserBackend` and reads them.
### Why not OIDC — the reason this design exists
NetBox was originally wired to Authelia over OIDC. It worked, but **NetBox has no SSO
group → role mapping**: `REMOTE_AUTH_SUPERUSER_GROUPS` and
`AUTH_LDAP_USER_FLAGS_BY_GROUP` are **LDAP-only**, and the social-auth pipeline runs only
`user_default_groups_handler`, which assigns one static group and nothing else. So every
SSO user landed as an ordinary member of `sso-users` and had to be promoted **by hand**,
and later AD group changes never propagated.
Header auth fixes exactly that: `REMOTE_AUTH_GROUP_SYNC_ENABLED` re-evaluates group
membership from the `Remote-Groups` header on **every request**, giving the same
declarative AD-group→role pattern Grafana and Proxmox already use. Remove someone from
`netbox-admins` in AD and their NetBox admin is gone immediately.
Getting there required replacing Contour with Envoy Gateway — Contour speaks only gRPC
ext_authz, Authelia only HTTP. See `../../platform/envoy-gateway/README.md`.
### Header mapping (all three defaults are wrong for Authelia)
| NetBox setting | value | why |
|---|---|---|
| `header` | `HTTP_REMOTE_USER` | matches Authelia's `Remote-User` |
| `groupHeader` | `HTTP_REMOTE_GROUPS` | NetBox defaults to `HTTP_REMOTE_USER_GROUP`, which Authelia never sends |
| `groupSeparator` | `,` | Authelia joins groups with a comma; NetBox defaults to `\|`, which would yield **one** group literally named `a,b,c` |
| `userEmail` | `HTTP_REMOTE_EMAIL` | Authelia sends `Remote-Email` |
Authelia has no split given/family name — only `Remote-Name` — so the first/last-name
headers are deliberately left unmapped.
### ⚠ Trust boundary — read before changing anything here
`RemoteUserBackend` trusts the header **unconditionally**; NetBox has no trusted-proxy
allowlist. Two things keep that safe and **both** must stay true:
1. **Envoy overrides the headers.** `headersToBackend` replaces any client-supplied
`Remote-User` with Authelia's verdict rather than merging it.
2. **`networkpolicy.yaml` blocks bypass.** Without it, any pod could hit
`netbox.netbox.svc:8080` directly with `Remote-User: admin` and be superuser.
Verified by test: a pod in `default` sending that header gets **connection refused**;
the same request from `envoy-gateway-system` is served. `failOpen: false`, so if Authelia
is down traffic is refused rather than admitted unauthenticated.
Access is further restricted in `authelia/values.yaml` to `group:netbox-admins` —
otherwise any AD account that can pass 2FA would be auto-provisioned a NetBox user.
**Local login remains as break-glass.** `AUTHENTICATION_BACKENDS` appends
`ObjectPermissionBackend`, which subclasses Django's `ModelBackend`, so the `admin`
password still works — reachable via `kubectl port-forward`, since the gateway
intercepts everything else.
## Prerequisites (already done)
```bash
# Dedicated Postgres role + database on the shared CNPG cluster
POD=$(kubectl -n shared-db get pods -l cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
kubectl -n shared-db exec "$POD" -c postgres -- psql -U postgres -v ON_ERROR_STOP=1 \
-c "CREATE ROLE netbox LOGIN PASSWORD '<see secret.yaml>'" \
-c "CREATE DATABASE netbox OWNER netbox"
```
There is **no OIDC client** for NetBox — forward-auth needs no client credential. What
must exist instead:
- AD group **`netbox-admins`** (`samba_ad_groups` in `../../infrastructure/samba-ad`), applied with
`ansible-playbook provision-dc.yml --tags directory,accounts`
- DNS A record **`netbox`** → `192.168.10.127` (`samba_ad_extra_a_records`), applied with
`ansible-playbook provision-dc.yml --tags dns`
- the `ext-authz` endpoint + access-control rule in `authelia/values.yaml`, and the
`ReferenceGrant` in `authelia/referencegrant-extauth.yaml`
## Deploy
```bash
helm repo add netbox https://netbox-community.github.io/netbox-chart/ && helm repo update netbox
kubectl apply -f netbox/namespace.yaml -f netbox/secret.yaml
# Authelia: ext-authz endpoint + access-control rule + cross-namespace grant
kubectl apply -f authelia/referencegrant-extauth.yaml
helm upgrade authelia authelia/authelia --version 0.11.6 -n authelia -f authelia/values.yaml
kubectl apply -f netbox/networkpolicy.yaml
helm upgrade --install netbox netbox/netbox --version 8.3.38 -n netbox -f netbox/values.yaml
kubectl apply -f netbox/securitypolicy.yaml # after the HTTPRoute it targets exists
```
> `authelia/values.yaml` is the *only* copy of Authelia's config. Before upgrading it,
> confirm it still matches the live release — `helm get values authelia -n authelia -o yaml`
> and compare **structurally** (parse both to YAML and diff keys); a textual diff is
> useless because `helm get values` sorts keys and strips comments.
## Verify
```bash
kubectl -n netbox rollout status deploy/netbox deploy/netbox-worker
kubectl -n netbox get pods,pvc,httproute,securitypolicy
# Unauthenticated request must be intercepted: 302 -> auth.ddupan.top
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://netbox.ad.ddupan.top/
# Trust boundary: this MUST fail (connection refused)
kubectl run spoof -n default --image=busybox:1.38.0 --restart=Never --rm -i -- \
wget -q -T 8 -O- --header='Remote-User: admin' http://netbox.netbox.svc.cluster.local/
# Local admin password (break-glass)
kubectl -n netbox get secret netbox-superuser -o jsonpath='{.data.password}' | base64 -d
# API reachable + DB migrated
kubectl -n netbox exec deploy/netbox -- curl -sf localhost:8080/api/status/ | head -c 400
```
Then browse <https://netbox.ad.ddupan.top>. Authelia intercepts, you authenticate with
2FA, and NetBox provisions the user on arrival — **already a superuser**, because
`netbox-admins` is in `REMOTE_AUTH_SUPERUSER_GROUPS`. No manual promotion step.
## Two API gotchas that will bite integrations
**Forward-auth blocks the API unless you carve it out.** Authelia intercepts every
request, including API calls carrying a valid NetBox token — it has no idea what a NetBox
token is, sees no session cookie, and 302s the caller to the login portal. Fixed with a
`bypass` rule for `^/api/` and `^/graphql/` in `authelia/values.yaml`, which must come
**before** the `two_factor` rule (Authelia is first-match-wins). This is not
unauthenticated access: NetBox's own token auth still gates those paths and an anonymous
call returns 403. Verified: token → 200, no token → 403, browser → 302.
**`Prefix.site` is silently ignored on NetBox 4.2+.** It was replaced by a generic scope
(`scope_type`/`scope_id`). Posting `site` returns **200 with the field dropped** — no
error — so a seed script looks like it worked while every prefix ends up unscoped, and an
idempotency check then re-patches forever. `VLAN` still uses `site`, so the two are
inconsistent. This is what made the first `seed.py` run non-idempotent.
## API tokens: `superuser.apiToken` is a dead value on NetBox 4.6
The chart still has a `superuser.apiToken` value (defaulting to a random UUID) and stores
it in the `netbox-superuser` Secret, but **no token is created** — verified:
`Token.objects.count()` was 0 after a clean bootstrap, and using that value returns
`403 {"detail":"Invalid v1 token"}`.
NetBox 4.5 introduced v2 tokens, which are stored only as a salted HMAC digest keyed by
`API_TOKEN_PEPPERS` and are the default for new tokens. Legacy v1 tokens (plaintext,
`Token <value>` header) are deprecated and go away in v5.0. So the secret's `api_token`
key should be treated as **stale, not a credential**.
Mint a real one in the UI, or from the CLI — note the plaintext is available only at
creation, and the header prefix is `Bearer nbt_<key>.`:
```bash
kubectl -n netbox exec deploy/netbox -c netbox -- /opt/netbox/venv/bin/python \
/opt/netbox/netbox/manage.py shell -c "
from users.models import Token, User
t = Token(user=User.objects.get(username='admin'), description='automation')
t.save()
print(t.get_auth_header_prefix() + t.token)"
```
This matters for the evaluation: any generation/sync tooling (§4 of `CONTEXT.md`)
authenticates this way, and `API_TOKEN_PEPPERS` becomes state that must survive — the
chart auto-generates pepper `1` and preserves it across upgrades via a `lookup`, so
**never clear that Secret or every issued token dies.**
**v2 tokens do NOT break the ecosystem** — an earlier version of this file claimed they
did; that was wrong and is corrected here. NetBox dispatches on the token *value* (an
`nbt_` prefix means v2), **not** the header scheme, so the same v2 token is accepted as
both `Authorization: Bearer <tok>` and `Authorization: Token <tok>` — verified, both 200.
The `"Invalid v1 token"` 403 above happens because the chart's UUID is not a token at all,
not because of the scheme. Tested working against this instance with a v2 token:
| integration | version | result |
|---|---|---|
| `netbox.netbox` modules (write) | 3.23.0 | ✅ created an object |
| `netbox.netbox.nb_inventory` (read) | 3.23.0 | ✅ grouped devices by role (needs `pytz`) |
| `e-breuninger/netbox` Terraform provider | **5.7.0** | ✅ read live prefix data |
Two traps when wiring them up:
- The Terraform provider's `server_url` must be the **base URL without `/api`**; passing
`.../api` yields a confusing go-openapi error
(`... is not supported by the TextConsumer`). Provider **v4.x fails against NetBox 4.6**
regardless — pin `~> 5.0`.
- `nb_inventory` needs `pytz` in the Ansible environment
(`uv tool install ansible-core --with ansible --with paramiko --with pytz`), and the
plugin must be enabled (`enable_plugins = netbox.netbox.nb_inventory`).
## Known caveats
- **Reaching NetBox no longer needs the WAN, but logging in still does.**
`netbox.ad.ddupan.top` is resolved by the DC and served on the LAN. However
`auth.ddupan.top` resolves publicly and routes back in through the cloudflared tunnel,
so the OIDC round-trip still crosses the flaky ISP link (`CONTEXT.md` §6) — the same
exposure Grafana has. The local `admin` account is the fallback when the WAN is down.
Closing this means giving Authelia an internal HTTPS name, which changes the **issuer**
and therefore touches Gitea, Grafana and OpenBao as registered clients — a decision to
take once for all services, not per service.
- `releaseCheck.url` is blanked so NetBox never blocks a page render on `api.github.com`.
- One k3s node, no HA: `valkey.architecture: standalone`, one web pod, one worker.
- Resource presets (`medium` web / `small` worker) are sized for the laptop's headroom at
survey time (~4 GiB free), not for throughput.
## Teardown
```bash
helm uninstall netbox -n netbox
kubectl delete ns netbox # also drops the media + valkey PVCs
POD=$(kubectl -n shared-db get pods -l cnpg.io/instanceRole=primary -o jsonpath='{.items[0].metadata.name}')
kubectl -n shared-db exec "$POD" -c postgres -- psql -U postgres \
-c "DROP DATABASE netbox" -c "DROP ROLE netbox"
# then remove the netbox access_control rule from authelia/values.yaml, and drop
# `netbox` from the ReferenceGrant's `from` list in authelia/referencegrant-extauth.yaml
helm upgrade authelia authelia/authelia --version 0.11.6 -n authelia -f authelia/values.yaml
kubectl apply -f authelia/referencegrant-extauth.yaml
```
The `netbox-admins` AD group and the `netbox` A record stay in `../../infrastructure/samba-ad` unless you
remove them there too; both are harmless if left.
## Data model
`terraform/topology.yml` holds the §3 topology; the Terraform root reads it with
`yamldecode` and applies it. **Direction is git → NetBox**: the YAML is authoritative,
NetBox is a derived mirror. That settles `CONTEXT.md` §4 the way the rest of the repo
works — a UI you must click to change routing would be a regression.
Terraform (not a script) because of the repo's own boundary — *Terraform owns API-level
configuration* (`../../infrastructure/openbao/terraform` is the precedent) — and because it brings the one
thing the previous hand-rolled `seed.py` could never do: **deletion**. Remove an entry from
the YAML and `terraform apply` removes the object. `plan` doubles as a drift report.
```bash
cd netbox/terraform
export TF_VAR_netbox_token=... # or terraform.tfvars
terraform init
terraform plan -parallelism=2 # see below re: parallelism
terraform apply -parallelism=2
uv run --with requests --with pyyaml python attach-wireless.py
```
Acceptance test, same spirit as the Ansible here:
`No changes. Your infrastructure matches the configuration.`
### Adopting an already-populated NetBox
`gen-imports.py` writes `imports.tf` from the live API so Terraform **adopts** existing
objects instead of failing on uniqueness. It reads the same `topology.yml`, so resource
addresses and `for_each` keys line up by construction. One-shot — delete `imports.tf` after
the first successful apply.
`netbox_device_primary_ip` is deliberately **not** imported: the provider has no importable
object at the device ID. Letting Terraform "create" it just re-PATCHes `primary_ip4` to the
value it already holds.
### ⚠ Terraform cannot do the Wi-Fi wiring
`netbox_device_interface` has **no `rf_role` and no `wireless_lans`** attribute — checked
against the provider schema — and `netbox.netbox`'s module has the identical gap. So
neither official tool can attach an SSID to a radio. `attach-wireless.py` does that last
mile from the same `topology.yml`; it is idempotent and has a `--check` mode. If a future
provider release adds those attributes, delete it and fold the fields into `main.tf`.
### Four things that cost time here
- **Pin the provider `~> 5.0`.** v4.3.1 fails against NetBox 4.6 at configure time.
- **`server_url` must NOT include `/api`** — same misleading go-openapi error if it does.
- **`-parallelism=2`.** At the default 10 the single NetBox pod times out reads
(`context deadline exceeded`) during import.
- **Pin NetBox's own defaults or fight them forever.** The provider defaults
`vm_role`/`is_full_depth` opposite to NetBox, and a VM's `site_id` is *derived from its
cluster* — leave any of them unset and every plan shows phantom changes.
Loaded: 1 site · 6 prefixes (+roles) · 2 VLANs in group `lab` · the DHCP pool as an
IPRange · 3 wireless LANs · 7 devices · 4 VMs in 2 clusters · interfaces with addresses,
MACs and primary IPs.
Hardware fields (model, serial, CPU/RAM) are **read from `dmidecode`**, not guessed — that
corrected three earlier assumptions: pve1 is a NUC6i3**SYB** board with an i3-6100**U**
(and reports a *blank* system serial, because the OEM never programmed DMI), and pve2/pve3
are Lenovo machine-type `10VGCTO1WW`. NetBox has no native CPU/RAM field for Devices (only
VMs get `vcpus`/`memory`), so that detail lives in `comments`; custom fields would be the
right answer if it ever needs to be queryable.
Two modelling choices worth knowing:
- **The laptop's Broadcom BCM4360 is an `inventory_item`, not an interface.** `b43`/`bcma`
claim the PCI device but cannot drive BCM4360 (it needs proprietary `broadcom-sta`/`wl`
with those modules blacklisted), so there is no netdev — an Interface would imply a
capability the OS does not have.
- **`ap-buffalo` was identified, not assumed**: MAC OUI `d4:2c:46` = BUFFALO.INC, model
`WSR-1800AX4S` from its login page, and Buffalo's factory SSID suffix `07B0` matches the
tail of that same MAC.
### ⚠ Two things the data now surfaces
1. **`ap-buffalo` holds `192.168.10.10` — the first address of the DHCP pool** (`.10`–`.250`).
Either it is a lease that can move, or a static overlapping the pool. Fix by moving the
AP below `.10` or starting the pool at `.11`; NetBox shows the collision but cannot
resolve it.
2. **Objects created before the Terraform conversion are not in state**, so Terraform will
not reconcile them: the guessed device types (`nuc6i3syh`, `thinkcentre-mini`,
`Laptop`) and the `Generic` manufacturer are orphans needing one manual prune. Anything
added *since* is managed — removing it from `topology.yml` now deletes it, which is
exactly what the old `seed.py` could not do.
### Wi-Fi
Three SSIDs off one AP, all bridged **untagged onto the flat LAN** (no `vlan` set) — the
Wi-Fi is not a separate segment; clients get an IX DHCP lease like anything else.
| SSID | radio | auth |
|---|---|---|
| `Buffalo-A-07B0-WPA3` | 5 GHz | WPA3-SAE |
| `Buffalo-A-07B0` | 5 GHz | WPA2-PSK (compat) |
| `Buffalo-G-07B0` | 2.4 GHz | WPA2-PSK |
**NetBox cannot express WPA3.** `auth_type` offers only
`open`/`wep`/`wpa-personal`/`wpa-enterprise`, so WPA3-SAE and WPA2-PSK both store as
`wpa-personal` and the real difference survives only in the description.
`auth_psk` is deliberately empty — NetBox *can* hold the passphrase, but the house Wi-Fi
key does not belong in a system whose backup story is untested when OpenBao is right there.
## Verdict: it can generate the config that matters
`CONTEXT.md` §8 step 5 named the one genuinely error-prone duplication — the VyOS
`area 0 network` list plus interface addresses, where forgetting a line is *silent*
(the subnet exists, has a gateway, and is unreachable from anywhere else).
`generate/vyos-ospf.py` derives it from NetBox and diffs against the live router:
```
$ uv run --with requests python netbox/generate/vyos-ospf.py --diff
matched=8 generated_only=0 live_only=0
```
**Exact match, zero drift.** The derivation rules are the valuable part:
| output | rule |
|---|---|
| interface addresses | every IP assigned to a `vyos-rtr` interface |
| `area 0 network` | every prefix the router **has an interface in** |
| `passive` | interfaces whose prefix has role `sdn` |
The second rule was wrong on the first attempt and the diff caught it. "The SDN prefixes"
omits `192.168.10.0/24` — but the LAN must be in area 0 or VyOS forms no adjacency with
the IX or the laptop and advertises nothing at all. Deriving from *where the router
actually has an address* yields the LAN for free and cannot forget a future VNet, which
is precisely the failure mode NetBox is supposed to prevent.
### Second consumer: AD DNS records (CONTEXT.md §4 item 3)
`generate/samba-a-records.py` derives `samba_ad_extra_a_records` from NetBox and diffs the
hand-written list in `../../infrastructure/samba-ad`:
```
in both=5 netbox only=3 vars.yml only=1
```
**Which** hosts get a record is intent, not a derived fact — domain-joined machines
self-register, so "every IP in the LAN prefix" would be wrong. The selector is NetBox's
native `dns_name` field on the IP address; set it and the host gets a record.
The diff paid for itself immediately: it found **`retrolab` (10.60.0.10) had a live AD DNS
record but was missing from NetBox entirely** — verified up and now modelled. That is the
duplication-detection the whole exercise is meant to provide.
Out of scope by design: service/ingress names like `netbox.ad.ddupan.top`, which point at
the k3s gateway rather than a host. Several such names share one address and `dns_name` is
single-valued per IP, so they stay hand-managed. This tool owns **host** records only.
**So it earns its place on this one case.** Honest caveats before adopting further:
- **This is one generator against one router.** The other consumers in `CONTEXT.md` §4
(the PVE SDN VNet list, `samba_ad_extra_a_records`, `nb_inventory`) are unproven.
- **NetBox demands ceremony.** Recording "pve1 is 192.168.10.4" first requires a
manufacturer, a device type, a device role and a site. For ~10 hosts that is real
overhead the YAML does not have.
- **Nothing consumes it yet.** Until a role actually reads from NetBox, this is a second
copy of the truth — the very duplication it is meant to remove. The next real step is
wiring `generate/vyos-ospf.py` into `proxmox/ansible/roles/vyos_router` so the template
has one source, not two.
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Generate samba_ad_extra_a_records from NetBox.
uv run --with requests --with pyyaml python netbox/generate/samba-a-records.py [--diff]
CONTEXT.md §4 item 3: the AD DNS A records for non-domain-joined hosts are hand-listed in
../../../infrastructure/samba-ad/ansible/group_vars/all/vars.yml, duplicating addresses that already live in
NetBox. This derives them instead.
SOURCE OF TRUTH FOR *WHICH* HOSTS: the `dns_name` field on the NetBox IP address. That is
intent, not a derived fact — domain-joined machines register themselves in AD DNS and must
NOT get a static record, so "every IP in the LAN prefix" would be wrong. An address gets a
record iff someone set dns_name on it.
Deliberately NOT handled: service/ingress names such as netbox.ad.ddupan.top, which point
at the k3s gateway rather than at a host. Several of those share one address, and NetBox's
dns_name is single-valued per IP, so they stay hand-managed in vars.yml. This tool only
owns HOST records.
Read-only: prints YAML and diffs. It never writes to the DC — ../../../infrastructure/samba-ad applies it
(`ansible-playbook provision-dc.yml --tags dns`).
"""
from __future__ import annotations
import ipaddress
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
ZONE = "ad.ddupan.top"
VARS = Path(__file__).parents[3] / "infrastructure/samba-ad/ansible/group_vars/all/vars.yml"
envfile = Path(__file__).parent.parent / "terraform" / ".env"
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
BASE = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/") + "/api"
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
def get(path: str):
req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {TOKEN}"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def generate() -> list[dict]:
out = []
for a in get("/ipam/ip-addresses/?limit=500")["results"]:
dns = (a.get("dns_name") or "").strip().lower()
if not dns.endswith(f".{ZONE}"):
continue
name = dns[: -len(f".{ZONE}")]
ip = str(ipaddress.ip_interface(a["address"]).ip)
out.append({"name": name, "ip": ip})
# Stable order so the diff is meaningful rather than churn.
return sorted(out, key=lambda r: ipaddress.ip_address(r["ip"]))
def current() -> list[dict]:
"""Parse the existing hand-written list without pulling in the whole vars file."""
if not VARS.exists():
return []
txt = VARS.read_text()
m = re.search(r"^samba_ad_extra_a_records:\s*$(.*?)(?=^\S)", txt, re.S | re.M)
if not m:
return []
return [{"name": n, "ip": i}
for n, i in re.findall(r'name:\s*"([^"]+)".*?ip:\s*"([^"]+)"', m.group(1))]
if __name__ == "__main__":
gen = generate()
if "--diff" not in sys.argv:
print("samba_ad_extra_a_records:")
for r in gen:
print(f' - {{ name: "{r["name"]}", ip: "{r["ip"]}" }}')
sys.exit(0)
have = {(r["name"], r["ip"]) for r in current()}
want = {(r["name"], r["ip"]) for r in gen}
for n, i in sorted(want | have, key=lambda x: ipaddress.ip_address(x[1])):
mark = " " if (n, i) in want and (n, i) in have else ("+ " if (n, i) in want else "- ")
print(f"{mark}{n:<10} {i}")
print(f"\nin both={len(want & have)} netbox only={len(want - have)} vars.yml only={len(have - want)}")
print("\n+ = NetBox has it, vars.yml does not - = hand-listed, not derivable from NetBox")
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Generate the VyOS interface + OSPF config from NetBox.
uv run --with requests python netbox/generate/vyos-ospf.py [--diff]
This is the ONE case CONTEXT.md §8 step 5 nominates as worth automating: today the VyOS
interface addresses, the `area 0 network` list and the PVE SDN VNets must be kept in sync
BY HAND, and forgetting the OSPF line is silent — the subnet exists, has a gateway, and
is simply unreachable from anywhere else.
DERIVATION RULES (these are the interesting part, not the code):
addresses every IP assigned to a vyos-rtr interface
area 0 every prefix that vyos-rtr HAS AN INTERFACE IN
passive interfaces whose prefix has role `sdn`
The second rule matters. The obvious rule — "the SDN prefixes" — is WRONG and was caught
by diffing against the live router: it omits 192.168.10.0/24, but the LAN must be in
area 0 or VyOS has no adjacency with the NEC IX or the laptop and nothing is advertised
at all. Deriving from "where does this router actually have an address" produces the LAN
for free and cannot forget a future VNet.
The third rule is why eth0 is NOT passive: it is the only interface that must form
adjacencies. eth1/eth2 face guests and are advertised without peering.
Read-only. It prints config; it does not touch the router.
"""
from __future__ import annotations
import ipaddress
import json
import os
import subprocess
import sys
import urllib.request
from pathlib import Path
ROUTER = "vyos-rtr"
envfile = Path(__file__).parent.parent / "terraform" / ".env"
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
BASE = os.environ.get("NETBOX_URL", "").rstrip("/") + "/api"
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
def get(path: str):
req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {TOKEN}"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
def generate() -> list[str]:
vm = get(f"/virtualization/virtual-machines/?name={ROUTER}")["results"]
if not vm:
sys.exit(f"{ROUTER} not found in NetBox")
prefixes = get("/ipam/prefixes/?limit=200")["results"]
ifaces = sorted(get(f"/virtualization/interfaces/?virtual_machine_id={vm[0]['id']}")["results"],
key=lambda i: i["name"])
lines, areas, passive = [], [], []
for iface in ifaces:
for addr in get(f"/ipam/ip-addresses/?vminterface_id={iface['id']}")["results"]:
lines.append(f"set interfaces ethernet {iface['name']} address '{addr['address']}'")
ip = ipaddress.ip_interface(addr["address"]).ip
# The prefix this address sits in == a network this router participates in.
for p in prefixes:
if ip in ipaddress.ip_network(p["prefix"]):
if p["prefix"] not in areas:
areas.append(p["prefix"])
if (p.get("role") or {}).get("slug") == "sdn":
passive.append(iface["name"])
break
# Keep the LAN first: it is the transit network, and reading the config that way
# matches how the adjacency is reasoned about.
areas.sort(key=lambda p: (not p.startswith("192.168."), p))
lines += [f"set protocols ospf area 0 network '{p}'" for p in areas]
lines += [f"set protocols ospf interface {i} passive" for i in sorted(set(passive))]
return lines
def live() -> list[str]:
out = subprocess.run(
["ssh", "-o", "ConnectTimeout=8", "-o", "BatchMode=yes", "[email protected]",
"/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands"],
capture_output=True, text=True, timeout=60).stdout
return [l.strip() for l in out.splitlines()
if ("ospf area" in l or "ospf interface" in l or
("ethernet eth" in l and "address" in l))]
if __name__ == "__main__":
gen = generate()
if "--diff" not in sys.argv:
print("\n".join(gen))
sys.exit(0)
have = live()
only_live = [l for l in have if l not in gen]
only_gen = [l for l in gen if l not in have]
for l in gen:
print((" " if l in have else "+ ") + l)
for l in only_live:
print("- " + l)
print(f"\nmatched={len(gen) - len(only_gen)} generated_only={len(only_gen)} live_only={len(only_live)}")
sys.exit(1 if (only_gen or only_live) else 0)
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: netbox
+42
View File
@@ -0,0 +1,42 @@
# Half of the trust boundary for header-based auth (the other half is Envoy
# overriding client-supplied Remote-* headers — see securitypolicy.yaml).
#
# WHY THIS IS NOT OPTIONAL: NetBox's RemoteUserBackend trusts HTTP_REMOTE_USER
# unconditionally; there is no trusted-proxy allowlist in NetBox. Envoy sanitises the
# header, but Envoy only sees traffic that goes THROUGH it. Any pod in the cluster
# could otherwise open a connection straight to netbox.netbox.svc:8080, send
# `Remote-User: admin`, and be a superuser. This policy removes that path.
#
# k3s enforces NetworkPolicy (kube-router backend), so this is a real control, not
# decoration.
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: netbox-ingress-gateway-only
namespace: netbox
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: netbox
app.kubernetes.io/instance: netbox
policyTypes:
- Ingress
ingress:
# Only Envoy may reach the app port.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: envoy-gateway-system
ports:
- protocol: TCP
port: 8080
# kubelet probes come from the node itself, outside any namespace, so they are not
# matched by a namespaceSelector. Without this the pod fails its readiness probe
# and is pulled from the Service.
- from:
- ipBlock:
cidr: 192.168.10.127/32
ports:
- protocol: TCP
port: 8080
+16
View File
@@ -0,0 +1,16 @@
# Template for netbox/secret.yaml (which is gitignored). Copy, fill in, apply.
#
# Only the database password lives here. NetBox holds no OIDC client secret: it
# authenticates via Authelia forward-auth at the gateway (securitypolicy.yaml), so
# there is no client credential for this app to keep.
apiVersion: v1
kind: Secret
metadata:
name: netbox-secrets
namespace: netbox
type: Opaque
stringData:
# Password for the dedicated `netbox` role on the shared CNPG cluster.
# Must match what was granted in shared-db (see README "Prerequisites").
# Consumed via externalDatabase.existingSecretName/Key in values.yaml.
postgresql-password: "REPLACE_WITH_DB_PASSWORD"
+68
View File
@@ -0,0 +1,68 @@
# Authelia forward-auth in front of NetBox, enforced at the gateway.
#
# This is what makes AD-group -> NetBox-role mapping possible at all: NetBox has no
# SSO group mapping, but it does have header-based group sync, and this supplies the
# headers from a source the app can trust (see netbox/values.yaml remoteAuth).
#
# Flow: browser -> Envoy -> (extAuth) Authelia -> 200 + Remote-* headers -> NetBox.
# On 401/403 Authelia redirects to https://auth.ddupan.top and back.
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: netbox-authelia
namespace: netbox
spec:
# Targets the HTTPRoute the chart generates, so the policy applies to exactly the
# traffic that reaches NetBox and nothing else on the shared gateway.
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: netbox
extAuth:
# HTTP, not gRPC. This is the whole reason the gateway is Envoy Gateway and not
# Contour: Contour supports only the gRPC ext_authz protocol, and Authelia
# implements the HTTP ExtAuthz filter.
http:
backendRefs:
- name: authelia
namespace: authelia # allowed by ../authelia/referencegrant-extauth.yaml
# The SERVICE port, not the container port. The Authelia chart publishes
# port 80 -> targetPort http (9091); referencing 9091 here is rejected with
# "TCP Port 9091 not found on service authelia/authelia".
port: 80
# Authelia's ExtAuthz endpoint. The ORIGINAL request path is appended to this
# prefix, which is how Authelia learns what was being requested.
path: /api/authz/ext-authz/
# Headers Envoy copies from Authelia's response ONTO the upstream request.
# NOTE this belongs to the `http` service block, not to `extAuth` — the API
# rejects it one level up (headersToBackend is a field of HTTPExtAuthService).
#
# SECURITY: "coexisting headers will be overridden" (Envoy Gateway API docs) —
# a client-supplied Remote-User is replaced by Authelia's verdict, not merged.
# This list is the entire trust boundary; do not add anything NetBox reads for
# authorization that Authelia does not itself vouch for.
headersToBackend:
- Remote-User
- Remote-Groups
- Remote-Email
- Remote-Name
# Headers Envoy forwards TO Authelia. Without cookie there is no session and every
# request bounces to the portal; without the X-Forwarded-* trio Authelia cannot
# reconstruct the original URL and answers 400.
headersToExtAuth:
- cookie
- authorization
- proxy-authorization
- accept
- x-forwarded-proto
- x-forwarded-host
- x-forwarded-uri
- x-forwarded-for
- x-original-method
# Fail CLOSED. If Authelia is down, refuse traffic rather than admit unauthenticated
# requests to a service whose entire auth model is "trust the header".
failOpen: false
+8
View File
@@ -0,0 +1,8 @@
# Local state and real credentials stay out of git (same as the other TF roots here).
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
terraform.tfvars
# Real API token — mint with the snippet in ../README.md
.env
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Attach SSIDs to radio interfaces and set rf_role — the part Terraform cannot express.
uv run --with requests --with pyyaml python netbox/terraform/attach-wireless.py [--check]
WHY THIS EXISTS. Terraform creates the WirelessLAN objects (netbox_wireless_lan) and the
radio interfaces, but `netbox_device_interface` has NO attribute for either:
* rf_role (ap / station)
* wireless_lans (which SSIDs this radio broadcasts)
Verified against the provider schema for e-breuninger/netbox 5.7.0 — and the
netbox.netbox Ansible collection 3.23.0 has the same gap in netbox_device_interface.
So neither official tool can do this; a few lines of API call is the honest fallback
rather than dropping the data.
Idempotent, and reads the same topology.yml Terraform does, so there is one source of
truth. Run it after `terraform apply`. If a future provider release grows these
attributes, delete this file and move the fields into main.tf.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import requests
import yaml
HERE = Path(__file__).parent
CHECK = "--check" in sys.argv
for envfile in (HERE / ".env", HERE / "../seed/.env"):
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
URL = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/")
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
S = requests.Session()
S.headers.update({"Authorization": f"Bearer {TOKEN}"})
def get(path: str, **params):
r = S.get(f"{URL}/api{path}", params=params, timeout=30)
r.raise_for_status()
return r.json()
def main() -> int:
topo = yaml.safe_load((HERE / "topology.yml").read_text())
changed = 0
# ssid -> id, resolved once
lans = {w["ssid"]: w["id"] for w in get("/wireless/wireless-lans/", limit=200)["results"]}
for dev in topo["devices"]:
for iface in dev.get("interfaces", []):
want_ssids = iface.get("wireless_lans")
want_role = iface.get("rf_role")
if not want_ssids and not want_role:
continue
found = get("/dcim/interfaces/", device=dev["name"], name=iface["name"])["results"]
if not found:
print(f" ! {dev['name']}:{iface['name']} not in NetBox — run terraform apply first")
continue
cur = found[0]
patch = {}
if want_role and (cur.get("rf_role") or {}).get("value") != want_role:
patch["rf_role"] = want_role
if want_ssids:
have = sorted(w["id"] for w in (cur.get("wireless_lans") or []))
missing = [s for s in want_ssids if s not in lans]
if missing:
print(f" ! SSID(s) not in NetBox: {missing} — run terraform apply first")
continue
want = sorted(lans[s] for s in want_ssids)
if have != want:
patch["wireless_lans"] = want
if not patch:
continue
changed += 1
if CHECK:
print(f" ~ would patch {dev['name']}:{iface['name']}: {list(patch)}")
else:
r = S.patch(f"{URL}/api/dcim/interfaces/{cur['id']}/", json=patch, timeout=30)
r.raise_for_status()
print(f" ~ patched {dev['name']}:{iface['name']}: {list(patch)}")
print(f"changed={changed}" + (" (check mode)" if CHECK else ""))
if changed == 0:
print("idempotent: nothing to do")
return 0
if __name__ == "__main__":
sys.exit(main())
+40
View File
@@ -0,0 +1,40 @@
# Remote state in SeaweedFS S3, on the LAN.
#
# WHY remote at all: local state means the only copy lives on this laptop, which is
# also the k3s node, the NFS server and the libvirt host — i.e. the single point of
# failure. It also cannot be locked, so two concurrent applies silently corrupt it.
#
# WHY s3.ad.ddupan.top and NOT obj.ddupan.top: the public name resolves to
# Cloudflare and hairpins through the WAN. On 2026-07-28 that path was blackholed
# for hours by a dead VPN tunnel. State must be reachable when the WAN is not —
# it is what you need DURING an incident. See ../../seaweedfs/httproute-s3.yaml.
#
# CREDENTIALS are not in this file. Export them before running terraform:
# export AWS_ACCESS_KEY_ID=$(bao kv get -field=... kv/k8s/seaweedfs-s3) # see README
# export AWS_SECRET_ACCESS_KEY=...
# The `terraform` S3 identity is scoped to this bucket only — it deliberately
# cannot create buckets or read anything else in the store.
terraform {
backend "s3" {
bucket = "tfstate"
key = "netbox/terraform.tfstate"
endpoints = {
s3 = "https://s3.ad.ddupan.top"
}
# SeaweedFS is not AWS: it has no regions, no IAM, no metadata service and no
# account IDs, so every AWS-specific validation has to be skipped or the
# provider fails before it ever talks to the endpoint.
region = "us-east-1"
use_path_style = true
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
skip_requesting_account_id = true
# Native S3 locking (Terraform >= 1.10; this repo runs 1.15). Writes a
# .tflock object alongside the state — no DynamoDB table needed.
use_lockfile = true
}
}
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Generate imports.tf so Terraform ADOPTS the objects already in NetBox.
uv run --with requests --with pyyaml python netbox/terraform/gen-imports.py
Run once, when converting an already-populated NetBox to Terraform management. Without it
the first `terraform apply` tries to CREATE objects that exist and fails on uniqueness.
This is a one-shot bootstrap, not part of the normal loop: once `terraform apply` has run,
state holds the IDs and imports.tf can be deleted. Same intent as
../../../infrastructure/openbao/terraform/imports.tf.
It reads the same topology.yml Terraform does, so the resource addresses and for_each keys
line up by construction rather than by hand-transcription.
"""
from __future__ import annotations
import os
import sys
import urllib.parse
from pathlib import Path
import requests
import yaml
HERE = Path(__file__).parent
for envfile in (HERE / "../seed/.env", HERE / ".env"):
if envfile.exists():
for line in envfile.read_text().splitlines():
if line.strip() and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
URL = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/")
TOKEN = os.environ.get("NETBOX_TOKEN", "")
if not TOKEN:
sys.exit("NETBOX_TOKEN not set")
S = requests.Session()
S.headers.update({"Authorization": f"Bearer {TOKEN}"})
def one(path: str, **params) -> str | None:
r = S.get(f"{URL}/api{path}", params={**params, "limit": 1}, timeout=30)
r.raise_for_status()
res = r.json()["results"]
return str(res[0]["id"]) if res else None
def main() -> int:
t = yaml.safe_load((HERE / "topology.yml").read_text())
out: list[str] = [
"# GENERATED by gen-imports.py — one-shot bootstrap, safe to delete after the first",
"# successful `terraform apply`. Do not hand-edit.",
"",
]
missing: list[str] = []
def emit(addr: str, oid: str | None, what: str):
if oid:
out.append(f'import {{\n to = {addr}\n id = "{oid}"\n}}\n')
else:
missing.append(what)
emit("netbox_site.this", one("/dcim/sites/", slug=t["site"]["slug"]), "site")
emit("netbox_vlan_group.this", one("/ipam/vlan-groups/", slug=t["vlan_group"]["slug"]), "vlan group")
for r in t["prefix_roles"]:
emit(f'netbox_ipam_role.this["{r["slug"]}"]', one("/ipam/roles/", slug=r["slug"]), r["slug"])
for v in t["vlans"]:
emit(f'netbox_vlan.this["{v["vid"]}"]', one("/ipam/vlans/", vid=v["vid"]), f"vlan {v['vid']}")
for p in t["prefixes"]:
emit(f'netbox_prefix.this["{p["prefix"]}"]', one("/ipam/prefixes/", prefix=p["prefix"]), p["prefix"])
for r in t["ip_ranges"]:
emit(f'netbox_ip_range.this["{r["start"]}-{r["end"]}"]',
one("/ipam/ip-ranges/", start_address=r["start"], end_address=r["end"]), "ip range")
for w in t["wireless_lans"]:
emit(f'netbox_wireless_lan.this["{w["ssid"]}"]', one("/wireless/wireless-lans/", ssid=w["ssid"]), w["ssid"])
for m in t["manufacturers"]:
emit(f'netbox_manufacturer.this["{m["slug"]}"]', one("/dcim/manufacturers/", slug=m["slug"]), m["slug"])
for d in t["device_types"]:
emit(f'netbox_device_type.this["{d["slug"]}"]', one("/dcim/device-types/", slug=d["slug"]), d["slug"])
for r in t["device_roles"]:
emit(f'netbox_device_role.this["{r["slug"]}"]', one("/dcim/device-roles/", slug=r["slug"]), r["slug"])
for c in t["cluster_types"]:
emit(f'netbox_cluster_type.this["{c["slug"]}"]', one("/virtualization/cluster-types/", slug=c["slug"]), c["slug"])
for c in t["clusters"]:
emit(f'netbox_cluster.this["{c["name"]}"]', one("/virtualization/clusters/", name=c["name"]), c["name"])
for d in t["devices"]:
did = one("/dcim/devices/", name=d["name"])
emit(f'netbox_device.this["{d["name"]}"]', did, d["name"])
for i in d["interfaces"]:
key = f'{d["name"]}:{i["name"]}'
iid = one("/dcim/interfaces/", device_id=did, name=i["name"]) if did else None
emit(f'netbox_device_interface.this["{key}"]', iid, key)
if i.get("ip"):
emit(f'netbox_ip_address.device["{key}"]', one("/ipam/ip-addresses/", address=i["ip"]), i["ip"])
# netbox_device_primary_ip is deliberately NOT imported: the provider has no
# importable object at the device ID ("no object exists with the given id").
# Letting Terraform "create" it simply re-PATCHes primary_ip4 to the value it
# already holds, which NetBox treats as a no-op.
if i.get("mac"):
mac = i["mac"].upper()
mid = one("/dcim/mac-addresses/", mac_address=mac)
emit(f'netbox_mac_address.this["{key}"]', mid, mac)
if iid:
emit(f'netbox_device_interface_primary_mac_address.this["{key}"]', iid, f"{key} primary mac")
for it in d.get("inventory_items", []):
key = f'{d["name"]}:{it["name"]}'
emit(f'netbox_inventory_item.this["{key}"]',
one("/dcim/inventory-items/", device_id=did, name=it["name"]) if did else None, key)
for v in t["virtual_machines"]:
vid = one("/virtualization/virtual-machines/", name=v["name"])
emit(f'netbox_virtual_machine.this["{v["name"]}"]', vid, v["name"])
for i in v["interfaces"]:
key = f'{v["name"]}:{i["name"]}'
iid = one("/virtualization/interfaces/", virtual_machine_id=vid, name=i["name"]) if vid else None
emit(f'netbox_interface.this["{key}"]', iid, key)
if i.get("ip"):
emit(f'netbox_ip_address.vm["{key}"]', one("/ipam/ip-addresses/", address=i["ip"]), i["ip"])
(HERE / "imports.tf").write_text("\n".join(out))
n = sum(1 for line in out if line.startswith("import {"))
print(f"wrote imports.tf with {n} import blocks")
if missing:
print(f"NOT FOUND in NetBox (Terraform will create these): {', '.join(missing)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+402
View File
@@ -0,0 +1,402 @@
# GENERATED by gen-imports.py — one-shot bootstrap, safe to delete after the first
# successful `terraform apply`. Do not hand-edit.
import {
to = netbox_site.this
id = "1"
}
import {
to = netbox_vlan_group.this
id = "1"
}
import {
to = netbox_ipam_role.this["lan"]
id = "1"
}
import {
to = netbox_ipam_role.this["sdn"]
id = "2"
}
import {
to = netbox_ipam_role.this["k3s"]
id = "3"
}
import {
to = netbox_ipam_role.this["wan"]
id = "4"
}
import {
to = netbox_vlan.this["100"]
id = "1"
}
import {
to = netbox_vlan.this["110"]
id = "2"
}
import {
to = netbox_prefix.this["192.168.10.0/24"]
id = "1"
}
import {
to = netbox_prefix.this["10.60.0.0/24"]
id = "2"
}
import {
to = netbox_prefix.this["10.61.0.0/24"]
id = "3"
}
import {
to = netbox_prefix.this["10.42.0.0/16"]
id = "4"
}
import {
to = netbox_prefix.this["10.43.0.0/16"]
id = "5"
}
import {
to = netbox_prefix.this["10.1.72.0/24"]
id = "6"
}
import {
to = netbox_ip_range.this["192.168.10.10/24-192.168.10.250/24"]
id = "1"
}
import {
to = netbox_wireless_lan.this["Buffalo-A-07B0-WPA3"]
id = "1"
}
import {
to = netbox_wireless_lan.this["Buffalo-A-07B0"]
id = "2"
}
import {
to = netbox_wireless_lan.this["Buffalo-G-07B0"]
id = "3"
}
import {
to = netbox_manufacturer.this["nec"]
id = "1"
}
import {
to = netbox_manufacturer.this["intel"]
id = "2"
}
import {
to = netbox_manufacturer.this["lenovo"]
id = "3"
}
import {
to = netbox_manufacturer.this["dell"]
id = "5"
}
import {
to = netbox_manufacturer.this["buffalo"]
id = "6"
}
import {
to = netbox_manufacturer.this["yamaha"]
id = "7"
}
import {
to = netbox_manufacturer.this["broadcom"]
id = "8"
}
import {
to = netbox_device_type.this["ix2215"]
id = "1"
}
import {
to = netbox_device_type.this["nuc6i3syb"]
id = "5"
}
import {
to = netbox_device_type.this["10vgcto1ww"]
id = "6"
}
import {
to = netbox_device_type.this["xps-15-9570"]
id = "7"
}
import {
to = netbox_device_type.this["wsr-1800ax4s"]
id = "8"
}
import {
to = netbox_device_type.this["rtx1200"]
id = "9"
}
import {
to = netbox_device_role.this["router"]
id = "1"
}
import {
to = netbox_device_role.this["hypervisor"]
id = "2"
}
import {
to = netbox_device_role.this["core-node"]
id = "3"
}
import {
to = netbox_device_role.this["wireless-ap"]
id = "4"
}
import {
to = netbox_cluster_type.this["proxmox"]
id = "1"
}
import {
to = netbox_cluster_type.this["libvirt"]
id = "2"
}
import {
to = netbox_cluster.this["homelab"]
id = "1"
}
import {
to = netbox_cluster.this["laptop-libvirt"]
id = "2"
}
import {
to = netbox_device.this["ix2215"]
id = "1"
}
import {
to = netbox_device_interface.this["ix2215:GigaEthernet2.0"]
id = "1"
}
import {
to = netbox_ip_address.device["ix2215:GigaEthernet2.0"]
id = "1"
}
import {
to = netbox_device_interface.this["ix2215:GigaEthernet0.0"]
id = "2"
}
import {
to = netbox_device.this["pve1"]
id = "2"
}
import {
to = netbox_device_interface.this["pve1:vmbr0"]
id = "3"
}
import {
to = netbox_ip_address.device["pve1:vmbr0"]
id = "2"
}
import {
to = netbox_device.this["pve2"]
id = "3"
}
import {
to = netbox_device_interface.this["pve2:vmbr0"]
id = "4"
}
import {
to = netbox_ip_address.device["pve2:vmbr0"]
id = "3"
}
import {
to = netbox_device.this["pve3"]
id = "4"
}
import {
to = netbox_device_interface.this["pve3:vmbr0"]
id = "5"
}
import {
to = netbox_ip_address.device["pve3:vmbr0"]
id = "4"
}
import {
to = netbox_device.this["laptop"]
id = "5"
}
import {
to = netbox_device_interface.this["laptop:br0"]
id = "6"
}
import {
to = netbox_ip_address.device["laptop:br0"]
id = "5"
}
import {
to = netbox_inventory_item.this["laptop:BCM4360 802.11ac"]
id = "1"
}
import {
to = netbox_device.this["ap-buffalo"]
id = "6"
}
import {
to = netbox_device_interface.this["ap-buffalo:lan1"]
id = "7"
}
import {
to = netbox_ip_address.device["ap-buffalo:lan1"]
id = "12"
}
import {
to = netbox_mac_address.this["ap-buffalo:lan1"]
id = "1"
}
import {
to = netbox_device_interface_primary_mac_address.this["ap-buffalo:lan1"]
id = "7"
}
import {
to = netbox_device_interface.this["ap-buffalo:wlan-2.4g"]
id = "8"
}
import {
to = netbox_device_interface.this["ap-buffalo:wlan-5g"]
id = "9"
}
import {
to = netbox_device.this["rtx1200"]
id = "7"
}
import {
to = netbox_virtual_machine.this["vyos-rtr"]
id = "1"
}
import {
to = netbox_interface.this["vyos-rtr:eth0"]
id = "1"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth0"]
id = "6"
}
import {
to = netbox_interface.this["vyos-rtr:eth1"]
id = "2"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth1"]
id = "7"
}
import {
to = netbox_interface.this["vyos-rtr:eth2"]
id = "3"
}
import {
to = netbox_ip_address.vm["vyos-rtr:eth2"]
id = "8"
}
import {
to = netbox_virtual_machine.this["dc1"]
id = "2"
}
import {
to = netbox_interface.this["dc1:lan"]
id = "4"
}
import {
to = netbox_ip_address.vm["dc1:lan"]
id = "9"
}
import {
to = netbox_virtual_machine.this["winadmin"]
id = "3"
}
import {
to = netbox_interface.this["winadmin:lan"]
id = "5"
}
import {
to = netbox_ip_address.vm["winadmin:lan"]
id = "10"
}
import {
to = netbox_virtual_machine.this["bao1"]
id = "4"
}
import {
to = netbox_interface.this["bao1:lan"]
id = "6"
}
import {
to = netbox_ip_address.vm["bao1:lan"]
id = "11"
}
+245
View File
@@ -0,0 +1,245 @@
# NetBox object graph, driven by topology.yml.
#
# WHY yamldecode rather than HCL resources per object: topology.yml stays the readable,
# authoritative artifact (git -> NetBox, see ../CONTEXT.md §4), and Terraform supplies what
# a plain script could not — state, `plan` as a drift report, and DELETION. Removing an
# entry from the YAML now removes the object from NetBox, which the previous seed script
# never did.
#
# Ownership boundary, matching ../../../infrastructure/openbao/terraform: Terraform owns API-level
# configuration. The k8s manifests that RUN NetBox live one level up in ../.
locals {
topo = yamldecode(file("${path.module}/topology.yml"))
# --- flattened lookup maps -------------------------------------------------
# Interfaces are nested under devices/VMs in the YAML; Terraform needs flat maps keyed
# by a stable string. "<parent>:<iface>" is that key everywhere below.
device_ifaces = merge([
for d in local.topo.devices : {
for i in d.interfaces : "${d.name}:${i.name}" => merge(i, { device = d.name })
}
]...)
vm_ifaces = merge([
for v in local.topo.virtual_machines : {
for i in v.interfaces : "${v.name}:${i.name}" => merge(i, { vm = v.name })
}
]...)
# Only interfaces that actually carry an address.
device_ips = { for k, i in local.device_ifaces : k => i if try(i.ip, null) != null }
vm_ips = { for k, i in local.vm_ifaces : k => i if try(i.ip, null) != null }
# The single address that becomes the parent's primary_ip4.
device_primary = { for k, i in local.device_ips : i.device => k if try(i.primary, false) }
vm_primary = { for k, i in local.vm_ips : i.vm => k if try(i.primary, false) }
device_macs = { for k, i in local.device_ifaces : k => i if try(i.mac, null) != null }
inventory_items = merge([
for d in local.topo.devices : {
for it in try(d.inventory_items, []) : "${d.name}:${it.name}" => merge(it, { device = d.name })
}
]...)
}
# --- site + IPAM ---------------------------------------------------------------
resource "netbox_site" "this" {
name = local.topo.site.name
slug = local.topo.site.slug
description = local.topo.site.description
status = "active"
}
resource "netbox_ipam_role" "this" {
for_each = { for r in local.topo.prefix_roles : r.slug => r }
name = each.value.name
slug = each.value.slug
}
resource "netbox_vlan_group" "this" {
name = local.topo.vlan_group.name
slug = local.topo.vlan_group.slug
description = local.topo.vlan_group.description
# Required by the provider. The SDN zone is a plain VLAN zone on vmbr0, which is
# bridge-vlan-aware for the full range, so do not narrow this without changing that.
vid_ranges = [[1, 4094]]
}
resource "netbox_vlan" "this" {
for_each = { for v in local.topo.vlans : tostring(v.vid) => v }
vid = each.value.vid
name = each.value.name
group_id = netbox_vlan_group.this.id
site_id = netbox_site.this.id
status = "active"
}
resource "netbox_prefix" "this" {
for_each = { for p in local.topo.prefixes : p.prefix => p }
prefix = each.value.prefix
status = "active"
# The provider exposes plain `site_id` and handles NetBox 4.2+'s generic
# scope_type/scope_id internally — which is exactly the trap that broke the hand-rolled
# script (posting `site` was silently dropped). Using the provider avoids it.
site_id = netbox_site.this.id
role_id = netbox_ipam_role.this[each.value.role].id
vlan_id = try(netbox_vlan.this[tostring(each.value.vlan)].id, null)
description = each.value.description
}
resource "netbox_ip_range" "this" {
for_each = { for r in local.topo.ip_ranges : "${r.start}-${r.end}" => r }
start_address = each.value.start
end_address = each.value.end
status = each.value.status
mark_utilized = try(each.value.mark_utilized, false)
description = each.value.description
}
# --- Wi-Fi ---------------------------------------------------------------------
# ⚠ PARTIAL: the provider can create the SSIDs but has NO attribute for attaching them to
# a radio interface, and none for `rf_role`. Neither does the netbox.netbox Ansible
# collection. That last mile is done by ./attach-wireless.py — see ../README.md.
resource "netbox_wireless_lan" "this" {
for_each = { for w in local.topo.wireless_lans : w.ssid => w }
ssid = each.value.ssid
auth_type = each.value.auth_type
auth_cipher = each.value.auth_cipher
description = each.value.description
# auth_psk deliberately unset: OpenBao is the secrets store, not NetBox.
}
# --- hardware ------------------------------------------------------------------
resource "netbox_manufacturer" "this" {
for_each = { for m in local.topo.manufacturers : m.slug => m }
name = each.value.name
slug = each.value.slug
}
resource "netbox_device_type" "this" {
for_each = { for d in local.topo.device_types : d.slug => d }
model = each.value.model
slug = each.value.slug
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
# Same reason as vm_role above: NetBox's default is true, so pin it or every plan wants
# to clear it. Meaningless for this hardware (nothing is rack-mounted) but stops churn.
is_full_depth = true
}
resource "netbox_device_role" "this" {
for_each = { for r in local.topo.device_roles : r.slug => r }
name = each.value.name
slug = each.value.slug
color_hex = each.value.color
# NetBox defaults this to true; the provider defaults it to false, so without pinning it
# every plan shows a spurious vm_role true -> false diff.
vm_role = true
}
resource "netbox_device" "this" {
for_each = { for d in local.topo.devices : d.name => d }
name = each.value.name
site_id = netbox_site.this.id
role_id = netbox_device_role.this[each.value.role].id
device_type_id = netbox_device_type.this[each.value.type].id
description = each.value.description
comments = try(each.value.comments, "")
serial = try(each.value.serial, "")
status = try(each.value.status, "active")
}
resource "netbox_device_interface" "this" {
for_each = local.device_ifaces
device_id = netbox_device.this[each.value.device].id
name = each.value.name
type = each.value.type
description = try(each.value.description, "")
mtu = try(each.value.mtu, null)
}
resource "netbox_inventory_item" "this" {
for_each = local.inventory_items
device_id = netbox_device.this[each.value.device].id
name = each.value.name
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
part_id = try(each.value.part_id, "")
serial = try(each.value.serial, "")
description = try(each.value.description, "")
}
# MACs are first-class objects in NetBox 4.2+; `mac_address` on the interface is read-only.
resource "netbox_mac_address" "this" {
for_each = local.device_macs
mac_address = upper(each.value.mac)
device_interface_id = netbox_device_interface.this[each.key].id
}
resource "netbox_device_interface_primary_mac_address" "this" {
for_each = local.device_macs
interface_id = netbox_device_interface.this[each.key].id
mac_address_id = netbox_mac_address.this[each.key].id
}
# --- virtualization ------------------------------------------------------------
resource "netbox_cluster_type" "this" {
for_each = { for c in local.topo.cluster_types : c.slug => c }
name = each.value.name
slug = each.value.slug
}
resource "netbox_cluster" "this" {
for_each = { for c in local.topo.clusters : c.name => c }
name = each.value.name
cluster_type_id = netbox_cluster_type.this[each.value.type].id
description = each.value.description
site_id = netbox_site.this.id
}
resource "netbox_virtual_machine" "this" {
for_each = { for v in local.topo.virtual_machines : v.name => v }
name = each.value.name
cluster_id = netbox_cluster.this[each.value.cluster].id
description = each.value.description
# NetBox DERIVES a VM's site from its cluster. Leaving this unset makes the provider
# try to clear it on every plan (site_id 1 -> None), so declare it to match.
site_id = netbox_site.this.id
}
resource "netbox_interface" "this" {
for_each = local.vm_ifaces
virtual_machine_id = netbox_virtual_machine.this[each.value.vm].id
name = each.value.name
description = try(each.value.description, "")
}
# --- addresses -----------------------------------------------------------------
resource "netbox_ip_address" "device" {
for_each = local.device_ips
ip_address = each.value.ip
status = "active"
# No `object_type` here: the provider pairs that with the GENERIC `interface_id`
# ("all of interface_id,object_type must be specified"). The dedicated
# *_interface_id attributes are standalone and imply the type.
device_interface_id = netbox_device_interface.this[each.key].id
# Native NetBox field. Setting it is INTENT: "this host needs a static A record in AD
# DNS". Domain-joined hosts self-register and are deliberately absent.
# ../generate/samba-a-records.py turns these into samba_ad_extra_a_records.
dns_name = try(each.value.dns_name, "")
}
resource "netbox_ip_address" "vm" {
for_each = local.vm_ips
ip_address = each.value.ip
status = "active"
virtual_machine_interface_id = netbox_interface.this[each.key].id
dns_name = try(each.value.dns_name, "")
}
# primary_ip4 lives on the parent, so the provider models it as its own resource.
resource "netbox_device_primary_ip" "this" {
for_each = local.device_primary
device_id = netbox_device.this[each.key].id
ip_address_id = netbox_ip_address.device[each.value].id
}
+14
View File
@@ -0,0 +1,14 @@
output "site_id" {
value = netbox_site.this.id
description = "NetBox ID of the Homelab site."
}
output "prefix_ids" {
value = { for k, p in netbox_prefix.this : k => p.id }
description = "prefix -> NetBox ID, for cross-referencing from other tooling."
}
output "device_ids" {
value = { for k, d in netbox_device.this : k => d.id }
description = "device name -> NetBox ID."
}
@@ -0,0 +1,2 @@
# Copy to terraform.tfvars (gitignored) and fill in, or export TF_VAR_netbox_token.
netbox_token = "nbt_xxxxxxxx.yyyyyyyy"
+262
View File
@@ -0,0 +1,262 @@
# Homelab topology — the INPUT to NetBox, not a dump of it.
#
# This file is deliberately the authoritative artifact: git stays the source of truth
# and NetBox is a derived mirror, populated by ./seed.py. That answers the design
# question in ../CONTEXT.md §4 the way the rest of this repo works — a UI you must
# click to change routing would be a regression against every other service here.
#
# Facts mirror CONTEXT.md §3 (verified live 2026-07-25). Interface names are verified,
# not guessed, EXCEPT where marked `# placeholder`.
site:
name: Homelab
slug: homelab
description: "Single flat 1G LAN on one unmanaged switch, 192.168.10.0/24"
# --- Layer 3 -----------------------------------------------------------------
prefixes:
- prefix: 192.168.10.0/24
role: lan
description: "LAN. Flat L2 across one dumb switch; OSPF area 0 runs here."
- prefix: 10.60.0.0/24
role: sdn
vlan: 100
description: "PVE SDN VNet labnet. Gateway 10.60.0.1 on vyos eth1."
- prefix: 10.61.0.0/24
role: sdn
vlan: 110
description: "PVE SDN VNet retronet. Gateway 10.61.0.1 on vyos eth2."
- prefix: 10.42.0.0/16
role: k3s
description: "k3s pod CIDR (laptop). Not routed off-node."
- prefix: 10.43.0.0/16
role: k3s
description: "k3s service CIDR (laptop). Not routed off-node."
- prefix: 10.1.72.0/24
role: wan
description: "WAN side of the NEC IX (GigaEthernet0.0)."
prefix_roles:
- { name: LAN, slug: lan }
- { name: SDN VNet, slug: sdn }
- { name: Kubernetes, slug: k3s }
- { name: WAN, slug: wan }
# The router's DHCP pool (CONTEXT.md §4 called this out specifically).
#
# There is no `dhcp` status — IPRange offers only active/reserved/deprecated. The pool
# concept is the `mark_utilized` boolean ("Report space as fully utilized"), which makes
# NetBox stop offering those addresses as available.
#
# Be precise about what this buys: it does NOT hard-block an allocation inside the
# range. It makes the collision VISIBLE — the range shows 100% utilised and the
# address never appears as a suggestion — where plain YAML shows nothing at all.
ip_ranges:
- start: 192.168.10.10/24
end: 192.168.10.250/24
status: active
mark_utilized: true
description: "NEC IX DHCP pool — do NOT statically allocate inside this."
vlan_group:
name: lab
slug: lab
description: "PVE SDN zone `lab` (type vlan, bridge vmbr0). Segmentation, NOT security."
vlans:
- { vid: 100, name: labnet, prefix: 10.60.0.0/24 }
- { vid: 110, name: retronet, prefix: 10.61.0.0/24 }
# --- Wi-Fi -------------------------------------------------------------------
# Broadcast by ap-buffalo (below). No `vlan:` on any of them: the AP bridges, so wireless
# clients land UNTAGGED on the flat LAN and pick up an address from the IX DHCP pool.
# They are on the same L2 as everything else — the Wi-Fi is not a separate segment.
#
# ⚠ NetBox has no WPA3 auth_type — the choices are open/wep/wpa-personal/wpa-enterprise,
# so WPA2-PSK and WPA3-SAE both land on `wpa-personal`. The real difference is recorded in
# the description because the model cannot express it.
#
# auth_psk is deliberately LEFT EMPTY. NetBox can store the passphrase, but that would put
# the house Wi-Fi key in a system whose own DB backup story is untested; OpenBao is the
# secrets store here (see ../../../infrastructure/openbao).
wireless_lans:
- ssid: Buffalo-A-07B0-WPA3
auth_type: wpa-personal
auth_cipher: aes
description: "5 GHz, WPA3-SAE. Preferred SSID for clients that support it."
- ssid: Buffalo-A-07B0
auth_type: wpa-personal
auth_cipher: aes
description: "5 GHz, WPA2-PSK. Compatibility SSID for clients that cannot do WPA3."
- ssid: Buffalo-G-07B0
auth_type: wpa-personal
auth_cipher: aes
description: "2.4 GHz, WPA2-PSK. Range/IoT band."
# --- Layer 2 / hardware ------------------------------------------------------
# Model/serial values below are READ FROM THE HARDWARE (`dmidecode -s ...`), not guessed.
manufacturers:
- { name: NEC, slug: nec }
- { name: Intel, slug: intel }
- { name: Lenovo, slug: lenovo }
- { name: Dell, slug: dell }
- { name: Buffalo, slug: buffalo }
- { name: Yamaha, slug: yamaha }
- { name: Broadcom, slug: broadcom }
device_types:
- { model: IX2215, slug: ix2215, manufacturer: nec }
# dmidecode: system-manufacturer/product/serial are all BLANK on this NUC (the OEM
# never programmed them). baseboard-product-name is the only real identifier, and it
# is SYB (the board), not the SYH chassis this was previously guessed to be.
- { model: NUC6i3SYB, slug: nuc6i3syb, manufacturer: intel }
# Lenovo's machine-type; this is the ThinkCentre M715q Tiny.
- { model: 10VGCTO1WW, slug: 10vgcto1ww, manufacturer: lenovo }
- { model: XPS 15 9570, slug: xps-15-9570, manufacturer: dell }
- { model: WSR-1800AX4S, slug: wsr-1800ax4s, manufacturer: buffalo }
- { model: RTX1200, slug: rtx1200, manufacturer: yamaha }
device_roles:
- { name: Router, slug: router, color: f44336 }
- { name: Hypervisor, slug: hypervisor, color: 2196f3 }
- { name: Core Node, slug: core-node, color: 4caf50 }
- { name: Wireless AP, slug: wireless-ap, color: ff9800 }
devices:
- name: ix2215
role: router
type: ix2215
description: "NEC IX. Gateway, OSPF area 0, BGP, DNS proxy, DHCP server."
interfaces:
- { name: GigaEthernet2.0, type: 1000base-t, ip: 192.168.10.1/24, primary: true, dns_name: gw.ad.ddupan.top }
- { name: GigaEthernet0.0, type: 1000base-t, ip: null, description: "WAN uplink, 10.1.72.0/24" }
- name: pve1
role: hypervisor
type: nuc6i3syb
description: "Proxmox VE 9.2. LINSTOR controller."
# No serial: this NUC reports blank system-serial-number (see device_types note).
comments: "Intel Core i3-6100U @ 2.30GHz, 4 threads, 15 GiB RAM. BIOS SYSKLi35.86A.0045.2016.0527.1055. Board NUC6i3SYB."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.4/24, primary: true, mtu: 9000, dns_name: pve1.ad.ddupan.top }
- name: pve2
role: hypervisor
type: 10vgcto1ww
serial: PC1AGX1Q
description: "Proxmox VE 9.2. LINSTOR satellite. The node that randomly froze."
comments: "AMD Ryzen 5 PRO 2400GE w/ Vega, 8 threads, 7 GiB RAM. BIOS M1XKT45A. Raven Ridge idle bug fixed in BIOS: Power Supply Idle Control = Typical Current Idle."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.7/24, primary: true, mtu: 9000, dns_name: pve2.ad.ddupan.top }
- name: pve3
role: hypervisor
type: 10vgcto1ww
serial: PC1AGX1P
description: "Proxmox VE 9.2. LINSTOR satellite."
comments: "AMD Ryzen 5 PRO 2400GE w/ Vega, 8 threads, 7 GiB RAM. BIOS M1XKT55A. Same silicon as pve2, so susceptible to the same idle bug in principle."
interfaces:
- { name: vmbr0, type: bridge, ip: 192.168.10.9/24, primary: true, mtu: 9000, dns_name: pve3.ad.ddupan.top }
- name: laptop
role: core-node
type: xps-15-9570
serial: 6R7CQQ2
description: "Core node, NOT a PVE cluster member. k3s, NFS, libvirt host, netboot.xyz, OSPF DR. Single point of failure for most of the lab."
comments: "Intel Core i7-8750H @ 2.20GHz, 12 threads, 30 GiB RAM. BIOS 1.20.0. Nvidia dGPU stays bare-metal for nvidia-container-toolkit. Built-in battery acts as a UPS."
interfaces:
- { name: br0, type: bridge, ip: 192.168.10.127/24, primary: true }
inventory_items:
# Present in hardware but NOT usable, so it is an inventory item rather than an
# interface — there is no netdev for it.
#
# `lspci -k` shows bcma-pci-bridge bound and b43 loaded, but b43 does NOT support
# BCM4360; that chip needs Broadcom's proprietary `wl` (broadcom-sta) driver with
# b43/bcma/ssb blacklisted. Until then the laptop cannot scan or join Wi-Fi.
- name: BCM4360 802.11ac
manufacturer: broadcom
part_id: "14e4:43a0"
description: "PCI 3b:00.0, Apple-subsystem card. No driver: b43 claims it but cannot drive BCM4360; needs broadcom-sta (wl)."
# Wi-Fi. Runs as an AP/bridge, not a router — the NEC IX is the gateway, so this box's
# routing, NAT and DHCP are not in play. Wireless clients land directly on the flat LAN.
#
# ⚠ Its address .10 is the FIRST ADDRESS OF THE DHCP POOL above. Either it holds a lease
# (so the address can move) or it is a static that overlaps the pool. NetBox surfaces
# the overlap; the underlying config still needs a decision. See ../README.md.
#
# Identified by MAC OUI d4:2c:46 = BUFFALO.INC plus the model string on its login page.
- name: ap-buffalo
role: wireless-ap
type: wsr-1800ax4s
description: "Buffalo AirStation, AP/bridge mode. Provides the house Wi-Fi."
comments: "Wi-Fi 6 (802.11ax) dual band. Web UI on http://192.168.10.10/. Model read from its login page; serial not recorded (needs the label or an authenticated session)."
interfaces:
- { name: lan1, type: 1000base-t, ip: 192.168.10.10/24, primary: true, mac: "d4:2c:46:09:07:b0", dns_name: ap.ad.ddupan.top, description: "Uplink to the dumb switch" }
# Buffalo's factory SSID scheme: A = 5 GHz, G = 2.4 GHz, and the suffix is the tail
# of this AP's own MAC (d4:2c:46:09:07:b0 -> 07B0), which independently corroborates
# that this device is the AP.
- name: wlan-2.4g
type: ieee802.11ax
rf_role: ap
description: "2.4 GHz radio"
wireless_lans: [Buffalo-G-07B0]
- name: wlan-5g
type: ieee802.11ax
rf_role: ap
description: "5 GHz radio"
wireless_lans: [Buffalo-A-07B0-WPA3, Buffalo-A-07B0]
# Spare/shelf kit. Recorded so it is not forgotten — knowing what you own and are NOT
# using is a legitimate reason to run a DCIM tool.
- name: rtx1200
role: router
type: rtx1200
status: inventory # NOT active: unplugged, no addresses, not cabled
description: "Yamaha RTX1200. Spare — not in use."
comments: "Gigabit VPN router. Kept as a spare / potential replacement for the NEC IX. Serial not recorded (would need the chassis label)."
interfaces: []
# --- Virtual machines --------------------------------------------------------
cluster_types:
- { name: Proxmox VE, slug: proxmox }
- { name: libvirt, slug: libvirt }
clusters:
- { name: homelab, type: proxmox, description: "3-node PVE cluster, no HA, LINSTOR place-count 2." }
- { name: laptop-libvirt, type: libvirt, description: "libvirt guests on the laptop." }
virtual_machines:
- name: vyos-rtr
cluster: homelab
description: "VyOS 2025.11. SDN gateway, OSPF area 0. VM 100. Routed, not NAT'd."
interfaces:
- { name: eth0, ip: 192.168.10.2/24, primary: true, dns_name: vyos-rtr.ad.ddupan.top, description: "LAN" }
- { name: eth1, ip: 10.60.0.1/24, description: "labnet gateway (VLAN 100), OSPF passive" }
- { name: eth2, ip: 10.61.0.1/24, description: "retronet gateway (VLAN 110), OSPF passive" }
# Found by diffing NetBox against samba_ad_extra_a_records — it had a live A record and
# was missing from NetBox entirely. Verified up at 10.60.0.10.
- name: retrolab
cluster: homelab
description: "AD-joined XFCE/xrdp host for running 86Box. Lives on labnet (VLAN 100)."
interfaces:
- { name: eth0, ip: 10.60.0.10/24, primary: true, dns_name: retrolab.ad.ddupan.top }
- name: dc1
cluster: laptop-libvirt
description: "Samba AD DC, authoritative for ad.ddupan.top."
interfaces:
- { name: lan, ip: 192.168.10.5/24, primary: true } # placeholder: NIC name not verified
- name: winadmin
cluster: laptop-libvirt
description: "Windows Server 2025 admin box."
interfaces:
- { name: lan, ip: 192.168.10.6/24, primary: true } # placeholder: NIC name not verified
- name: bao1
cluster: laptop-libvirt
description: "OpenBao — internal CA + secrets store."
interfaces:
- { name: lan, ip: 192.168.10.8/24, primary: true, dns_name: bao.ad.ddupan.top } # placeholder: NIC name not verified
+15
View File
@@ -0,0 +1,15 @@
variable "netbox_url" {
type = string
description = "Base URL of NetBox, WITHOUT the /api suffix."
default = "https://netbox.ad.ddupan.top"
}
variable "netbox_token" {
type = string
sensitive = true
description = <<-EOT
NetBox API token. A v2 token (nbt_<key>.<secret>) works fine — NetBox dispatches on the
token value, not the Authorization scheme. Mint one with the snippet in ../README.md.
Supply via TF_VAR_netbox_token or terraform.tfvars (gitignored).
EOT
}
+20
View File
@@ -0,0 +1,20 @@
terraform {
required_version = ">= 1.5"
required_providers {
netbox = {
source = "e-breuninger/netbox"
# ⚠ Pin to 5.x. Provider 4.3.1 FAILS against NetBox 4.6 at provider-configure time
# with a go-openapi error ("... is not supported by the TextConsumer"); 5.7.0 works.
version = "~> 5.0"
}
}
}
# Same pattern as ../../../infrastructure/openbao/terraform and ../../../infrastructure/cloudflared/terraform: credentials come
# from outside the repo, state is local and gitignored.
provider "netbox" {
# ⚠ BASE URL ONLY — no /api suffix. Passing ".../api" produces the same misleading
# TextConsumer error as the version mismatch above and costs an hour to diagnose.
server_url = var.netbox_url
api_token = var.netbox_token
}
+190
View File
@@ -0,0 +1,190 @@
# NetBox — IPAM + DCIM, candidate "source of truth for network facts".
# THIS IS AN EVALUATION DEPLOYMENT (see CONTEXT.md §1: adoption is not decided).
# Nothing else in the repo reads from NetBox yet; it is safe to delete outright.
#
# Chart: netbox/netbox 8.3.38 (app v4.6.5)
# helm repo add netbox https://netbox-community.github.io/netbox-chart/
#
# Shape mirrors the other k3s services here: external shared Postgres, private
# exposure over the Tailscale ingress, authentication delegated to Authelia OIDC.
# Pin the app image; the chart default matches but drifts on every chart bump.
image:
tag: v4.6.5
# --- Local break-glass superuser -------------------------------------------
# Deliberately kept, exactly like Grafana's local admin: NetBox's SSO group->
# superuser mapping does not exist (that feature is LDAP-only, see README), so
# the first SSO login lands as an ordinary user and needs promoting BY someone.
# Password is generated by the chart and preserved in the netbox-superuser Secret.
superuser:
name: admin
email: [email protected]
# NetBox refuses writes via any hostname not listed here. The pod IP is appended
# automatically (allowedHostsIncludesPodIP) for the k8s probes.
allowedHosts:
- netbox.ad.ddupan.top
- netbox.netbox.svc.cluster.local
- localhost
# Envoy terminates TLS and forwards plain HTTP, so Django sees an http:// request
# carrying an https:// Origin header. Without this, every POST (including the login
# form) fails CSRF verification — it presents as "login is broken", not "config is
# missing". Same requirement behind any TLS-terminating proxy.
csrf:
trustedOrigins:
- https://netbox.ad.ddupan.top
# It is an infrastructure source of truth on a private tailnet, but "on the
# tailnet" is not authentication — require a login for read access too.
#
# This logs a benign FutureWarning at startup ("LOGIN_REQUIRED is deprecated ...
# can be removed from your configuration file"): NetBox v5.0 drops the setting and
# makes login mandatory, i.e. true is the direction of travel. The warning is
# unavoidable via this chart — the ConfigMap always emits the key, and NetBox warns
# on false as well ("unauthenticated access will no longer be supported").
loginRequired: true
# Everything else in this homelab is UTC (the node is Etc/UTC); stay consistent
# so timestamps line up with VictoriaLogs.
timeZone: UTC
# Version check phones home to api.github.com. The WAN flaps (CONTEXT.md §6) and
# a blocking outbound call on page render is exactly what we don't want.
releaseCheck:
url: ""
# --- Authentication: Authelia forward-auth (trusted headers) --------------
# Authelia authenticates + enforces 2FA at the GATEWAY (securitypolicy.yaml); by the
# time a request arrives here it is already authenticated, and Envoy has attached
# Remote-* headers describing who the user is.
#
# WHY NOT OIDC (which this used to be): NetBox has NO SSO group -> role mapping.
# REMOTE_AUTH_SUPERUSER_GROUPS and AUTH_LDAP_USER_FLAGS_BY_GROUP are LDAP-only; the
# social-auth pipeline only runs user_default_groups_handler, so an OIDC user landed
# as an ordinary member of one static group and had to be promoted BY HAND, and AD
# group changes never propagated. With header auth, REMOTE_AUTH_GROUP_SYNC_ENABLED
# re-evaluates group membership on EVERY request — the same declarative AD-group
# pattern already used by Grafana and Proxmox.
#
# ⚠⚠ THIS SETTING IS ONLY SAFE BEHIND THE GATEWAY. RemoteUserBackend trusts the
# header unconditionally — NetBox has no trusted-proxy allowlist. Two things keep
# that honest, and BOTH must stay true:
# 1. Envoy sets these headers from Authelia's response, and Envoy Gateway's
# headersToBackend OVERRIDES any client-supplied value ("coexisting headers
# will be overridden"), so a spoofed Remote-User cannot survive the hop.
# 2. networkpolicy.yaml restricts pod ingress to the gateway namespace, so nothing
# in-cluster can bypass Envoy and talk to :8080 directly.
# Removing either one turns `Remote-User: admin` into an instant superuser.
remoteAuth:
enabled: true
backends:
- netbox.authentication.RemoteUserBackend
# Authelia's header names. NetBox's defaults assume HTTP_REMOTE_USER_GROUP, but
# Authelia emits Remote-Groups -> HTTP_REMOTE_GROUPS, and joins values with a
# COMMA where NetBox defaults to "|". Both must be overridden or group sync
# silently yields one group literally named "a,b,c".
header: HTTP_REMOTE_USER
groupHeader: HTTP_REMOTE_GROUPS
groupSeparator: ","
# Authelia sends Remote-Email and Remote-Name (a single display name); it has no
# split given/family name, so the first/last-name headers are left unmapped.
userEmail: HTTP_REMOTE_EMAIL
autoCreateUser: true
autoCreateGroups: true # mirror AD groups into NetBox groups as they appear
groupSyncEnabled: true # re-evaluate membership on every request
# AD group -> NetBox role. Managed in ../../infrastructure/samba-ad (samba_ad_groups).
superuserGroups:
- netbox-admins
staffGroups:
- netbox-admins # is_staff => access to the Django admin site
# The OIDC client config that used to live here (SOCIAL_AUTH_OIDC_*) is gone; the
# netbox-secrets Secret now only carries the database password.
extraConfig: []
# --- Postgres: the shared CNPG cluster ------------------------------------
# Dedicated role + database, no shared superuser — same as Authelia and Gitea.
postgresql:
enabled: false
externalDatabase:
host: shared-postgresql-rw.shared-db.svc.cluster.local
port: 5432
database: netbox
username: netbox
existingSecretName: netbox-secrets
existingSecretKey: postgresql-password
# --- Valkey (Redis) for the RQ task queue + caching -----------------------
# Bundled subchart rather than a shared instance: nothing else in the cluster
# runs Redis, and NetBox wants two logical databases of its own.
#
# ⚠ Bitnami's public catalog no longer serves versioned tags (only `latest`,
# versioned images moved to the `bitnamilegacy` repo), which is why the chart
# ships `tag: latest`. `latest` is not reproducible, so pin the DIGEST instead —
# resolved 2026-07-25 for bitnami/valkey:latest.
valkey:
image:
digest: sha256:5d43ca8bb57aa263ef78d1684dbf1e4b4f63844727eafdd5ea2b0e102a25b141
# Single node: the chart default is primary+replica, which buys nothing here
# (one k8s node, no HA anywhere in this cluster — CONTEXT.md §6).
architecture: standalone
primary:
persistence:
enabled: true
storageClass: localpv-zfs-ceph
size: 1Gi
resourcesPreset: micro
# --- Storage --------------------------------------------------------------
# Media = uploaded images/attachments only; the real data is in Postgres.
#
# ⚠ NOT localpv-zfs-ceph, unlike every other PVC here. The chart mounts this ONE
# RWO claim into BOTH Deployments (web and worker — the worker writes files the web
# serves), and the OpenEBS ZFS class provisions a **zvol**: a block device with xfs
# on it, which cannot be mounted twice. Whichever pod won the race mounted it and
# the other stuck in Init forever with
# verifyMount: device already mounted at [...]
# from the CSI node plugin. local-path is hostPath-based, so kubelet can bind-mount
# the same directory into both pods. (localpv-zfs-ceph stays correct for the valkey
# PVC below — single StatefulSet pod, no sharing.)
persistence:
enabled: true
storageClass: local-path
size: 2Gi
# --- LAN exposure via the Contour gateway ---------------------------------
# https://netbox.ad.ddupan.top — reachable from any LAN host, no Tailscale client
# needed, and it does not traverse the WAN. Deliberately NOT on the cloudflared
# tunnel: a full inventory of the network is not something to publish.
#
# Gateway API, not Ingress. The shared gateway lives in ../../platform/envoy-gateway and already
# terminates TLS with the *.ad.ddupan.top wildcard from ../../platform/cert-manager — so this
# service needs no cert of its own, just this route plus a DNS A record on the DC
# (../../infrastructure/samba-ad). Authentication is enforced at the gateway by the SecurityPolicy in
# securitypolicy.yaml, before a request ever reaches this pod.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: eg
namespace: envoy-gateway-system
sectionName: https # the :443 listener; :80 is for ACME/redirects only
hostnames:
- netbox.ad.ddupan.top
# --- Resource budget ------------------------------------------------------
# The laptop is the single k3s node and already runs everything (CONTEXT.md §6);
# at survey time it had ~4 GiB RAM headroom. Keep this deployment modest.
resourcesPreset: medium # 500m/1Gi requests, 750m/1.5Gi limits
worker:
resourcesPreset: small # 500m/512Mi requests — one worker, no bulk jobs yet
# Nightly changelog/job pruning. Retentions are the chart defaults (90 days).
housekeeping:
enabled: true
schedule: "17 4 * * *"
+7
View File
@@ -0,0 +1,7 @@
# OpenViking container ports
OPENVIKING_HTTP_PORT=1933
OPENVIKING_UI_PORT=8020
JINA_HTTP_PORT=8081
# Optional: disable the bundled VikingBot UI/agent helper if you do not need it.
OPENVIKING_WITH_BOT=1
+65
View File
@@ -0,0 +1,65 @@
# OpenViking (Docker)
This folder contains a Docker Compose setup for OpenViking using the official image.
## What this setup assumes
- VLM provider: `openai-codex`
- Embedding model family: `jina-embeddings-v5-text-small-clustering`
- Embedding server: local OpenAI-compatible endpoint powered by `llama.cpp` CUDA12 image
- Persistent OpenViking data lives in `./data`
- Persistent embedding model cache lives in `./models` (mounted to Hugging Face cache)
- HTTP API is exposed on `1933`
- Console/UI is exposed on `8020`
- Local embedding endpoint is exposed on `127.0.0.1:8081`
## Files
- `docker-compose.yml` — the container definition
- `.env.example` — optional port/bot defaults
- `ov.conf.example` — configuration example using local Jina embeddings + Codex VLM
- `ovcli.conf.example` — CLI/client config example
## Setup
1. Copy the env template:
```bash
cp .env.example .env
```
2. Create the persistent directories and copy the config examples:
```bash
mkdir -p data models
cp ov.conf.example data/ov.conf
cp ovcli.conf.example data/ovcli.conf
mkdir -p data/workspace
```
3. Start the containers:
```bash
docker compose up -d
```
4. If Codex auth is not yet available, complete the login/import step inside the OpenViking container:
```bash
docker compose exec -it openviking openviking-server init
```
5. Verify the server:
```bash
docker compose exec -it openviking openviking-server doctor
curl http://localhost:1933/health
```
## Notes
- The embedding service uses the Jina v5 omni small text-matching GGUF model and serves an OpenAI-compatible `/v1` API locally.
- `provider: "openai"` is used in `ov.conf` because the embedding endpoint is OpenAI-compatible, even though the underlying model is Jina.
- `provider: "openai-codex"` does not require `vlm.api_key` once Codex OAuth is available through `openviking-server init`.
- If you want the bundled VikingBot disabled, set `OPENVIKING_WITH_BOT=0` in `.env`.
- The first startup can take a while because the Jina model must be downloaded into `./models`.
View File
+26
View File
@@ -0,0 +1,26 @@
{
"storage": {
"workspace": "/app/.openviking/workspace",
"vectordb": {
"name": "context",
"backend": "local"
},
"agfs": {
"backend": "local"
}
},
"embedding": {
"dense": {
"api_base": "http://jina-embeddings:8080/v1",
"api_key": "local-jina",
"provider": "openai",
"dimension": 1024,
"model": "jinaai/jina-embeddings-v5-omni-small-text-matching-GGUF"
}
},
"vlm": {
"api_base": "https://chatgpt.com/backend-api/codex",
"provider": "openai-codex",
"model": "gpt-5.3-codex"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"url": "http://localhost:1933",
"timeout": 60.0
}
+40
View File
@@ -0,0 +1,40 @@
services:
jina-embeddings:
image: ghcr.io/ggml-org/llama.cpp:server-cuda12-b8914
container_name: jina-embeddings
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
ports:
- "127.0.0.1:${JINA_HTTP_PORT:-8081}:8080"
volumes:
- ./models:/root/.cache/huggingface
command:
- -hf
- jinaai/jina-embeddings-v5-omni-small-clustering-GGUF:Q2_M
- --embedding
- --host
- "0.0.0.0"
- --port
- "8080"
restart: unless-stopped
openviking:
image: ghcr.io/volcengine/openviking:latest
container_name: openviking
depends_on:
- jina-embeddings
ports:
- "${OPENVIKING_HTTP_PORT:-1933}:1933"
- "${OPENVIKING_UI_PORT:-8020}:8020"
volumes:
- ./data:/app/.openviking
environment:
- OPENVIKING_CONFIG_FILE=/app/.openviking/ov.conf
- OPENVIKING_CLI_CONFIG_FILE=/app/.openviking/ovcli.conf
- OPENVIKING_WITH_BOT=${OPENVIKING_WITH_BOT:-1}
restart: unless-stopped
+26
View File
@@ -0,0 +1,26 @@
{
"storage": {
"workspace": "/app/.openviking/workspace",
"vectordb": {
"name": "context",
"backend": "local"
},
"agfs": {
"backend": "local"
}
},
"embedding": {
"dense": {
"api_base": "http://jina-embeddings:8080/v1",
"api_key": "local-jina",
"provider": "openai",
"dimension": 1024,
"model": "jinaai/jina-embeddings-v5-omni-small-text-matching-GGUF"
}
},
"vlm": {
"api_base": "https://chatgpt.com/backend-api/codex",
"provider": "openai-codex",
"model": "gpt-5.3-codex"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"url": "http://localhost:1933",
"timeout": 60.0
}
+14
View File
@@ -0,0 +1,14 @@
version: "3.9"
services:
ps3netsrv:
image: shawly/ps3netsrv:latest
container_name: ps3netsrv
restart: unless-stopped
environment:
TZ: Asia/Tokyo
USER_ID: "1000"
GROUP_ID: "1000"
ports:
- "38008:38008"
volumes:
- "/mnt/pool/games/ps3:/games:rw"
+34
View File
@@ -0,0 +1,34 @@
# SeaweedFS (S3 storage)
**Purpose**
- Deploy SeaweedFS as the S3-compatible object store with the official Helm chart.
- Use chart-managed persistent storage and expose the admin UI through Tailscale.
**Files**
| File | Description |
| --- | --- |
| `values.yaml` | Helm values for the official SeaweedFS chart. |
| `helm.sh` | Installs or upgrades the SeaweedFS release. |
**Install**
1. Set real S3 access and secret keys in `values.yaml`.
2. Apply the manifests:
```bash
bash ~/services/apps/seaweedfs/helm.sh
```
**Access**
- Inside the cluster, the S3 endpoint is `http://seaweedfs-s3.seaweedfs.svc.cluster.local:8333`.
- The filer UI is available at `http://seaweedfs-filer.seaweedfs.svc.cluster.local:8888`.
- Public access is routed through Cloudflare Tunnel at `https://obj.ddupan.top`.
- The admin UI is exposed through Tailscale on the `seaweedfs-admin` Ingress.
- For local testing, use port-forward:
```bash
kubectl -n seaweedfs port-forward svc/seaweedfs-s3 8333:8333 \
svc/seaweedfs-filer 8888:8888 \
svc/seaweedfs-admin 23646:23646
```
**Notes**
- The chart manages master, volume, filer, S3, and admin components.
- The chart-managed S3 secret uses the current AK/SK pair for the admin user.
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
helm repo add seaweedfs https://seaweedfs.github.io/seaweedfs/helm >/dev/null 2>&1 || true
helm repo update >/dev/null
helm upgrade --install seaweedfs seaweedfs/seaweedfs \
--namespace seaweedfs \
--create-namespace \
-f "$(dirname "$0")/values.yaml" \
--wait
+39
View File
@@ -0,0 +1,39 @@
# LAN route to the SeaweedFS S3 endpoint.
#
# WHY, when obj.ddupan.top already works: that hostname resolves to Cloudflare and
# hairpins the request out through the WAN and back down the tunnel. It is fine for
# a browser. It is a bad dependency for **Terraform state**, which is exactly what
# you need during an incident — and on 2026-07-28 that path was blackholed for
# hours by a dead `openvpn-client@naist` tunnel whose 58 split-tunnel routes
# swallowed Cloudflare's ranges. State operations must not leave the LAN.
#
# The public obj.ddupan.top route is unchanged and still served via cloudflared;
# both terminate at the same Service.
#
# No cert work: s3.ad.ddupan.top is covered by the existing *.ad.ddupan.top
# wildcard on the gateway's `https` listener, so this is an HTTPRoute plus one DNS
# A record in ../../infrastructure/samba-ad — the documented way to add a LAN service.
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: seaweedfs-s3
namespace: seaweedfs
spec:
parentRefs:
- name: eg
namespace: envoy-gateway-system
# Pin to the wildcard listener; the https-auth listener only matches
# auth.ddupan.top and would report a needless "no matching listener".
sectionName: https
hostnames:
- s3.ad.ddupan.top
rules:
- backendRefs:
- name: seaweedfs-s3
port: 8333
# S3 PUTs of Terraform state can be large (smtp-relay's is already ~1.1MB
# because the azuread app registration carries a lot). Envoy's default
# per-try timeout is comfortably above that, but the retry policy matters
# more: a half-written state file is far worse than a failed apply, so do
# not add retries here. Terraform handles its own locking and retry.
+76
View File
@@ -0,0 +1,76 @@
global:
seaweedfs:
image:
repository: ""
name: chrislusf/seaweedfs
tag: latest
master:
enabled: true
replicas: 1
data:
type: persistentVolumeClaim
size: 10Gi
storageClass: localpv-zfs-ceph
logs:
type: emptyDir
volume:
enabled: true
replicas: 1
dataDirs:
- name: data1
type: persistentVolumeClaim
size: 100Gi
storageClass: localpv-zfs-ceph
maxVolumes: 0
idx:
type: emptyDir
logs:
type: emptyDir
filer:
enabled: true
replicas: 1
data:
type: persistentVolumeClaim
size: 10Gi
storageClass: localpv-zfs-ceph
logs:
type: emptyDir
s3:
enabled: true
enableAuth: true
# Identities come from a Secret synced out of OpenBao by External Secrets
# (../../platform/external-secrets/externalsecrets.yaml -> kv/k8s/seaweedfs-s3), NOT from
# the chart's own s3.credentials.
#
# WHY: those keys used to be INLINE in this file and were committed in the
# initial commit. They also slipped past a content scan, because the regex
# looked for `secret[:=]` and the key is written `secretKey` — the word is
# followed by "Key", not a colon. See CLAUDE.md on scanning by content.
# The leaked anvAdmin key is in git history and still needs ROTATING.
existingConfigSecret: seaweedfs-s3-config
s3:
enableAuth: true
# No credentials block on purpose — populating it makes the chart render a
# Secret from values, which is what put credentials in git in the first place.
admin:
enabled: true
replicas: 1
data:
type: emptyDir
logs:
type: emptyDir
ingress:
enabled: true
className: tailscale
host: seaweedfs-admin
path: /
pathType: Prefix
annotations: {}
tls:
- hosts:
- seaweedfs-admin
@@ -0,0 +1,27 @@
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: shared-postgresql
namespace: shared-db
spec:
serviceAccountName: shared-postgresql-sa
instances: 1
enableSuperuserAccess: true
superuserSecret:
name: shared-postgresql-superuser-secret
storage:
storageClass: localpv-zfs-ceph
size: 10Gi
resources:
requests:
memory: 512Mi
cpu: 250m
limits:
memory: 1Gi
cpu: 500m
bootstrap:
initdb:
database: e5renew
owner: postgres
secret:
name: shared-postgresql-superuser-secret
+144
View File
@@ -0,0 +1,144 @@
# Shared PostgreSQL Migration Runbook
## Target
- CloudNativePG cluster: `shared-postgresql`
- Namespace: `shared-db`
- Compatibility service: `shared-postgresql.shared-db.svc.cluster.local:5432`
- CNPG rw service: `shared-postgresql-rw.shared-db.svc.cluster.local:5432`
- Tailscale service: `shared-postgresql-tailscale.shared-db.svc.cluster.local:5432`
## Apply order
1. Create the CNPG superuser secret
2. Apply `shared-postgresql/cloudnativepg-cluster.yaml`
3. Wait for the cluster to become ready
4. Restore the databases and validate against the CNPG `rw` Service
5. Remove the legacy Helm release so the old Service can be removed cleanly
6. Apply `shared-postgresql/shared-postgresql-service.yaml`
7. Validate app connectivity through the compatibility and Tailscale Services
## Storage preflight
```bash
kubectl get storageclass localpv-zfs-ceph
kubectl get pods -n openebs
```
Make sure the OpenEBS ZFS storage class exists and the OpenEBS components are healthy before starting the cutover.
## Reconcile source passwords
The following passwords are the current source of truth for the migration:
- `SOURCE_POSTGRES_PASSWORD` for the source dump and CNPG superuser secret
- `OLD_GITEA_PASSWORD` and `OLD_CASDOOR_PASSWORD` for the restored app logins
## Create the CNPG secret
```bash
kubectl create secret generic shared-postgresql-superuser-secret \
-n shared-db \
--type=kubernetes.io/basic-auth \
--from-literal=username=postgres \
--from-literal=password="$SOURCE_POSTGRES_PASSWORD" \
--dry-run=client -o yaml | kubectl apply -f -
```
## Dump from the old instance
Pause writes from dependent apps first.
```bash
kubectl get pod -n shared-db -l app.kubernetes.io/component=primary,app.kubernetes.io/instance=shared-postgresql,app.kubernetes.io/name=postgresql --show-labels
SOURCE_POD="$(kubectl get pod -n shared-db -l app.kubernetes.io/component=primary,app.kubernetes.io/instance=shared-postgresql,app.kubernetes.io/name=postgresql -o jsonpath='{.items[0].metadata.name}')"
kubectl exec -n shared-db "$SOURCE_POD" -- sh -lc "PGPASSWORD='$SOURCE_POSTGRES_PASSWORD' pg_dumpall -h 127.0.0.1 -U postgres --globals-only" > globals.sql
kubectl exec -n shared-db "$SOURCE_POD" -- sh -lc "PGPASSWORD='$SOURCE_POSTGRES_PASSWORD' pg_dump -h 127.0.0.1 -U postgres -Fc -d e5renew" > e5renew.dump
kubectl exec -n shared-db "$SOURCE_POD" -- sh -lc "PGPASSWORD='$SOURCE_POSTGRES_PASSWORD' pg_dump -h 127.0.0.1 -U postgres -Fc -d gitea" > gitea.dump
kubectl exec -n shared-db "$SOURCE_POD" -- sh -lc "PGPASSWORD='$SOURCE_POSTGRES_PASSWORD' pg_dump -h 127.0.0.1 -U postgres -Fc -d casdoor" > casdoor.dump
```
## Restore into CNPG
`globals.sql` restores the roles and passwords from the source cluster. The init SQL only creates the application databases and grants.
```bash
cat globals.sql > globals.with-postgres-reset.sql
printf "ALTER ROLE postgres PASSWORD :'source_postgres_password';\n" >> globals.with-postgres-reset.sql
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -v source_postgres_password="$SOURCE_POSTGRES_PASSWORD" -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d postgres -f globals.with-postgres-reset.sql
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d postgres -f shared-postgresql/shared-postgresql-init.sql
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" pg_restore -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d e5renew e5renew.dump
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" pg_restore -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d gitea gitea.dump
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" pg_restore -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d casdoor casdoor.dump
```
## Pre-cutover validation
```bash
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d e5renew -c 'select 1;'
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U postgres -d e5renew -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$OLD_GITEA_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U gitea -d gitea -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$OLD_CASDOOR_PASSWORD" psql -h shared-postgresql-rw.shared-db.svc.cluster.local -U casdoor -d casdoor -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
```
## Post-cutover validation
```bash
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql.shared-db.svc.cluster.local -U postgres -d e5renew -c 'select 1;'
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql.shared-db.svc.cluster.local -U postgres -d e5renew -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$OLD_GITEA_PASSWORD" psql -h shared-postgresql.shared-db.svc.cluster.local -U gitea -d gitea -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$OLD_CASDOOR_PASSWORD" psql -h shared-postgresql.shared-db.svc.cluster.local -U casdoor -d casdoor -c 'create table if not exists migration_check(id int); insert into migration_check values (1); delete from migration_check; drop table migration_check;'
PGPASSWORD="$SOURCE_POSTGRES_PASSWORD" psql -h shared-postgresql-tailscale.shared-db.svc.cluster.local -U postgres -d e5renew -c 'select 1;'
```
## Rollback
- Keep the old Helm-based PostgreSQL deployment until validation passes.
- If restore fails, delete the CNPG cluster.
- Delete the CNPG compatibility Service with `kubectl delete -f shared-postgresql/shared-postgresql-service.yaml` so the `shared-postgresql` hostname is free for the legacy release again.
- Reapply the legacy Helm release.
- After the old Helm release is back, regenerate the Tailscale Service from the live pod labels and apply it:
```bash
python - <<'PY' | kubectl apply -f -
import json
import subprocess
import yaml
pod = subprocess.check_output([
'kubectl', 'get', 'pod', '-n', 'shared-db',
'-l', 'app.kubernetes.io/component=primary,app.kubernetes.io/instance=shared-postgresql,app.kubernetes.io/name=postgresql',
'-o', 'json'
], text=True)
labels = json.loads(pod)['items'][0]['metadata']['labels']
selector = {
'app.kubernetes.io/component': labels['app.kubernetes.io/component'],
'app.kubernetes.io/instance': labels['app.kubernetes.io/instance'],
'app.kubernetes.io/name': labels['app.kubernetes.io/name'],
}
service = {
'apiVersion': 'v1',
'kind': 'Service',
'metadata': {
'name': 'shared-postgresql-tailscale',
'namespace': 'shared-db',
},
'spec': {
'type': 'LoadBalancer',
'loadBalancerClass': 'tailscale',
'ports': [
{
'name': 'tcp-postgresql',
'port': 5432,
'protocol': 'TCP',
'targetPort': 5432,
}
],
'selector': selector,
},
}
print(yaml.safe_dump(service, sort_keys=False))
PY
```
- Do not delete the old PVC until the new cluster is verified.
@@ -0,0 +1,6 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: shared-postgresql-sa
namespace: shared-db
automountServiceAccountToken: true
@@ -0,0 +1,18 @@
-- Roles come from globals.sql; this script only creates databases and grants.
SELECT 'CREATE DATABASE e5renew'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'e5renew')\gexec
GRANT ALL PRIVILEGES ON DATABASE e5renew TO postgres;
SELECT 'CREATE DATABASE casdoor'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'casdoor')\gexec
GRANT ALL PRIVILEGES ON DATABASE casdoor TO casdoor;
SELECT 'CREATE DATABASE gitea'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'gitea')\gexec
GRANT ALL PRIVILEGES ON DATABASE gitea TO gitea;
-- authelia: role created out-of-band (see authelia/README.md); DB owned by it.
SELECT 'CREATE DATABASE authelia OWNER authelia'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'authelia')\gexec
GRANT ALL PRIVILEGES ON DATABASE authelia TO authelia;
@@ -0,0 +1,31 @@
apiVersion: v1
kind: Service
metadata:
name: shared-postgresql
namespace: shared-db
spec:
type: ClusterIP
selector:
cnpg.io/cluster: shared-postgresql
cnpg.io/instanceRole: primary
ports:
- name: postgres
port: 5432
targetPort: 5432
---
apiVersion: v1
kind: Service
metadata:
name: shared-postgresql-tailscale
namespace: shared-db
spec:
type: LoadBalancer
loadBalancerClass: tailscale
selector:
cnpg.io/cluster: shared-postgresql
cnpg.io/instanceRole: primary
ports:
- name: tcp-postgresql
port: 5432
protocol: TCP
targetPort: 5432
+4
View File
@@ -0,0 +1,4 @@
# Never commit the real credentials or minted tokens
secret.yaml
tokens/
.noreply-password
+136
View File
@@ -0,0 +1,136 @@
# SMTP relay (Postfix + sasl-xoauth2 → Microsoft 365)
One internal SMTP endpoint that in-cluster apps use for outbound mail. It authenticates
to Exchange Online with **OAuth2 (XOAUTH2)** via [`mauroreggio/postfix-365`], which bundles
[`sasl-xoauth2`] — tokens are refreshed **inside the SASL layer**, no sidecar/cron. Basic-auth
SMTP (app passwords) is being retired by Microsoft; this is the modern replacement.
```
Authelia / Gitea / … ──plain SMTP :25 (in-cluster, no auth)──▶ smtp-relay ──587 STARTTLS + XOAUTH2──▶ smtp.office365.com
```
Reach it at: `smtp-relay.smtp-relay.svc.cluster.local:25`. Sends **as** `[email protected]`.
[`mauroreggio/postfix-365`]: https://github.com/mauroreggio/postfix-365
[`sasl-xoauth2`]: https://github.com/tarickb/sasl-xoauth2
---
## Prerequisites (Microsoft 365 / Entra — mostly interactive, one-time)
1. **`ddupan.top` is a verified domain** in the tenant (Admin center → Settings → Domains,
Status = Healthy). If not, add it and complete the TXT verification (DNS is Cloudflare —
can be done in `../../../infrastructure/cloudflared/terraform/`).
2. **Mailbox `[email protected]`** exists with an **Exchange Online license**.
3. **Authenticated SMTP enabled** on it: Admin center → Users → that user → Mail →
*Manage email apps* → tick **Authenticated SMTP**. (OAuth won't work without this.)
## Step 1 — Entra app registration (as code)
```bash
cd terraform
az login # account that can create app regs AND grant admin consent (Global Admin)
terraform init
terraform apply # creates the app, delegated Graph SMTP.Send + admin consent, a client secret
```
Grab the three values for the k8s secret:
```bash
terraform output -raw client_id
terraform output -raw tenant_id
```
> This is a **PUBLIC** client (device-code flow) — there is **no client secret**. Leave
> `CLIENT_SECRET` empty in `secret.yaml`. Presenting a secret makes Entra reject the token
> refresh with `AADSTS700025 "Client is public..."`.
## Step 2 — Deploy the relay
```bash
cd ..
cp secret.example.yaml secret.yaml # paste CLIENT_ID / CLIENT_SECRET / TENANT_ID
kubectl apply -f namespace.yaml
kubectl apply -f secret.yaml -f pvc.yaml
kubectl apply -f deployment.yaml -f service.yaml
kubectl -n smtp-relay rollout status deploy/smtp-relay
```
At this point Postfix runs but has **no token yet**, so relaying fails until step 3.
## Step 3 — Bootstrap the token (one-time, interactive device-code)
The image's `sasl-xoauth2-tool` needs the `msal` Python module, which isn't bundled.
Install it ephemerally (only needed for this one mint; the C++ SASL plugin refreshes
without it):
```bash
POD=$(kubectl get pod -n smtp-relay -l app=smtp-relay -o name | head -1 | cut -d/ -f2)
kubectl exec -n smtp-relay $POD -- sh -c 'python3 -m ensurepip >/dev/null 2>&1; python3 -m pip install -q msal'
```
Mint the token (env vars come from the pod's secret; `CLIENT_SECRET` is empty → public flow):
```bash
kubectl exec -n smtp-relay -it deploy/smtp-relay -- sh -c \
'sasl-xoauth2-tool get-token outlook /etc/tokens/[email protected] \
--client-id="$CLIENT_ID" --tenant="$TENANT_ID" --client-secret="$CLIENT_SECRET" --use-device-flow'
```
It prints a URL + code — open <https://microsoft.com/devicelogin> and **sign in as the SENDER
mailbox `[email protected]`** (NOT yourself/the admin — a token minted for the wrong user gives
`535 5.7.3`). If prompted for a client secret, press Enter. Then fix ownership so Postfix can
read/rewrite it:
```bash
kubectl exec -n smtp-relay $POD -- chown postfix:postfix /etc/tokens/[email protected]
```
Verify identity if unsure: decode the token and check `upn` == `[email protected]`. The token
persists on the PVC; sasl-xoauth2 refreshes it automatically thereafter.
## Step 4 — Test
```bash
kubectl exec -n smtp-relay $POD -- sh -c \
'echo "Subject: relay test\n\nhello" | sendmail -f [email protected] [email protected]'
kubectl exec -n smtp-relay $POD -- tail -n 40 /var/log/maillog # look for "status=sent"
```
## Step 5 — Point apps at it
- **Authelia** — replace the filesystem notifier with SMTP in `../authelia/values.yaml`:
address `smtp://smtp-relay.smtp-relay.svc.cluster.local:25`, sender `[email protected]`,
`disable_require_tls: true` (plain in-cluster hop). 2FA enrollment codes then go to real email.
- Any future app: same address, **From = `[email protected]`** (O365 rejects other senders
with `5.7.60` unless a send-as alias is configured in Exchange).
## Deliverability (keep mail out of Junk)
- **SPF / MX / DMARC** for `ddupan.top` already exist (M365 domain setup).
- **DKIM**: ✅ **enabled 2026-07-28** (`Enabled: True`, `Status: Valid`). CNAMEs are in
`../../infrastructure/cloudflared/terraform/` (`selector1/2._domainkey`); signing was turned on with
`scripts/enable-dkim.ps1` then `scripts/enable-dkim-finish.ps1`.
- ⚠️ **The CNAME target is NXDOMAIN until signing is enabled.** Microsoft creates the
tenant host (`<tenant>.d-v1.dkim.mail.microsoft`) only at enable time, so a correct
CNAME looks broken beforehand and `Get-DkimSigningConfig` reports `CnameMissing`.
**Do not go hunting for the "real" CNAME value** — run step 2 and re-check DNS.
- ⚠️ **Both scripts deadlock if run via `!` or with output redirected to a file** — the
device code never becomes visible. Run under a PTY:
`DOTNET_SYSTEM_NET_DISABLEIPV6=1 script -qfc "pwsh -NoProfile -File scripts/enable-dkim.ps1" /tmp/dkim.log`
The `DISABLEIPV6` is required on the laptop — see the IPv6 trap in the root `CLAUDE.md`;
without it `Connect-ExchangeOnline` hangs in `SYN-SENT` with no output at all.
- **Still soft**: SPF is `~all` and DMARC is `p=none`. Harden to `-all` / `p=quarantine`
once aggregate reports confirm DKIM passes — not before, or you quarantine your own mail.
## Notes / gotchas
- **PUBLIC client, no secret** — see Step 1. `CLIENT_SECRET` stays empty.
- **Sign in as `noreply@` (the sender), not the admin**, during the Step 3 device login — a
token minted for the wrong identity fails with `535 5.7.3`.
- **`msal` is ephemeral** — reinstall it in the pod (Step 3) before any re-mint; it's gone after
a restart but only the one-time mint needs it.
- **Token PVC is writable state, not in Git.** The refresh token rotates; it lives only on
the PVC. Back it up if you want to avoid re-running step 3.
- **Refresh-token longevity**: Azure AD refresh tokens renew on use but can expire under
Conditional Access / long idle — if relaying suddenly fails auth, re-run step 3.
+81
View File
@@ -0,0 +1,81 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: smtp-relay
namespace: smtp-relay
labels:
app: smtp-relay
spec:
replicas: 1
strategy:
type: Recreate # single writer on the token PVC (RWO)
selector:
matchLabels:
app: smtp-relay
template:
metadata:
labels:
app: smtp-relay
spec:
containers:
- name: postfix
# Postfix + sasl-xoauth2 (OAuth2/XOAUTH2 to M365). Refreshes tokens in the
# SASL layer — no sidecar. https://github.com/mauroreggio/postfix-365
image: ghcr.io/mauroreggio/postfix-365:1.0.0
envFrom:
- secretRef:
name: smtp-relay-secret # CLIENT_ID, CLIENT_SECRET, TENANT_ID
env:
- name: TIMEZONE
value: 'Asia/Shanghai'
- name: HOSTNAME
value: 'smtp-relay.ddupan.top' # HELO name
- name: DOMAIN_NAME
value: 'ddupan.top'
# Submitters trusted without SMTP AUTH. k3s pod + service CIDRs, plus the
# three Proxmox nodes by /32 so they can relay system mail (PVE alerts,
# smartd, cron) to M365 — they have no other way off a residential IP.
# Deliberately /32s, NOT 192.168.10.0/24: everything else on the LAN still
# hits `defer_unauth_destination`, so this stays a closed relay.
# Exposed to those nodes via service-lan.yaml (LoadBalancer :25).
- name: MY_NETWORK
value: '10.42.0.0/16, 10.43.0.0/16, 192.168.10.4/32, 192.168.10.7/32, 192.168.10.9/32'
- name: DISABLE_SMTP_AUTH_ON_PORT_25
value: 'true'
- name: MESSAGE_SIZE_LIMIT
value: '26214400' # 25 MiB
# The M365 mailbox we authenticate + send AS (device-code refresh token
# lives at /etc/tokens/<AUTH_USER> on the PVC).
- name: AUTH_USER
value: '[email protected]'
- name: RELAY_HOST
value: 'smtp.office365.com'
- name: RELAY_HOST_PORT
value: '587'
ports:
- name: smtp
containerPort: 25
volumeMounts:
# Writable + persistent: sasl-xoauth2 rewrites the token file on refresh.
- name: tokens
mountPath: /etc/tokens
readinessProbe:
tcpSocket:
port: 25
initialDelaySeconds: 10
periodSeconds: 15
livenessProbe:
tcpSocket:
port: 25
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
volumes:
- name: tokens
persistentVolumeClaim:
claimName: smtp-relay-tokens
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: smtp-relay
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: smtp-relay-tokens
namespace: smtp-relay
labels:
app: smtp-relay
spec:
# Holds /etc/tokens/<AUTH_USER> — the OAuth refresh/access token that sasl-xoauth2
# rewrites as it refreshes. Must persist across restarts, so NOT a Secret mount.
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 64Mi
@@ -0,0 +1,18 @@
# DKIM step 2 of 2: enable signing, once the CNAME targets from enable-dkim.ps1 are
# published and resolving. Validates the CNAMEs and turns on DKIM signing.
#
# ! pwsh ~/services/apps/smtp-relay/scripts/enable-dkim-finish.ps1
param(
[string]$Domain = 'ddupan.top'
)
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -Device -ShowBanner:$false
try {
Set-DkimSigningConfig -Identity $Domain -Enabled $true -ErrorAction Stop
Write-Host "DKIM enabled for $Domain."
} catch {
Write-Host "Enable failed: $($_.Exception.Message)"
Write-Host "If it mentions CNAME records, they aren't visible to Exchange yet — wait and re-run."
}
Get-DkimSigningConfig -Identity $Domain | Format-List Name, Enabled, Status, Selector1CNAME, Selector2CNAME
Disconnect-ExchangeOnline -Confirm:$false
+31
View File
@@ -0,0 +1,31 @@
# DKIM step 1 of 2: create the signing config (disabled) and PRINT the exact CNAME
# targets. The 2025 CNAME format includes a per-tenant character only Exchange knows,
# so we must read Selector1CNAME/Selector2CNAME from here, publish them, THEN enable.
#
# ! pwsh ~/services/apps/smtp-relay/scripts/enable-dkim.ps1
# Sign in as a tenant admin. Paste the CNAME values back so DNS can be updated.
param(
[string]$Domain = 'ddupan.top'
)
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -Device -ShowBanner:$false
# Create directly (disabled). Don't pre-check with Get — on a missing domain it only
# WARNS (not errors), which defeats try/catch. Catch the "already exists" case instead.
try {
New-DkimSigningConfig -DomainName $Domain -KeySize 2048 -Enabled $false -ErrorAction Stop | Out-Null
Write-Host "Created DKIM config for $Domain (disabled)."
} catch {
if ("$($_.Exception.Message)" -match 'already exist') {
Write-Host "DKIM config already exists — continuing."
} else {
Write-Host "New-DkimSigningConfig failed: $($_.Exception.Message)"
Disconnect-ExchangeOnline -Confirm:$false
return
}
}
Write-Host "`n== PUBLISH THESE EXACT CNAME TARGETS (paste them back) =="
Get-DkimSigningConfig -Identity $Domain | Format-List Name, Enabled, Status, Selector1CNAME, Selector2CNAME
Write-Host "After the CNAMEs are updated + resolving, run scripts/enable-dkim-finish.ps1 to enable."
Disconnect-ExchangeOnline -Confirm:$false
@@ -0,0 +1,19 @@
# Enables Authenticated SMTP (SMTP AUTH) on [email protected] so OAuth SMTP works.
# Run it yourself (interactive admin device-login):
# ! pwsh ~/services/apps/smtp-relay/scripts/enable-smtp-auth.ps1
# At the prompt, open https://microsoft.com/devicelogin and sign in as a tenant ADMIN.
param(
[string]$Mailbox = '[email protected]'
)
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -Device -ShowBanner:$false
$mbx = Get-CASMailbox -Identity $Mailbox -ErrorAction SilentlyContinue
if (-not $mbx) {
Write-Host "Mailbox '$Mailbox' isn't provisioned yet (licensing can take a few minutes). Wait and re-run."
} else {
Set-CASMailbox -Identity $Mailbox -SmtpClientAuthenticationDisabled:$false
Write-Host "== Authenticated SMTP status =="
Get-CASMailbox -Identity $Mailbox | Format-List Name, SmtpClientAuthenticationDisabled
}
Disconnect-ExchangeOnline -Confirm:$false
+19
View File
@@ -0,0 +1,19 @@
# Copy to secret.yaml (gitignored) and fill in — or let the Terraform in ./terraform/
# emit these values (it creates the Entra app registration + client secret).
#
# CLIENT_ID = Application (client) ID of the Entra app registration
# CLIENT_SECRET = a client secret VALUE for that app (used by sasl-xoauth2 to refresh)
# TENANT_ID = your Entra tenant (directory) ID
#
# NOTE: these three are NOT the whole story — the relay also needs a device-code
# refresh token minted once into the PVC (see README "Bootstrap the token").
apiVersion: v1
kind: Secret
metadata:
name: smtp-relay-secret
namespace: smtp-relay
type: Opaque
stringData:
CLIENT_ID: 'CHANGE-ME'
CLIENT_SECRET: 'CHANGE-ME'
TENANT_ID: 'CHANGE-ME'
+40
View File
@@ -0,0 +1,40 @@
# LAN-facing SMTP endpoint for hosts OUTSIDE the k3s cluster — specifically the
# three Proxmox nodes (pve1 .4, pve2 .7, pve3 .9), which relay their system mail
# (PVE notifications, smartd, cron) through here to M365.
#
# Kept SEPARATE from service.yaml on purpose: that ClusterIP service is what
# Authelia and Gitea address by DNS name (smtp-relay.smtp-relay.svc.cluster.local),
# and it must not change shape.
#
# k3s servicelb (klipper) host-binds :25 on the laptop (192.168.10.127).
#
# CRITICAL — why loadBalancerSourceRanges is the ONLY real access control here:
# klipper SNATs incoming connections, so the relay sees every LAN client as
# `_gateway[10.42.0.1]` rather than its true address. 10.42.0.1 is inside the
# pod CIDR that MY_NETWORK already trusts, which means postfix's IP-based
# `permit_mynetworks` CANNOT distinguish a Proxmox node from any other LAN host —
# per-node /32 entries in MY_NETWORK are decorative. Without the source ranges
# below this service is an OPEN RELAY to the whole LAN (verified: an untrusted
# host got a 220 banner and would have been permitted to relay).
# So: restrict at the LB. Do not remove this block.
apiVersion: v1
kind: Service
metadata:
name: smtp-relay-lan
namespace: smtp-relay
labels:
app: smtp-relay
spec:
type: LoadBalancer
selector:
app: smtp-relay
# Only the three Proxmox nodes may even open a TCP connection to :25.
loadBalancerSourceRanges:
- 192.168.10.4/32 # pve1
- 192.168.10.7/32 # pve2
- 192.168.10.9/32 # pve3
ports:
- name: smtp
port: 25
targetPort: 25
protocol: TCP
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: smtp-relay
namespace: smtp-relay
labels:
app: smtp-relay
spec:
selector:
app: smtp-relay
ports:
# In-cluster apps send here: smtp-relay.smtp-relay.svc.cluster.local:25
- name: smtp
port: 25
targetPort: 25
+5
View File
@@ -0,0 +1,5 @@
*.tfstate
*.tfstate.*
.terraform/
terraform.tfvars
crash.log
+22
View File
@@ -0,0 +1,22 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/azuread" {
version = "3.9.0"
constraints = "~> 3.0"
hashes = [
"h1:+ZknnMPMLJ1dIVqxto9ZWoakX4ljsek5cmajhUfEwN4=",
"zh:1c3e89cf19118fc07d7b04257251fc9897e722c16e0a0df7b07fcd261f8c12e7",
"zh:39b11a075e4baa4f6ed5c72a8427013d50f43eecc1a7603b73bccf80f952f758",
"zh:41484c196c943b39411f561e70a308bd2a71da18155bfec7381ba0bd61361d34",
"zh:42068e5da223494beea5f7fcb9057c308cbfa92f96e53c50083e2639216479d8",
"zh:464d7da44682443a4b64bfdaf3d0eb53011c6e1471f244f6354c4d5bca18edce",
"zh:49f597ea3fac39931ff91e55afd5b5cc91e449920a03716f82509d588aaab708",
"zh:6092c376accfc50b555b7a0cd56b76c09abc3d65ac9dd5069063d6f9f1e76d3b",
"zh:65326a9f3ac0783c16e05c16422d191f0a926b8d021fd5303c1fdf8dc42f16e9",
"zh:784214ed809347d74562bb38194c0cef57831eaa621ba3b7cdd3fe7a7a76d844",
"zh:b4233f9bc791adc7d6643507fa5b47360a21125763a072d953586151cacb65f9",
"zh:c4ecdd995ff99b7e362e087c45f080816bcf097da5be257c87b912210e45dd3e",
"zh:f0122771f71cb98248e70cdd6c2ccd3bffb34e79d19897fa28b785c86b2312ed",
]
}

Some files were not shown because too many files have changed in this diff Show More