diff --git a/.gitignore b/.gitignore index cbee9c6..b09dea4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ checkmk/checkmk/* downtify/Downtify_downloads headscale/config/* headscale/data/* +# NetBird local hostnames and generated secrets +netbird/.env +netbird/secrets/ searxng/core-config/* # Steaming services files diff --git a/netbird/.env.example b/netbird/.env.example new file mode 100644 index 0000000..fbd3ccc --- /dev/null +++ b/netbird/.env.example @@ -0,0 +1,13 @@ +# Public hostname advertised to NetBird clients and used for TLS/OAuth. +NETBIRD_DOMAIN=nb.forust.xyz + +# Internal-only aliases routed by the existing Traefik instance. +NETBIRD_LOCAL_DOMAIN=netbird.workstation.internal +NETBIRD_DEV_DOMAIN=netbird.gigaforust.internal + +NETBIRD_PROXY_SUBNET=auto + +NETBIRD_CLIENT_HOSTNAME=hostname + +# Add a dashboard-generated setup key before starting client.compose.yaml. +# NB_SETUP_KEY= diff --git a/netbird/README.md b/netbird/README.md new file mode 100644 index 0000000..f5ef834 --- /dev/null +++ b/netbird/README.md @@ -0,0 +1,123 @@ +# NetBird + +Self-hosted NetBird with the combined management, signal, relay, and STUN server. The dashboard and server run behind the repository's existing external Traefik instance on the Docker `proxy` network. Only STUN UDP `3478` is published directly. + +The deployment uses SQLite for a single-instance homelab server. The persistent `netbird_data` volume and the datastore encryption key are both required to recover the installation. + +## Files + +- `compose.yaml`: dashboard and combined server; selected by the marker-driven deploy workflow through `active`. +- `config.template.yaml`: non-secret server configuration rendered at startup. +- `entrypoint.sh`: injects Docker secrets into an in-memory runtime configuration. +- `client.compose.yaml`: optional host-network peer using a dashboard-generated setup key. +- `.env`: ignored local hostnames, the detected Traefik Docker-network subnet, and optional client setup key. +- `secrets/`: ignored relay secret and datastore encryption key. + +## First deployment + +Run these commands on the Docker host before merging the activating branch. The deploy preflight resets tracked files but preserves ignored local state. + +```bash +cd /srv/homelab/netbird +./setup.sh +$EDITOR .env +docker compose config --quiet +docker compose up -d +``` + +Review the values in `.env` before starting. The example public hostname is `netbird.forust.xyz`; change it if a different public domain was selected. `setup.sh` replaces `NETBIRD_PROXY_SUBNET=auto` with the first IPv4 subnet of the external Docker `proxy` network. Keep that value synchronized with the network; set an explicit CIDR instead if the network is managed elsewhere. + +`setup.sh` is idempotent and never replaces existing secrets. Do not delete or regenerate `secrets/datastore-encryption-key` after the first successful start unless all encrypted setup keys and API tokens are intentionally being invalidated. + +## Network prerequisites + +- Point the public hostname directly to the Docker host. Do not proxy UDP `3478` through Cloudflare or another CDN. +- Allow inbound TCP `80`, TCP `443`, and UDP `3478` through the host firewall and upstream router. +- Ensure the external `proxy` Docker network exists and Traefik uses its `websecure` entrypoint and `letsencrypt` resolver. `NETBIRD_PROXY_SUBNET` must describe that network; it is used to trust only forwarded client addresses from Traefik. +- Ensure the internal names in `.env` resolve where the local and development aliases are needed. +- Keep Traefik's `websecure` read timeout disabled for long-lived gRPC and WebSocket sessions. This repository configures `--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0` in `traefik/compose.yaml`. + +After startup, verify OIDC discovery through the public TLS endpoint: + +```bash +curl -fsS "https://${NETBIRD_DOMAIN}/oauth2/.well-known/openid-configuration" +``` + +Open `https://${NETBIRD_DOMAIN}` immediately and complete the initial owner setup. Treat the initial setup flow as public until the owner exists. + +## Optional host client + +The client intentionally lives in a separate Compose project. Normal server deploys use `--remove-orphans`, so keeping the client in the server project would cause it to be removed. + +1. Create a reusable or ephemeral setup key in the NetBird dashboard. +2. Put `NB_SETUP_KEY=` in the ignored `netbird/.env` file. +3. Set `NETBIRD_CLIENT_HOSTNAME` to this machine's desired peer name. +4. Start and inspect the client: + +```bash +cd /srv/homelab/netbird +docker compose -f client.compose.yaml config --quiet +docker compose -f client.compose.yaml up -d +docker compose -f client.compose.yaml exec netbird-client netbird status +``` + +The client uses host networking and requires `NET_ADMIN`, `SYS_ADMIN`, `SYS_RESOURCE`, and `/dev/net/tun`. Remove it without affecting the server stack: + +```bash +docker compose -f client.compose.yaml down +``` + +## Operations + +Inspect status and logs: + +```bash +docker compose ps +docker compose logs --tail=200 netbird-server dashboard +``` + +Stop or remove containers without deleting data: + +```bash +docker compose down +``` + +Do not add `-v` to `docker compose down`; it would delete the NetBird datastore. + +## Backup and restore + +Back up both the persistent volume and the ignored secret files. For a consistent SQLite backup, briefly stop the server first and store the resulting archive and `datastore-encryption-key` in an encrypted backup: + +```bash +cd /srv/homelab/netbird +mkdir -p backups +docker compose stop netbird-server +docker run --rm \ + -v netbird_data:/data:ro \ + -v "$PWD/backups:/backup" \ + busybox:1.37.0 \ + tar -C /data -czf "/backup/netbird-data-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" . +docker compose start netbird-server +``` + +Also securely back up: + +- `secrets/datastore-encryption-key` — required to decrypt stored secrets. +- `secrets/relay-auth-secret` — keeps issued relay credentials valid across restoration. +- `netbird/.env` — optional, but it records the public and internal hostnames. + +Test a restore in an isolated Docker host before relying on a backup. + +## Upgrade + +1. Take and verify a backup. +2. Review NetBird release notes for server, client, and dashboard compatibility. +3. Update the pinned tags in `compose.yaml`; update `client.compose.yaml` separately when deploying the client. +4. Pull and recreate the selected services: + +```bash +docker compose pull +docker compose up -d +``` + +The image tags are intentionally pinned instead of using `latest`, matching this repository's pull-on-deploy policy. diff --git a/netbird/client.compose.yaml b/netbird/client.compose.yaml new file mode 100644 index 0000000..aa3d783 --- /dev/null +++ b/netbird/client.compose.yaml @@ -0,0 +1,31 @@ +name: netbird-client + +services: + netbird-client: + image: netbirdio/netbird:0.79.0 + container_name: netbird-client + hostname: "${NETBIRD_CLIENT_HOSTNAME:?Set NETBIRD_CLIENT_HOSTNAME in netbird/.env}" + restart: unless-stopped + cap_add: + - NET_ADMIN + - SYS_ADMIN + - SYS_RESOURCE + devices: + - /dev/net/tun + network_mode: host + environment: + NB_SETUP_KEY: "${NB_SETUP_KEY:?Set NB_SETUP_KEY in netbird/.env after creating a peer setup key}" + NB_MANAGEMENT_URL: "https://${NETBIRD_DOMAIN:?Set NETBIRD_DOMAIN in netbird/.env}" + volumes: + - netbird-client:/var/lib/netbird + healthcheck: + test: ["CMD", "/usr/local/bin/netbird", "status", "--check", "live"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + stop_grace_period: 30s + +volumes: + netbird-client: + name: netbird-client diff --git a/netbird/compose.yaml b/netbird/compose.yaml new file mode 100644 index 0000000..9f55ca7 --- /dev/null +++ b/netbird/compose.yaml @@ -0,0 +1,152 @@ +name: netbird + +services: + netbird-server: + image: netbirdio/netbird-server:0.79.0 + container_name: netbird-server + restart: unless-stopped + environment: + NETBIRD_DOMAIN: "${NETBIRD_DOMAIN:?Set NETBIRD_DOMAIN in netbird/.env}" + NETBIRD_PROXY_SUBNET: "${NETBIRD_PROXY_SUBNET:?Set NETBIRD_PROXY_SUBNET in netbird/.env (run setup.sh)}" + entrypoint: + - /bin/sh + - /opt/netbird/entrypoint.sh + command: + - --config + - /run/netbird/config.yaml + ports: + - "3478:3478/udp" + volumes: + - netbird_data:/var/lib/netbird + - ./config.template.yaml:/opt/netbird/config.template.yaml:ro + - ./entrypoint.sh:/opt/netbird/entrypoint.sh:ro + secrets: + - relay_auth_secret + - datastore_encryption_key + tmpfs: + - /run/netbird:mode=0700 + healthcheck: + test: + - CMD + - bash + - -ec + - exec 3<>/dev/tcp/127.0.0.1/80 + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + stop_grace_period: 30s + labels: + - "traefik.enable=true" + - "traefik.http.services.netbird-server.loadbalancer.server.port=80" + - "traefik.http.services.netbird-server-h2c.loadbalancer.server.port=80" + - "traefik.http.services.netbird-server-h2c.loadbalancer.server.scheme=h2c" + + # gRPC routers + # Prod Router + - "traefik.http.routers.netbird-grpc.rule=Host(`${NETBIRD_DOMAIN:?Set NETBIRD_DOMAIN in netbird/.env}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`))" + - "traefik.http.routers.netbird-grpc.entrypoints=websecure" + - "traefik.http.routers.netbird-grpc.service=netbird-server-h2c" + - "traefik.http.routers.netbird-grpc.priority=100" + - "traefik.http.routers.netbird-grpc.tls=true" + - "traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt" + # Local Router + - "traefik.http.routers.netbird-grpc-local.rule=Host(`${NETBIRD_LOCAL_DOMAIN:?Set NETBIRD_LOCAL_DOMAIN in netbird/.env}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`))" + - "traefik.http.routers.netbird-grpc-local.entrypoints=websecure" + - "traefik.http.routers.netbird-grpc-local.service=netbird-server-h2c" + - "traefik.http.routers.netbird-grpc-local.priority=100" + - "traefik.http.routers.netbird-grpc-local.tls=true" + # Dev Router + - "traefik.http.routers.netbird-grpc-dev.rule=Host(`${NETBIRD_DEV_DOMAIN:?Set NETBIRD_DEV_DOMAIN in netbird/.env}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`))" + - "traefik.http.routers.netbird-grpc-dev.entrypoints=websecure" + - "traefik.http.routers.netbird-grpc-dev.service=netbird-server-h2c" + - "traefik.http.routers.netbird-grpc-dev.priority=100" + - "traefik.http.routers.netbird-grpc-dev.tls=true" + + # Backend routers + # Prod Router + - "traefik.http.routers.netbird-backend.rule=Host(`${NETBIRD_DOMAIN:?Set NETBIRD_DOMAIN in netbird/.env}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))" + - "traefik.http.routers.netbird-backend.entrypoints=websecure" + - "traefik.http.routers.netbird-backend.service=netbird-server" + - "traefik.http.routers.netbird-backend.priority=100" + - "traefik.http.routers.netbird-backend.tls=true" + - "traefik.http.routers.netbird-backend.tls.certresolver=letsencrypt" + # Local Router + - "traefik.http.routers.netbird-backend-local.rule=Host(`${NETBIRD_LOCAL_DOMAIN:?Set NETBIRD_LOCAL_DOMAIN in netbird/.env}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))" + - "traefik.http.routers.netbird-backend-local.entrypoints=websecure" + - "traefik.http.routers.netbird-backend-local.service=netbird-server" + - "traefik.http.routers.netbird-backend-local.priority=100" + - "traefik.http.routers.netbird-backend-local.tls=true" + # Dev Router + - "traefik.http.routers.netbird-backend-dev.rule=Host(`${NETBIRD_DEV_DOMAIN:?Set NETBIRD_DEV_DOMAIN in netbird/.env}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))" + - "traefik.http.routers.netbird-backend-dev.entrypoints=websecure" + - "traefik.http.routers.netbird-backend-dev.service=netbird-server" + - "traefik.http.routers.netbird-backend-dev.priority=100" + - "traefik.http.routers.netbird-backend-dev.tls=true" + networks: + - proxy + + dashboard: + image: netbirdio/dashboard:v2.90.10 + container_name: netbird-dashboard + restart: unless-stopped + environment: + NETBIRD_MGMT_API_ENDPOINT: "https://${NETBIRD_DOMAIN:?Set NETBIRD_DOMAIN in netbird/.env}" + NETBIRD_MGMT_GRPC_API_ENDPOINT: "https://${NETBIRD_DOMAIN:?Set NETBIRD_DOMAIN in netbird/.env}" + AUTH_AUDIENCE: netbird-dashboard + AUTH_CLIENT_ID: netbird-dashboard + AUTH_CLIENT_SECRET: "" + AUTH_AUTHORITY: "https://${NETBIRD_DOMAIN:?Set NETBIRD_DOMAIN in netbird/.env}/oauth2" + AUTH_SUPPORTED_SCOPES: openid profile email groups + AUTH_REDIRECT_URI: /nb-auth + AUTH_SILENT_REDIRECT_URI: /nb-silent-auth + USE_AUTH0: "false" + LETSENCRYPT_DOMAIN: none + depends_on: + netbird-server: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "--fail", "--silent", "--show-error", "http://127.0.0.1/"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 15s + labels: + - "traefik.enable=true" + - "traefik.http.services.netbird-dashboard.loadbalancer.server.port=80" + # Dashboard catch-all routers + # Prod Router + - "traefik.http.routers.netbird-dashboard.rule=Host(`${NETBIRD_DOMAIN:?Set NETBIRD_DOMAIN in netbird/.env}`)" + - "traefik.http.routers.netbird-dashboard.entrypoints=websecure" + - "traefik.http.routers.netbird-dashboard.service=netbird-dashboard" + - "traefik.http.routers.netbird-dashboard.priority=1" + - "traefik.http.routers.netbird-dashboard.tls=true" + - "traefik.http.routers.netbird-dashboard.tls.certresolver=letsencrypt" + # Local Router + - "traefik.http.routers.netbird-dashboard-local.rule=Host(`${NETBIRD_LOCAL_DOMAIN:?Set NETBIRD_LOCAL_DOMAIN in netbird/.env}`)" + - "traefik.http.routers.netbird-dashboard-local.entrypoints=websecure" + - "traefik.http.routers.netbird-dashboard-local.service=netbird-dashboard" + - "traefik.http.routers.netbird-dashboard-local.priority=1" + - "traefik.http.routers.netbird-dashboard-local.tls=true" + # Dev Router + - "traefik.http.routers.netbird-dashboard-dev.rule=Host(`${NETBIRD_DEV_DOMAIN:?Set NETBIRD_DEV_DOMAIN in netbird/.env}`)" + - "traefik.http.routers.netbird-dashboard-dev.entrypoints=websecure" + - "traefik.http.routers.netbird-dashboard-dev.service=netbird-dashboard" + - "traefik.http.routers.netbird-dashboard-dev.priority=1" + - "traefik.http.routers.netbird-dashboard-dev.tls=true" + networks: + - proxy + +networks: + proxy: + external: true + +volumes: + netbird_data: + name: netbird_data + +secrets: + relay_auth_secret: + file: ./secrets/relay-auth-secret + datastore_encryption_key: + file: ./secrets/datastore-encryption-key diff --git a/netbird/config.template.yaml b/netbird/config.template.yaml new file mode 100644 index 0000000..1ec383c --- /dev/null +++ b/netbird/config.template.yaml @@ -0,0 +1,26 @@ +server: + listenAddress: ":80" + exposedAddress: "https://__NETBIRD_DOMAIN__:443" + stunPorts: + - 3478 + metricsPort: 9090 + healthcheckAddress: ":9000" + logLevel: info + logFile: console + authSecret: "__NETBIRD_AUTH_SECRET__" + dataDir: "/var/lib/netbird" + disableAnonymousMetrics: true + auth: + issuer: "https://__NETBIRD_DOMAIN__/oauth2" + signKeyRefreshEnabled: true + dashboardRedirectURIs: + - "https://__NETBIRD_DOMAIN__/nb-auth" + - "https://__NETBIRD_DOMAIN__/nb-silent-auth" + reverseProxy: + trustedHTTPProxies: + - "__NETBIRD_PROXY_SUBNET__" + trustedPeers: + - "__NETBIRD_PROXY_SUBNET__" + store: + engine: sqlite + encryptionKey: "__NETBIRD_ENCRYPTION_KEY__" diff --git a/netbird/k8s/certificates.yaml b/netbird/k8s/certificates.yaml new file mode 100644 index 0000000..5a2afca --- /dev/null +++ b/netbird/k8s/certificates.yaml @@ -0,0 +1,28 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: netbird-prod-tls + namespace: netbird +spec: + secretName: netbird-prod-tls + dnsNames: + - nb.forust.xyz + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: internal-wildcard-tls + namespace: netbird +spec: + secretName: internal-wildcard-tls + dnsNames: + - "*.workstation.internal" + - "*.gigaforust.internal" + - workstation.internal + - gigaforust.internal + issuerRef: + name: internal-ca + kind: ClusterIssuer diff --git a/netbird/k8s/config.yaml b/netbird/k8s/config.yaml new file mode 100644 index 0000000..ae150b9 --- /dev/null +++ b/netbird/k8s/config.yaml @@ -0,0 +1,160 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: netbird-config + namespace: netbird +data: + # Public hostname, rendered into the server config by entrypoint.sh. + NETBIRD_DOMAIN: "nb.forust.xyz" + NETBIRD_PROXY_SUBNET: "10.244.0.0/16" + + NETBIRD_MGMT_API_ENDPOINT: "https://nb.forust.xyz" + NETBIRD_MGMT_GRPC_API_ENDPOINT: "https://nb.forust.xyz" + AUTH_AUDIENCE: "netbird-dashboard" + AUTH_CLIENT_ID: "netbird-dashboard" + AUTH_CLIENT_SECRET: "" + AUTH_AUTHORITY: "https://nb.forust.xyz/oauth2" + AUTH_SUPPORTED_SCOPES: "openid profile email groups" + AUTH_REDIRECT_URI: "/nb-auth" + AUTH_SILENT_REDIRECT_URI: "/nb-silent-auth" + USE_AUTH0: "false" + LETSENCRYPT_DOMAIN: "none" + + config.template.yaml: | + server: + listenAddress: ":80" + exposedAddress: "https://__NETBIRD_DOMAIN__:443" + stunPorts: + - 3478 + metricsPort: 9090 + healthcheckAddress: ":9000" + logLevel: info + logFile: console + authSecret: "__NETBIRD_AUTH_SECRET__" + dataDir: "/var/lib/netbird" + disableAnonymousMetrics: true + auth: + issuer: "https://__NETBIRD_DOMAIN__/oauth2" + signKeyRefreshEnabled: true + dashboardRedirectURIs: + - "https://__NETBIRD_DOMAIN__/nb-auth" + - "https://__NETBIRD_DOMAIN__/nb-silent-auth" + reverseProxy: + trustedHTTPProxies: + - "__NETBIRD_PROXY_SUBNET__" + trustedPeers: + - "__NETBIRD_PROXY_SUBNET__" + store: + engine: sqlite + encryptionKey: "__NETBIRD_ENCRYPTION_KEY__" + + entrypoint.sh: | + #!/bin/sh + set -eu + + umask 077 + + TEMPLATE_PATH=/opt/netbird/config.template.yaml + RENDERED_PATH=/run/netbird/config.yaml + RELAY_SECRET_PATH=/run/secrets/relay_auth_secret + ENCRYPTION_KEY_PATH=/run/secrets/datastore_encryption_key + + is_valid_proxy_subnet() { + candidate="$1" + case "$candidate" in + 0.0.0.0/0) + return 1 + ;; + */*) + address="${candidate%%/*}" + prefix="${candidate#*/}" + ;; + *) + return 1 + ;; + esac + + case "$prefix" in + 0|[1-9]|[1-2][0-9]|3[0-2]) ;; + *) + return 1 + ;; + esac + + old_ifs="$IFS" + IFS=. + # shellcheck disable=SC2086 + set -- $address + IFS="$old_ifs" + [ "$#" -eq 4 ] || return 1 + + for octet do + case "$octet" in + 0|[1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]) ;; + *) + return 1 + ;; + esac + done + } + + read_secret() { + secret_path="$1" + + if [ ! -r "$secret_path" ]; then + echo "Required secret is not readable: $secret_path" >&2 + exit 1 + fi + + secret_value="$(cat "$secret_path")" + if [ -z "$secret_value" ]; then + echo "Required secret is empty: $secret_path" >&2 + exit 1 + fi + + printf '%s' "$secret_value" + } + + if [ -z "${NETBIRD_DOMAIN:-}" ]; then + echo "NETBIRD_DOMAIN must be set" >&2 + exit 1 + fi + + case "$NETBIRD_DOMAIN" in + *[!A-Za-z0-9.-]*) + echo "NETBIRD_DOMAIN contains unsupported characters" >&2 + exit 1 + ;; + esac + + if [ -z "${NETBIRD_PROXY_SUBNET:-}" ] || [ "$NETBIRD_PROXY_SUBNET" = "auto" ]; then + echo "NETBIRD_PROXY_SUBNET must be an explicit IPv4 CIDR; run netbird/setup.sh first" >&2 + exit 1 + fi + if ! is_valid_proxy_subnet "$NETBIRD_PROXY_SUBNET"; then + echo "NETBIRD_PROXY_SUBNET must be a non-default IPv4 CIDR, for example 172.20.0.0/16" >&2 + exit 1 + fi + + if [ "$#" -ne 2 ] || [ "$1" != "--config" ] || [ "$2" != "$RENDERED_PATH" ]; then + echo "Expected: --config $RENDERED_PATH" >&2 + exit 1 + fi + + relay_secret="$(read_secret "$RELAY_SECRET_PATH")" + encryption_key="$(read_secret "$ENCRYPTION_KEY_PATH")" + + mkdir -p "$(dirname "$RENDERED_PATH")" + sed \ + -e "s|__NETBIRD_DOMAIN__|${NETBIRD_DOMAIN}|g" \ + -e "s|__NETBIRD_AUTH_SECRET__|${relay_secret}|g" \ + -e "s|__NETBIRD_ENCRYPTION_KEY__|${encryption_key}|g" \ + -e "s|__NETBIRD_PROXY_SUBNET__|${NETBIRD_PROXY_SUBNET}|g" \ + "$TEMPLATE_PATH" >"$RENDERED_PATH" + + if grep -q '__NETBIRD_' "$RENDERED_PATH"; then + echo "Rendered NetBird configuration still contains unresolved placeholders" >&2 + exit 1 + fi + + exec /go/bin/netbird-server "$@" diff --git a/netbird/k8s/ingress.yaml b/netbird/k8s/ingress.yaml new file mode 100644 index 0000000..a1d762e --- /dev/null +++ b/netbird/k8s/ingress.yaml @@ -0,0 +1,83 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: netbird-prod + namespace: netbird +spec: + entryPoints: + - websecure + routes: + - match: Host(`nb.forust.xyz`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`)) + kind: Rule + priority: 100 + middlewares: + - name: crowdsec-bouncer + namespace: crowdsec + services: + - name: netbird-server-service + port: 80 + scheme: h2c + - match: Host(`nb.forust.xyz`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`)) + kind: Rule + priority: 100 + middlewares: + - name: crowdsec-bouncer + namespace: crowdsec + services: + - name: netbird-server-service + port: 80 + - match: Host(`nb.forust.xyz`) + kind: Rule + priority: 1 + middlewares: + - name: crowdsec-bouncer + namespace: crowdsec + services: + - name: netbird-dashboard-service + port: 80 + tls: + secretName: netbird-prod-tls +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: netbird-local + namespace: netbird +spec: + entryPoints: + - websecure + routes: + - match: (Host(`netbird.workstation.internal`) || Host(`netbird.gigaforust.internal`)) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`) || PathPrefix(`/management.ProxyService/`)) + kind: Rule + priority: 100 + services: + - name: netbird-server-service + port: 80 + scheme: h2c + - match: (Host(`netbird.workstation.internal`) || Host(`netbird.gigaforust.internal`)) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`)) + kind: Rule + priority: 100 + services: + - name: netbird-server-service + port: 80 + - match: Host(`netbird.workstation.internal`) || Host(`netbird.gigaforust.internal`) + kind: Rule + priority: 1 + services: + - name: netbird-dashboard-service + port: 80 + tls: + secretName: internal-wildcard-tls +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRouteUDP +metadata: + name: netbird-stun + namespace: netbird +spec: + entryPoints: + - netbird-stun + routes: + - services: + - name: netbird-server-service + port: 3478 diff --git a/netbird/k8s/namespace.yaml b/netbird/k8s/namespace.yaml new file mode 100644 index 0000000..db05a13 --- /dev/null +++ b/netbird/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: netbird diff --git a/netbird/k8s/netbird.yaml b/netbird/k8s/netbird.yaml new file mode 100644 index 0000000..7ae1fc4 --- /dev/null +++ b/netbird/k8s/netbird.yaml @@ -0,0 +1,181 @@ +apiVersion: v1 +kind: Service +metadata: + name: netbird-server-service + namespace: netbird +spec: + selector: + app: netbird-server + ports: + - port: 80 + name: http + targetPort: 80 + protocol: TCP + - port: 3478 + name: stun + targetPort: 3478 + protocol: UDP +--- +apiVersion: v1 +kind: Service +metadata: + name: netbird-dashboard-service + namespace: netbird +spec: + selector: + app: netbird-dashboard + ports: + - port: 80 + name: http + targetPort: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: netbird-server-deployment + namespace: netbird +spec: + replicas: 1 + selector: + matchLabels: + app: netbird-server + template: + metadata: + labels: + app: netbird-server + spec: + containers: + - name: netbird-server + image: netbirdio/netbird-server:0.79.0 + command: ["/bin/sh", "/opt/netbird/entrypoint.sh", "--config", "/run/netbird/config.yaml"] + envFrom: + - configMapRef: + name: netbird-config + ports: + - containerPort: 80 + name: http + protocol: TCP + - containerPort: 3478 + name: stun + protocol: UDP + volumeMounts: + - name: netbird-data + mountPath: /var/lib/netbird + - name: netbird-files + mountPath: /opt/netbird + readOnly: true + - name: netbird-secrets + mountPath: /run/secrets/relay_auth_secret + subPath: relay_auth_secret + readOnly: true + - name: netbird-secrets + mountPath: /run/secrets/datastore_encryption_key + subPath: datastore_encryption_key + readOnly: true + - name: netbird-run + mountPath: /run/netbird + readinessProbe: + tcpSocket: + port: 80 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 5 + livenessProbe: + tcpSocket: + port: 80 + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 5 + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "1000m" + volumes: + - name: netbird-data + persistentVolumeClaim: + claimName: netbird-pvc + - name: netbird-files + configMap: + name: netbird-config + defaultMode: 0755 + items: + - key: config.template.yaml + path: config.template.yaml + - key: entrypoint.sh + path: entrypoint.sh + - name: netbird-secrets + secret: + secretName: netbird-secrets + items: + - key: relay_auth_secret + path: relay_auth_secret + - key: datastore_encryption_key + path: datastore_encryption_key + - name: netbird-run + emptyDir: + medium: Memory +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: netbird-dashboard-deployment + namespace: netbird +spec: + replicas: 1 + selector: + matchLabels: + app: netbird-dashboard + template: + metadata: + labels: + app: netbird-dashboard + spec: + containers: + - name: dashboard + image: netbirdio/dashboard:v2.90.10 + envFrom: + - configMapRef: + name: netbird-config + ports: + - containerPort: 80 + name: http + readinessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 5 + livenessProbe: + httpGet: + path: / + port: 80 + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 5 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: netbird-pvc + namespace: netbird +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/netbird/k8s/secrets.yaml.example b/netbird/k8s/secrets.yaml.example new file mode 100644 index 0000000..699f9ef --- /dev/null +++ b/netbird/k8s/secrets.yaml.example @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: netbird-secrets + namespace: netbird +type: Opaque +stringData: + # hex, 64 chars: openssl rand -hex 32 + relay_auth_secret: "REPLACE_ME" + # base64, 44 chars: openssl rand -base64 32 + datastore_encryption_key: "REPLACE_ME" diff --git a/netbox/.env.example b/netbox/.env.example new file mode 100644 index 0000000..0fbc3b2 --- /dev/null +++ b/netbox/.env.example @@ -0,0 +1,32 @@ +POSTGRES_DB=netbox +POSTGRES_USER=netbox +POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD + +DB_NAME=netbox +DB_USER=netbox +DB_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD +DB_HOST=postgres +DB_PORT=5432 +DB_SSLMODE=disable + +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD +REDIS_DATABASE=0 +REDIS_CACHE_HOST=redis-cache +REDIS_CACHE_PORT=6379 +REDIS_CACHE_PASSWORD=CHANGE_ME_REDIS_CACHE_PASSWORD +REDIS_CACHE_DATABASE=1 + +ALLOWED_HOSTS=localhost,127.0.0.1,[::1],netbox.forust.xyz,netbox.workstation.internal +CSRF_TRUSTED_ORIGINS=https://netbox.forust.xyz,https://netbox.workstation.internal + +SECRET_KEY=CHANGE_ME_DJANGO_SECRET_KEY +API_TOKEN_PEPPER_1=CHANGE_ME_API_TOKEN_PEPPER +TIME_ZONE=Europe/Bratislava +TZ=Europe/Bratislava + +SKIP_SUPERUSER=false +SUPERUSER_NAME=admin +SUPERUSER_EMAIL=admin@example.com +SUPERUSER_PASSWORD=CHANGE_ME_SUPERUSER_PASSWORD diff --git a/netbox/README.md b/netbox/README.md new file mode 100644 index 0000000..3a352f8 --- /dev/null +++ b/netbox/README.md @@ -0,0 +1,96 @@ +# NetBox + +NetBox for homelab documentation and visualization. Two runtimes are available: + +| Runtime | Manifest | Purpose | +| ------- | -------------- | -------------------------------------------------------------- | +| Docker | `compose.yaml` | Local stand on `127.0.0.1:8000` (no public exposure) | +| k8s | `k8s/` | Homelab service on `netbox.forust.xyz` (and the internal name) | + +Both use the same image (`netboxcommunity/netbox:v4.7-5.1.1`) and Valkey for tasks +plus a second logical database for caching. The Docker stand keeps its own +PostgreSQL container, while the k8s deployment uses the shared `database` cluster +(`postgres.database.svc.cluster.local:5432`, role/database `netbox`); only Valkey +stays a per-service StatefulSet. + +## Docker Compose + +```bash +cp .env.example .env +# replace CHANGE_ME +docker compose up -d +``` + +The UI is available at . The port is bound to `127.0.0.1` +intentionally, so this stand is not exposed on the LAN or public interfaces. + +The `netbox` service is also attached to the external `proxy` network and carries +Traefik labels for `netbox.forust.xyz` and `netbox.workstation.internal`. Those +labels only take effect while the Docker Traefik stack is running; it is currently +stopped, and the live ingress path in this homelab is the k8s Traefik. + +Inspect startup and health with: + +```bash +docker compose ps +docker compose logs -f netbox +``` + +Stop it with `docker compose down`; data is kept in the named volumes +`netbox-postgres`, `netbox-media-files`, `netbox-reports-files`, +`netbox-scripts-files` and `netbox-redis-data`. + +## Kubernetes + +`k8s/` is deployed in the homelab cluster and serves `netbox.forust.xyz` publicly +plus `netbox.workstation.internal` / `netbox.gigaforust.internal` internally. To +rebuild it from scratch: + +```bash +# 1. shared PostgreSQL: the password lives in the shared secret, NetBox keeps a copy +kubectl -n database patch secret postgres-shared-secrets \ + --type merge -p '{"stringData":{"NETBOX_DB_PASSWORD":""}}' +kubectl -n database exec postgres17-0 -- psql -U postgres -d postgres \ + -c 'CREATE ROLE netbox LOGIN PASSWORD ...' -c 'CREATE DATABASE netbox OWNER netbox' + +# 2. secrets first: the deploy workflow never applies *secret*.yaml +cp k8s/secrets.yaml.example k8s/secrets.yaml # replace CHANGE_ME +kubectl apply -f k8s/secrets.yaml + +# 3. manifests +kubectl apply -f k8s/ +``` + +The shared cluster is reached at `postgres.database.svc.cluster.local:5432`. Its +NetworkPolicy (`postgres/k8s/network-policy.yaml`) must list the `netbox` namespace +or connections are dropped, and `postgres/initdb/01-create-databases.sh` already +creates the role and database on a fresh data directory. NetBox has no PostgreSQL +StatefulSet of its own — only `netbox-valkey`. + +`netbox.forust.xyz` resolves to this host (`78.98.72.122`) through the `DOMAINS` +list in the `default/cfddns` secret. cert-manager issues `netbox-prod-tls` with the +`letsencrypt-prod` issuer, the internal route uses `internal-wildcard-tls`. + +Resources are permanent again now that the first-boot migrations are complete: +the web container reserves `100m`/`512Mi` and is capped at `2` CPU/`2Gi`, the +worker reserves `50m`/`256Mi` and is capped at `1` CPU/`1Gi`, and Valkey reserves +`25m`/`64Mi` and is capped at `250m`/`256Mi`. The deliberately generous CPU caps +leave enough headroom for future schema migrations without letting one process +consume the whole node. + +The first start applies ~810 migrations, each in its own transaction with DDL and +a commit; every later start is a no-op. The startup probe allows 15 minutes and +`progressDeadlineSeconds` is 1800 for the same reason. Probes run inside the pod +and explicitly set `Host: netbox.forust.xyz`; a kubelet `httpGet.host` field would +replace the probe destination with that public hostname and bypass the pod. + +## Secrets + +- `netbox/.env` (compose) and `netbox/k8s/secrets.yaml` (k8s) are gitignored. Only + `.env.example` and `k8s/secrets.yaml.example` are committed. +- `netbox/configuration/configuration.py` is env-driven: hosts, database, Redis and + the Django keys all come from the environment, so the same settings file works in + both runtimes. The k8s copy lives in the `netbox-settings` ConfigMap + (`k8s/settings.yaml`) and must be kept in sync with the file. +- Rotating `SECRET_KEY` invalidates all sessions; rotating `API_TOKEN_PEPPER_1` + invalidates every API token. diff --git a/checkmk/k8s/active b/netbox/active similarity index 100% rename from checkmk/k8s/active rename to netbox/active diff --git a/netbox/compose.yaml b/netbox/compose.yaml new file mode 100644 index 0000000..1b2f312 --- /dev/null +++ b/netbox/compose.yaml @@ -0,0 +1,137 @@ +services: + netbox: + image: docker.io/netboxcommunity/netbox:v4.7-5.1.1 + container_name: netbox + restart: unless-stopped + user: "netbox:root" + ports: + - "127.0.0.1:8000:8080" + env_file: + - .env + environment: + GRANIAN_WORKERS: "2" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + redis-cache: + condition: service_healthy + volumes: + - ./configuration:/etc/netbox/config:z,ro + - netbox-media-files:/opt/netbox/netbox/media + - netbox-reports-files:/opt/netbox/netbox/reports + - netbox-scripts-files:/opt/netbox/netbox/scripts + networks: + - default + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.services.netbox.loadbalancer.server.port=8080" + + # Prod Router + - "traefik.http.routers.netbox.rule=Host(`netbox.forust.xyz`)" + - "traefik.http.routers.netbox.entrypoints=websecure" + - "traefik.http.routers.netbox.middlewares=security-headers@file" + - "traefik.http.routers.netbox.tls.certresolver=letsencrypt" + # Local Router + - "traefik.http.routers.netbox-local.rule=Host(`netbox.workstation.internal`)" + - "traefik.http.routers.netbox-local.entrypoints=websecure" + - "traefik.http.routers.netbox-local.tls=true" + healthcheck: + test: ["CMD", "/opt/netbox/health.sh"] + start_period: 600s + timeout: 5s + interval: 15s + retries: 10 + + netbox-worker: + image: docker.io/netboxcommunity/netbox:v4.7-5.1.1 + container_name: netbox-worker + restart: unless-stopped + user: "netbox:root" + command: + - /opt/netbox/venv/bin/python + - /opt/netbox/netbox/manage.py + - rqworker + env_file: + - .env + depends_on: + netbox: + condition: service_healthy + volumes: + - ./configuration:/etc/netbox/config:z,ro + - netbox-media-files:/opt/netbox/netbox/media + - netbox-reports-files:/opt/netbox/netbox/reports + - netbox-scripts-files:/opt/netbox/netbox/scripts + healthcheck: + test: ["CMD-SHELL", "ps -ef | grep -q '[r]qworker'"] + start_period: 30s + timeout: 5s + interval: 15s + retries: 10 + + postgres: + image: docker.io/postgres:18.6-alpine + container_name: netbox-postgres + restart: unless-stopped + environment: + POSTGRES_DB: "${POSTGRES_DB:?POSTGRES_DB must be set}" + POSTGRES_USER: "${POSTGRES_USER:?POSTGRES_USER must be set}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}" + volumes: + - netbox-postgres:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", 'pg_isready -q -t 2 -d "$${POSTGRES_DB}" -U "$${POSTGRES_USER}"'] + start_period: 20s + timeout: 5s + interval: 10s + retries: 10 + + redis: + image: docker.io/valkey/valkey:9.1.2-alpine + container_name: netbox-redis + restart: unless-stopped + command: + - sh + - -c + - valkey-server --appendonly yes --requirepass "$$REDIS_PASSWORD" + environment: + REDIS_PASSWORD: "${REDIS_PASSWORD:?REDIS_PASSWORD must be set}" + volumes: + - netbox-redis-data:/data + healthcheck: + test: ["CMD-SHELL", 'valkey-cli --pass "$${REDIS_PASSWORD}" ping | grep -q PONG'] + start_period: 5s + timeout: 5s + interval: 5s + retries: 10 + + redis-cache: + image: docker.io/valkey/valkey:9.1.2-alpine + container_name: netbox-redis-cache + restart: unless-stopped + command: + - sh + - -c + - valkey-server --requirepass "$$REDIS_CACHE_PASSWORD" + environment: + REDIS_CACHE_PASSWORD: "${REDIS_CACHE_PASSWORD:?REDIS_CACHE_PASSWORD must be set}" + healthcheck: + test: ["CMD-SHELL", 'valkey-cli --pass "$${REDIS_CACHE_PASSWORD}" ping | grep -q PONG'] + start_period: 5s + timeout: 5s + interval: 5s + retries: 10 + +volumes: + netbox-media-files: + netbox-reports-files: + netbox-scripts-files: + netbox-postgres: + netbox-redis-data: + +networks: + default: + proxy: + external: true diff --git a/netbox/configuration/configuration.py b/netbox/configuration/configuration.py new file mode 100644 index 0000000..9a3a552 --- /dev/null +++ b/netbox/configuration/configuration.py @@ -0,0 +1,48 @@ +import os + + +def _csv(name, default=""): + return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()] + + +ALLOWED_HOSTS = _csv("ALLOWED_HOSTS", "localhost,127.0.0.1,[::1]") +CSRF_TRUSTED_ORIGINS = _csv("CSRF_TRUSTED_ORIGINS") +USE_X_FORWARDED_HOST = True +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +DATABASES = { + "default": { + "NAME": os.environ["DB_NAME"], + "USER": os.environ["DB_USER"], + "PASSWORD": os.environ["DB_PASSWORD"], + "HOST": os.environ["DB_HOST"], + "PORT": os.environ.get("DB_PORT", "5432"), + "OPTIONS": {"sslmode": os.environ.get("DB_SSLMODE", "disable")}, + "CONN_MAX_AGE": int(os.environ.get("DB_CONN_MAX_AGE", "300")), + } +} + +REDIS = { + "tasks": { + "HOST": os.environ["REDIS_HOST"], + "PORT": int(os.environ.get("REDIS_PORT", "6379")), + "PASSWORD": os.environ["REDIS_PASSWORD"], + "DATABASE": int(os.environ.get("REDIS_DATABASE", "0")), + "SSL": False, + }, + "caching": { + "HOST": os.environ["REDIS_CACHE_HOST"], + "PORT": int(os.environ.get("REDIS_CACHE_PORT", "6379")), + "PASSWORD": os.environ["REDIS_CACHE_PASSWORD"], + "DATABASE": int(os.environ.get("REDIS_CACHE_DATABASE", "1")), + "SSL": False, + }, +} + +SECRET_KEY = os.environ["SECRET_KEY"] +API_TOKEN_PEPPERS = {1: os.environ["API_TOKEN_PEPPER_1"]} +TIME_ZONE = os.environ.get("TIME_ZONE", "UTC") +MEDIA_ROOT = "/opt/netbox/netbox/media" +REPORTS_ROOT = "/opt/netbox/netbox/reports" +SCRIPTS_ROOT = "/opt/netbox/netbox/scripts" +CENSUS_REPORTING_ENABLED = False diff --git a/netbox/k8s/certificates.yaml b/netbox/k8s/certificates.yaml new file mode 100644 index 0000000..b26909b --- /dev/null +++ b/netbox/k8s/certificates.yaml @@ -0,0 +1,28 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: netbox-prod-tls + namespace: netbox +spec: + secretName: netbox-prod-tls + dnsNames: + - netbox.forust.xyz + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: internal-wildcard-tls + namespace: netbox +spec: + secretName: internal-wildcard-tls + dnsNames: + - "*.workstation.internal" + - "*.gigaforust.internal" + - workstation.internal + - gigaforust.internal + issuerRef: + name: internal-ca + kind: ClusterIssuer diff --git a/netbox/k8s/config.yaml b/netbox/k8s/config.yaml new file mode 100644 index 0000000..9d9dc65 --- /dev/null +++ b/netbox/k8s/config.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: netbox-config + namespace: netbox +data: + DB_HOST: "postgres.database.svc.cluster.local" + DB_PORT: "5432" + DB_SSLMODE: "disable" + REDIS_HOST: "netbox-valkey" + REDIS_PORT: "6379" + REDIS_DATABASE: "0" + REDIS_CACHE_HOST: "netbox-valkey" + REDIS_CACHE_PORT: "6379" + REDIS_CACHE_DATABASE: "1" + TIME_ZONE: "Europe/Bratislava" + TZ: "Europe/Bratislava" + GRANIAN_WORKERS: "2" + ALLOWED_HOSTS: "netbox.forust.xyz,netbox.workstation.internal,netbox.gigaforust.internal" + CSRF_TRUSTED_ORIGINS: "https://netbox.forust.xyz,https://netbox.workstation.internal,https://netbox.gigaforust.internal" + SKIP_SUPERUSER: "false" diff --git a/netbox/k8s/ingress.yaml b/netbox/k8s/ingress.yaml new file mode 100644 index 0000000..bb4b7ea --- /dev/null +++ b/netbox/k8s/ingress.yaml @@ -0,0 +1,36 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: netbox-prod + namespace: netbox +spec: + entryPoints: + - websecure + routes: + - match: Host(`netbox.forust.xyz`) + kind: Rule + middlewares: + - name: crowdsec-bouncer + namespace: crowdsec + services: + - name: netbox-service + port: 8080 + tls: + secretName: netbox-prod-tls +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: netbox-local + namespace: netbox +spec: + entryPoints: + - websecure + routes: + - match: Host(`netbox.workstation.internal`) || Host(`netbox.gigaforust.internal`) + kind: Rule + services: + - name: netbox-service + port: 8080 + tls: + secretName: internal-wildcard-tls diff --git a/netbox/k8s/namespace.yaml b/netbox/k8s/namespace.yaml new file mode 100644 index 0000000..1a63822 --- /dev/null +++ b/netbox/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: netbox diff --git a/netbox/k8s/netbox.yaml b/netbox/k8s/netbox.yaml new file mode 100644 index 0000000..95f778c --- /dev/null +++ b/netbox/k8s/netbox.yaml @@ -0,0 +1,204 @@ +apiVersion: v1 +kind: Service +metadata: + name: netbox-service + namespace: netbox +spec: + selector: + app: netbox + ports: + - name: http + port: 8080 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: netbox-deployment + namespace: netbox + labels: + app: netbox +spec: + replicas: 1 + progressDeadlineSeconds: 300 + selector: + matchLabels: + app: netbox + strategy: + # ReadWriteOnce PVC + type: Recreate + template: + metadata: + labels: + app: netbox + spec: + containers: + - name: netbox + image: docker.io/netboxcommunity/netbox:v4.7-5.1.1 + ports: + - name: http + containerPort: 8080 + envFrom: + - configMapRef: + name: netbox-config + - secretRef: + name: netbox-secrets + volumeMounts: + - name: netbox-config + mountPath: /etc/netbox/config + readOnly: true + - name: netbox-media + mountPath: /opt/netbox/netbox/media + - name: netbox-reports + mountPath: /opt/netbox/netbox/reports + - name: netbox-scripts + mountPath: /opt/netbox/netbox/scripts + startupProbe: + exec: + command: + - /opt/netbox/venv/bin/python + - -c + - >- + exec /usr/bin/curl --fail --silent --show-error --max-time 4 + --header 'Host: netbox.forust.xyz' + http://127.0.0.1:8080/login/ >/dev/null + failureThreshold: 90 + periodSeconds: 10 + readinessProbe: + exec: + command: + - /opt/netbox/venv/bin/python + - -c + - >- + exec /usr/bin/curl --fail --silent --show-error --max-time 4 + --header 'Host: netbox.forust.xyz' + http://127.0.0.1:8080/login/ >/dev/null + periodSeconds: 10 + livenessProbe: + exec: + command: + - /opt/netbox/venv/bin/python + - -c + - >- + exec /usr/bin/curl --fail --silent --show-error --max-time 4 + --header 'Host: netbox.forust.xyz' + http://127.0.0.1:8080/login/ >/dev/null + initialDelaySeconds: 30 + periodSeconds: 30 + resources: + requests: + cpu: "100m" + memory: "512Mi" + limits: + cpu: "2" + memory: "2Gi" + volumes: + - name: netbox-config + configMap: + name: netbox-settings + - name: netbox-media + persistentVolumeClaim: + claimName: netbox-media-pvc + - name: netbox-reports + persistentVolumeClaim: + claimName: netbox-reports-pvc + - name: netbox-scripts + persistentVolumeClaim: + claimName: netbox-scripts-pvc +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: netbox-worker-deployment + namespace: netbox + labels: + app: netbox-worker +spec: + replicas: 1 + progressDeadlineSeconds: 300 + selector: + matchLabels: + app: netbox-worker + strategy: + type: Recreate + template: + metadata: + labels: + app: netbox-worker + spec: + containers: + - name: netbox-worker + image: docker.io/netboxcommunity/netbox:v4.7-5.1.1 + command: + - /opt/netbox/venv/bin/python + - netbox/manage.py + - rqworker + workingDir: /opt/netbox + envFrom: + - configMapRef: + name: netbox-config + - secretRef: + name: netbox-secrets + volumeMounts: + - name: netbox-config + mountPath: /etc/netbox/config + readOnly: true + - name: netbox-media + mountPath: /opt/netbox/netbox/media + - name: netbox-reports + mountPath: /opt/netbox/netbox/reports + - name: netbox-scripts + mountPath: /opt/netbox/netbox/scripts + resources: + requests: + cpu: "50m" + memory: "256Mi" + limits: + cpu: "1" + memory: "1Gi" + volumes: + - name: netbox-config + configMap: + name: netbox-settings + - name: netbox-media + persistentVolumeClaim: + claimName: netbox-media-pvc + - name: netbox-reports + persistentVolumeClaim: + claimName: netbox-reports-pvc + - name: netbox-scripts + persistentVolumeClaim: + claimName: netbox-scripts-pvc +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: netbox-media-pvc + namespace: netbox +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 2Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: netbox-reports-pvc + namespace: netbox +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: netbox-scripts-pvc + namespace: netbox +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi diff --git a/netbox/k8s/secrets.yaml.example b/netbox/k8s/secrets.yaml.example new file mode 100644 index 0000000..dabfabe --- /dev/null +++ b/netbox/k8s/secrets.yaml.example @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Secret +metadata: + name: netbox-secrets + namespace: netbox +type: Opaque +stringData: + DB_NAME: "netbox" + DB_USER: "netbox" + DB_PASSWORD: "CHANGE_ME_POSTGRES_PASSWORD" + REDIS_PASSWORD: "CHANGE_ME_VALKEY_PASSWORD" + REDIS_CACHE_PASSWORD: "CHANGE_ME_VALKEY_PASSWORD" + VALKEY_PASSWORD: "CHANGE_ME_VALKEY_PASSWORD" + SECRET_KEY: "CHANGE_ME_DJANGO_SECRET_KEY" + API_TOKEN_PEPPER_1: "CHANGE_ME_API_TOKEN_PEPPER" + SUPERUSER_NAME: "admin" + SUPERUSER_EMAIL: "admin@example.com" + SUPERUSER_PASSWORD: "CHANGE_ME_SUPERUSER_PASSWORD" diff --git a/netbox/k8s/settings.yaml b/netbox/k8s/settings.yaml new file mode 100644 index 0000000..fbd67d8 --- /dev/null +++ b/netbox/k8s/settings.yaml @@ -0,0 +1,56 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: netbox-settings + namespace: netbox +data: + # Sync wit netbox/configuration/configuration.py (the Docker mounts that file). + configuration.py: | + import os + + + def _csv(name, default=""): + return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()] + + + ALLOWED_HOSTS = _csv("ALLOWED_HOSTS", "localhost,127.0.0.1,[::1]") + CSRF_TRUSTED_ORIGINS = _csv("CSRF_TRUSTED_ORIGINS") + USE_X_FORWARDED_HOST = True + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + + DATABASES = { + "default": { + "NAME": os.environ["DB_NAME"], + "USER": os.environ["DB_USER"], + "PASSWORD": os.environ["DB_PASSWORD"], + "HOST": os.environ["DB_HOST"], + "PORT": os.environ.get("DB_PORT", "5432"), + "OPTIONS": {"sslmode": os.environ.get("DB_SSLMODE", "disable")}, + "CONN_MAX_AGE": int(os.environ.get("DB_CONN_MAX_AGE", "300")), + } + } + + REDIS = { + "tasks": { + "HOST": os.environ["REDIS_HOST"], + "PORT": int(os.environ.get("REDIS_PORT", "6379")), + "PASSWORD": os.environ["REDIS_PASSWORD"], + "DATABASE": int(os.environ.get("REDIS_DATABASE", "0")), + "SSL": False, + }, + "caching": { + "HOST": os.environ["REDIS_CACHE_HOST"], + "PORT": int(os.environ.get("REDIS_CACHE_PORT", "6379")), + "PASSWORD": os.environ["REDIS_CACHE_PASSWORD"], + "DATABASE": int(os.environ.get("REDIS_CACHE_DATABASE", "1")), + "SSL": False, + }, + } + + SECRET_KEY = os.environ["SECRET_KEY"] + API_TOKEN_PEPPERS = {1: os.environ["API_TOKEN_PEPPER_1"]} + TIME_ZONE = os.environ.get("TIME_ZONE", "UTC") + MEDIA_ROOT = "/opt/netbox/netbox/media" + REPORTS_ROOT = "/opt/netbox/netbox/reports" + SCRIPTS_ROOT = "/opt/netbox/netbox/scripts" + CENSUS_REPORTING_ENABLED = False diff --git a/netbox/k8s/valkey.yaml b/netbox/k8s/valkey.yaml new file mode 100644 index 0000000..fc8cef9 --- /dev/null +++ b/netbox/k8s/valkey.yaml @@ -0,0 +1,82 @@ +apiVersion: v1 +kind: Service +metadata: + name: netbox-valkey + namespace: netbox + labels: + app: netbox-valkey +spec: + clusterIP: None + selector: + app: netbox-valkey + ports: + - name: valkey + port: 6379 + targetPort: valkey +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: netbox-valkey + namespace: netbox + labels: + app: netbox-valkey +spec: + serviceName: netbox-valkey + replicas: 1 + selector: + matchLabels: + app: netbox-valkey + template: + metadata: + labels: + app: netbox-valkey + spec: + containers: + - name: valkey + image: docker.io/valkey/valkey:9.1.2-alpine + command: + - sh + - -c + - valkey-server --appendonly yes --save 30 1 --loglevel warning --requirepass "$VALKEY_PASSWORD" + env: + - name: VALKEY_PASSWORD + valueFrom: + secretKeyRef: + name: netbox-secrets + key: VALKEY_PASSWORD + ports: + - name: valkey + containerPort: 6379 + volumeMounts: + - name: valkey-data + mountPath: /data + startupProbe: + exec: + command: ["sh", "-c", 'valkey-cli --pass "$VALKEY_PASSWORD" ping | grep -q PONG'] + failureThreshold: 20 + periodSeconds: 5 + readinessProbe: + exec: + command: ["sh", "-c", 'valkey-cli --pass "$VALKEY_PASSWORD" ping | grep -q PONG'] + periodSeconds: 10 + livenessProbe: + exec: + command: ["sh", "-c", 'valkey-cli --pass "$VALKEY_PASSWORD" ping | grep -q PONG'] + initialDelaySeconds: 20 + periodSeconds: 20 + resources: + requests: + cpu: "25m" + memory: "64Mi" + limits: + cpu: "250m" + memory: "256Mi" + volumeClaimTemplates: + - metadata: + name: valkey-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi diff --git a/postgres/README.md b/postgres/README.md index 982bd41..db166ac 100644 --- a/postgres/README.md +++ b/postgres/README.md @@ -1,7 +1,7 @@ # Shared PostgreSQL This directory contains the shared PostgreSQL 17 deployment for Authentik, -Gitea, Netronome, and Statuspage. It creates one database and one login role +Gitea, NetBox, Netronome, and Statuspage. It creates one database and one login role per service. Per-service standalone databases were removed after the migration (Sep 2026); Penpot stays on its own compose PostgreSQL (archived, not part of the shared instance). @@ -12,6 +12,7 @@ not part of the shared instance). | ---------- | ------------------- | -------------------------------------- | | Authentik | 2025.10.x | Supported (Authentik requires 14+) | | Gitea | 1.27.3 | Supported (Gitea requires 12+) | +| NetBox | 4.7.x | Supported (NetBox 4.x requires 13+) | | Netronome | 0.14.0 | Supported (upstream's example uses 17) | | Statuspage | custom | Supported | diff --git a/postgres/initdb/01-create-databases.sh b/postgres/initdb/01-create-databases.sh index 4da0148..fbcba0f 100755 --- a/postgres/initdb/01-create-databases.sh +++ b/postgres/initdb/01-create-databases.sh @@ -3,6 +3,7 @@ set -euo pipefail : "${AUTHENTIK_DB_PASSWORD:?AUTHENTIK_DB_PASSWORD is required}" : "${GITEA_DB_PASSWORD:?GITEA_DB_PASSWORD is required}" +: "${NETBOX_DB_PASSWORD:?NETBOX_DB_PASSWORD is required}" : "${NETRONOME_DB_PASSWORD:?NETRONOME_DB_PASSWORD is required}" : "${PENPOT_DB_PASSWORD:?PENPOT_DB_PASSWORD is required}" : "${STATUSPAGE_DB_PASSWORD:?STATUSPAGE_DB_PASSWORD is required}" @@ -23,6 +24,7 @@ SQL create_role_and_database authentik authentik "$AUTHENTIK_DB_PASSWORD" create_role_and_database gitea gitea "$GITEA_DB_PASSWORD" +create_role_and_database netbox netbox "$NETBOX_DB_PASSWORD" create_role_and_database netronome netronome "$NETRONOME_DB_PASSWORD" create_role_and_database penpot penpot "$PENPOT_DB_PASSWORD" create_role_and_database statuspage statuspage "$STATUSPAGE_DB_PASSWORD" diff --git a/postgres/k8s/network-policy.yaml b/postgres/k8s/network-policy.yaml index b4ec4dd..17203de 100644 --- a/postgres/k8s/network-policy.yaml +++ b/postgres/k8s/network-policy.yaml @@ -17,6 +17,9 @@ spec: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: gitea + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: netbox - namespaceSelector: matchLabels: kubernetes.io/metadata.name: netronome diff --git a/postgres/k8s/postgres.yaml b/postgres/k8s/postgres.yaml index e452d52..ae83540 100644 --- a/postgres/k8s/postgres.yaml +++ b/postgres/k8s/postgres.yaml @@ -113,6 +113,7 @@ data: : "${AUTHENTIK_DB_PASSWORD:?AUTHENTIK_DB_PASSWORD is required}" : "${GITEA_DB_PASSWORD:?GITEA_DB_PASSWORD is required}" + : "${NETBOX_DB_PASSWORD:?NETBOX_DB_PASSWORD is required}" : "${NETRONOME_DB_PASSWORD:?NETRONOME_DB_PASSWORD is required}" : "${PENPOT_DB_PASSWORD:?PENPOT_DB_PASSWORD is required}" : "${STATUSPAGE_DB_PASSWORD:?STATUSPAGE_DB_PASSWORD is required}" @@ -133,6 +134,7 @@ data: create_role_and_database authentik authentik "$AUTHENTIK_DB_PASSWORD" create_role_and_database gitea gitea "$GITEA_DB_PASSWORD" + create_role_and_database netbox netbox "$NETBOX_DB_PASSWORD" create_role_and_database netronome netronome "$NETRONOME_DB_PASSWORD" create_role_and_database penpot penpot "$PENPOT_DB_PASSWORD" create_role_and_database statuspage statuspage "$STATUSPAGE_DB_PASSWORD" diff --git a/postgres/k8s/secrets.yaml.example b/postgres/k8s/secrets.yaml.example index 4768d78..7311069 100644 --- a/postgres/k8s/secrets.yaml.example +++ b/postgres/k8s/secrets.yaml.example @@ -8,6 +8,7 @@ stringData: POSTGRES_ADMIN_PASSWORD: "" AUTHENTIK_DB_PASSWORD: "" GITEA_DB_PASSWORD: "" + NETBOX_DB_PASSWORD: "" NETRONOME_DB_PASSWORD: "" PENPOT_DB_PASSWORD: "" STATUSPAGE_DB_PASSWORD: "" diff --git a/rackpeek/compose.yaml b/rackpeek/compose.yaml new file mode 100644 index 0000000..5c257b4 --- /dev/null +++ b/rackpeek/compose.yaml @@ -0,0 +1,34 @@ +services: + rackpeek: + image: docker.io/aptacode/rackpeek:v2.1.0 + container_name: rackpeek + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + TZ: "Europe/Bratislava" + volumes: + - rackpeek-config:/app/config + networks: + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.services.rackpeek.loadbalancer.server.port=8080" + + # Local Router + - "traefik.http.routers.rackpeek-local.rule=Host(`rackpeek.workstation.internal`) || Host(`rack.workstation.internal`) || Host(`rackpeek.gigaforust.internal`) || Host(`rack.gigaforust.internal`)" + - "traefik.http.routers.rackpeek-local.entrypoints=websecure" + - "traefik.http.routers.rackpeek-local.tls=true" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"] + start_period: 15s + timeout: 5s + interval: 30s + retries: 3 + +volumes: + rackpeek-config: + +networks: + proxy: + external: true diff --git a/rackpeek/k8s/active b/rackpeek/k8s/active new file mode 100644 index 0000000..e69de29 diff --git a/rackpeek/k8s/certificates.yaml b/rackpeek/k8s/certificates.yaml new file mode 100644 index 0000000..05fd5a5 --- /dev/null +++ b/rackpeek/k8s/certificates.yaml @@ -0,0 +1,15 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: internal-wildcard-tls + namespace: rackpeek +spec: + secretName: internal-wildcard-tls + dnsNames: + - "*.workstation.internal" + - "*.gigaforust.internal" + - workstation.internal + - gigaforust.internal + issuerRef: + name: internal-ca + kind: ClusterIssuer diff --git a/rackpeek/k8s/ingress.yaml b/rackpeek/k8s/ingress.yaml new file mode 100644 index 0000000..a1c1947 --- /dev/null +++ b/rackpeek/k8s/ingress.yaml @@ -0,0 +1,16 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: rackpeek-local + namespace: rackpeek +spec: + entryPoints: + - websecure + routes: + - match: Host(`rackpeek.workstation.internal`) || Host(`rack.workstation.internal`) || Host(`rackpeek.gigaforust.internal`) || Host(`rack.gigaforust.internal`) + kind: Rule + services: + - name: rackpeek-service + port: 8080 + tls: + secretName: internal-wildcard-tls diff --git a/rackpeek/k8s/namespace.yaml b/rackpeek/k8s/namespace.yaml new file mode 100644 index 0000000..c30ce2f --- /dev/null +++ b/rackpeek/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: rackpeek diff --git a/rackpeek/k8s/rackpeek.yaml b/rackpeek/k8s/rackpeek.yaml new file mode 100644 index 0000000..42fa1fb --- /dev/null +++ b/rackpeek/k8s/rackpeek.yaml @@ -0,0 +1,89 @@ +apiVersion: v1 +kind: Service +metadata: + name: rackpeek-service + namespace: rackpeek +spec: + selector: + app: rackpeek + ports: + - name: http + port: 8080 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rackpeek-deployment + namespace: rackpeek + labels: + app: rackpeek +spec: + replicas: 1 + selector: + matchLabels: + app: rackpeek + strategy: + type: Recreate + template: + metadata: + labels: + app: rackpeek + spec: + securityContext: + # Image runs as uid/gid 1654 + fsGroup: 1654 + containers: + - name: rackpeek + image: docker.io/aptacode/rackpeek:v2.1.0 + ports: + - name: http + containerPort: 8080 + env: + - name: RPK_YAML_DIR + value: "/app/config" + - name: TZ + value: "Europe/Bratislava" + volumeMounts: + - name: rackpeek-config + mountPath: /app/config + startupProbe: + httpGet: + path: /health + port: http + failureThreshold: 30 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 30 + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumes: + - name: rackpeek-config + persistentVolumeClaim: + claimName: rackpeek-pvc +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: rackpeek-pvc + namespace: rackpeek +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: local-path-retain + resources: + requests: + storage: 2Gi diff --git a/traefik/compose.yaml b/traefik/compose.yaml index 247d12a..05b8a7c 100644 --- a/traefik/compose.yaml +++ b/traefik/compose.yaml @@ -20,6 +20,7 @@ services: - "--entryPoints.web.http.redirections.entryPoint.scheme=https" - "--entryPoints.web.http.redirections.entryPoint.to=websecure" - "--entryPoints.websecure.address=:443" + - "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0" - "--entryPoints.websecure.http.middlewares=error-pages@docker" - "--entryPoints.websecure.http.tls=true" - "--entryPoints.ssh.address=:2221" diff --git a/traefik/k8s/traefik-values.yaml b/traefik/k8s/traefik-values.yaml index 9fc2e63..566e8a8 100644 --- a/traefik/k8s/traefik-values.yaml +++ b/traefik/k8s/traefik-values.yaml @@ -86,6 +86,12 @@ ports: protocol: UDP expose: default: true + netbird-stun: + port: 3478 + exposedPort: 3478 + protocol: UDP + expose: + default: true checkmk-agent: port: 8000 protocol: TCP