Compare commits

..
Author SHA1 Message Date
renovate-bot a112b03ba1 chore(deps): update container patch updates
ci / lint-prettier (push) Successful in 6s
ci / lint-ruff (push) Successful in 3s
ci / lint-yaml (push) Successful in 6s
ci / lint-dockerfiles (push) Successful in 4s
ci / validate (push) Successful in 7s
ci / lint-prettier (pull_request) Successful in 5s
ci / lint-ruff (pull_request) Successful in 2s
ci / lint-yaml (pull_request) Successful in 4s
ci / lint-dockerfiles (pull_request) Successful in 2s
ci / validate (pull_request) Successful in 4s
renovate-ci / validate-renovate (pull_request) Successful in 14s
ci / build (push) Has been skipped
ci / build (pull_request) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
ci / deploy-userbot-panel (pull_request) Has been skipped
2026-09-20 16:18:00 +00:00
145 changed files with 429 additions and 3635 deletions

No files matched your search

+20
View File
@@ -347,3 +347,23 @@ jobs:
;; ;;
esac esac
done done
deploy-userbot-panel:
needs: build
if: github.ref_name == 'main' && contains(needs.build.outputs.services, 'userbot')
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Apply and roll out userbot panel
shell: bash
run: |
kubectl apply -f userbot/k8s/base/panel.yaml
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
# Keep legacy deployments (forust/anna) in sync with manifests; they have no replicas field, so apply leaves scaling to the user manager only.
kubectl apply -f userbot/k8s/base/userbots.yaml
kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
-241
View File
@@ -1,241 +0,0 @@
#!/usr/bin/env bash
# Shared stages for the deploy workflow. Runs on the workstation, invoked as:
# REPO=/srv/homelab APPLY_PRUNE=false bash -se <<'EOF'
# source "$REPO/.gitea/workflows/deploy-lib.sh"
# run_stage "$STAGE"
# EOF
set -euo pipefail
: "${REPO:?REPO must be set}"
APPLY_PRUNE="${APPLY_PRUNE:-false}"
log() {
echo "== $* =="
}
collect_k8s() {
git -C "$REPO" ls-files -- "$1" \
| grep -E '\.ya?ml$' \
| grep -Ev '/overlays/' \
| grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$' \
| grep -Ev '(^|/)[^/]*secret[^/]*\.ya?ml$' \
| sort
}
kustomize_overlay() {
if [ -f "$1/overlays/prod/kustomization.yaml" ]; then
echo "$1/overlays/prod"
elif [ -f "$1/base/kustomization.yaml" ]; then
echo "$1/base"
fi
}
select_manifests() {
K8S_MANIFESTS=()
KUSTOMIZE_APPS=()
COMPOSE_STACKS=()
local kd_rel kd overlay cf_rel cf f
while IFS= read -r kd_rel; do
kd="$REPO/$kd_rel"
if [ ! -f "$kd/active" ]; then
echo "skip (no k8s/active): $kd_rel"
continue
fi
overlay="$(kustomize_overlay "$kd" || true)"
if [ -n "${overlay:-}" ]; then
echo "kustomize app: ${overlay#$REPO/}"
KUSTOMIZE_APPS+=("$overlay")
else
while IFS= read -r f; do
[ -n "$f" ] && K8S_MANIFESTS+=("$REPO/$f")
done < <(collect_k8s "$kd_rel" || true)
fi
done < <(
git -C "$REPO" ls-files '*.yaml' '*.yml' \
| grep -E '(^|/)k8s/' \
| sed -E 's#((^|.*/)k8s)/.*#\1#' \
| sort -u
)
while IFS= read -r cf_rel; do
cf="$REPO/$cf_rel"
if [ -f "$(dirname "$cf")/active" ]; then
echo "compose: $cf_rel"
COMPOSE_STACKS+=("$cf")
else
echo "skip (no root active): $cf_rel"
fi
done < <(git -C "$REPO" ls-files '*/compose.yaml' '*/compose.yml' compose.yaml compose.yml | sort)
}
stage_preflight() {
if [ ! -d "$REPO/.git" ]; then
echo "Repository not found at $REPO"
exit 1
fi
git -C "$REPO" fetch origin main
log "Workstation state"
echo " local: $(git -C "$REPO" rev-parse --short HEAD)"
echo " remote: $(git -C "$REPO" rev-parse --short origin/main)"
if [ -n "$(git -C "$REPO" status --porcelain --untracked-files=no)" ]; then
echo "ERROR: workstation has local tracked modifications, refusing reset:"
git -C "$REPO" status --porcelain --untracked-files=no
git -C "$REPO" diff --stat
exit 1
fi
git -C "$REPO" reset --hard origin/main
}
stage_validate() {
cd "$REPO"
select_manifests
local m k cf
log "Validate compose stacks"
for cf in ${COMPOSE_STACKS[@]+"${COMPOSE_STACKS[@]}"}; do
echo " config: $cf"
docker compose -f "$cf" config --quiet
done
log "Validate k8s manifests (kubectl dry-run=client)"
for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do
kubectl apply --dry-run=client -f "$m" >/dev/null
done
for k in ${KUSTOMIZE_APPS[@]+"${KUSTOMIZE_APPS[@]}"}; do
kubectl apply -k "$k" --dry-run=client >/dev/null
done
log "Validate k8s manifests (kubectl dry-run=server)"
for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do
kubectl apply --dry-run=server -f "$m" >/dev/null
done
for k in ${KUSTOMIZE_APPS[@]+"${KUSTOMIZE_APPS[@]}"}; do
kubectl apply -k "$k" --dry-run=server >/dev/null
done
log "Checking referenced Secrets exist"
echo " (deploy never applies *secret*.yaml; create missing ones from the laptop)"
local ref_secrets=() missing_secrets=() all_secrets s
if [ "${#K8S_MANIFESTS[@]}" -gt 0 ]; then
while IFS= read -r s; do
[ -n "$s" ] && ref_secrets+=("$s")
done < <(
{
grep -h -A1 -E 'secretRef:|secretKeyRef:' "${K8S_MANIFESTS[@]}" 2>/dev/null || true
grep -h -E 'secretName:' "${K8S_MANIFESTS[@]}" 2>/dev/null || true
} | grep -E 'name:' | sed -E 's/.*name:[[:space:]]*//' | tr -d '"'"'"' "'"'" | sed -E 's/[[:space:]]*#.*//' | awk 'NF' | sort -u || true
)
fi
all_secrets="$(kubectl get secrets -A --no-headers -o custom-columns=:metadata.name 2>/dev/null || true)"
for s in ${ref_secrets[@]+"${ref_secrets[@]}"}; do
if printf '%s\n' "$all_secrets" | grep -qx "$s"; then
echo " ok: $s"
else
echo " MISSING: $s"
missing_secrets+=("$s")
fi
done
if [ "${#missing_secrets[@]}" -gt 0 ]; then
echo "ERROR: ${#missing_secrets[@]} referenced Secret(s) not found in the cluster:"
printf ' - %s\n' "${missing_secrets[@]}"
echo "Create them manually from the laptop, e.g.:"
echo " kubectl apply -f SERVICE/k8s/secrets.yaml # see SERVICE/k8s/secrets.yaml.example"
exit 1
fi
}
stage_apply_k8s() {
cd "$REPO"
select_manifests >/dev/null
local ns_files=() other_files=() m k prune_opts=()
for m in ${K8S_MANIFESTS[@]+"${K8S_MANIFESTS[@]}"}; do
case "$m" in
*/namespace.y?ml) ns_files+=("$m") ;;
*) other_files+=("$m") ;;
esac
done
if [ "$APPLY_PRUNE" = "true" ]; then
prune_opts=(--prune -l app.kubernetes.io/managed-by=homelab-deploy)
fi
if [ "${#ns_files[@]}" -gt 0 ]; then
log "Applying namespaces (${#ns_files[@]} files)"
for m in "${ns_files[@]}"; do
kubectl apply -f "$m"
done
fi
if [ -f "$REPO/prometheus-stack/k8s/active" ]; then
if [ ! -f "$REPO/prometheus-stack/k8s/grafana-values.yaml" ]; then
echo "ERROR: prometheus-stack/k8s/grafana-values.yaml (gitignored) missing on workstation, restore it first."
exit 1
fi
log "Upgrading kube-prometheus-stack"
helm upgrade --install prometheus-stack prometheus-community/kube-prometheus-stack \
--namespace prometheus \
--version 86.2.3 \
--values "$REPO/prometheus-stack/k8s/grafana-values.yaml" \
--wait --timeout 10m
fi
if [ -f "$REPO/loki/k8s/active" ]; then
log "Upgrading loki/alloy"
helm repo add grafana https://grafana.github.io/helm-charts >/dev/null 2>&1 || true
helm repo update grafana >/dev/null 2>&1 || true
helm upgrade --install loki grafana/loki \
--version 7.3.0 \
--namespace prometheus \
--values "$REPO/loki/k8s/loki-values.yaml" \
--wait --timeout 10m
helm upgrade --install alloy grafana/alloy \
--version 1.12.1 \
--namespace prometheus \
--values "$REPO/loki/k8s/alloy-values.yaml" \
--wait --timeout 10m
fi
if [ "${#other_files[@]}" -gt 0 ]; then
log "Applying resources (${#other_files[@]} files)"
for m in "${other_files[@]}"; do
kubectl apply "${prune_opts[@]}" -f "$m"
done
fi
for k in ${KUSTOMIZE_APPS[@]+"${KUSTOMIZE_APPS[@]}"}; do
log "Applying kustomize app: ${k#$REPO/}"
kubectl apply -k "$k"
done
if [ -f "$REPO/userbot/k8s/active" ]; then
log "userbot panel hook"
if kubectl get secret userbot-common-secrets -n userbot >/dev/null 2>&1; then
echo " userbot-common-secrets already present in userbot ns, not touching"
elif kubectl get secret userbot-common-secrets -n default >/dev/null 2>&1; then
echo " bootstrapping userbot-common-secrets into userbot ns"
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
else
echo " WARNING: userbot-common-secrets missing in both default and userbot ns; create it manually from the laptop"
fi
kubectl rollout restart deployment/userbot-panel -n userbot
kubectl rollout status deployment/userbot-panel -n userbot --timeout=180s
fi
}
stage_apply_compose() {
cd "$REPO"
select_manifests >/dev/null
local cf
log "Redeploying docker compose stacks (${#COMPOSE_STACKS[@]} stacks)"
for cf in ${COMPOSE_STACKS[@]+"${COMPOSE_STACKS[@]}"}; do
echo " compose: $cf"
if grep -Eq '^\s+pull_policy:\s*build\b' "$cf"; then
docker compose -f "$cf" build
docker compose -f "$cf" push
fi
docker compose -f "$cf" up -d --pull always --remove-orphans
done
}
run_stage() {
case "${1:?stage required}" in
preflight) stage_preflight ;;
validate) stage_validate ;;
apply-k8s) stage_apply_k8s ;;
apply-compose) stage_apply_compose ;;
*)
echo "ERROR: unknown stage: $1"
exit 1
;;
esac
}
+154 -49
View File
@@ -1,71 +1,176 @@
name: deploy name: deploy
on: on:
push:
branches:
- main
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: deploy-main group: deploy-main
cancel-in-progress: false cancel-in-progress: false
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
APPLY_PRUNE: ${{ vars.APPLY_PRUNE }}
jobs: jobs:
preflight: redeploy:
runs-on: [self-hosted, linux, arch, homelab, prod] runs-on: [self-hosted, linux, arch, homelab, prod]
steps: steps:
- name: Checkout repository - name: Redeploy workstation
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Fetch and reset workstation
shell: bash shell: bash
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# Set APPLY_PRUNE=true to enable kubectl apply --prune. Requires every
# manifest to carry label app.kubernetes.io/managed-by=homelab-deploy,
# otherwise previously applied resources get deleted on the next run.
APPLY_PRUNE: ${{ vars.APPLY_PRUNE }}
run: | run: |
set -euo pipefail set -euo pipefail
./.gitea/workflows/ssh-run.sh preflight
validate: : "${DEPLOY_HOST:?missing DEPLOY_HOST}"
needs: [preflight] : "${DEPLOY_USER:?missing DEPLOY_USER}"
runs-on: [self-hosted, linux, arch, homelab, prod] : "${DEPLOY_KEY:?missing DEPLOY_SSH_KEY}"
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Dry-run manifests and check Secrets deploy_port="${DEPLOY_PORT:-22}"
shell: bash deploy_path="${DEPLOY_PATH:-/srv/homelab}"
run: |
ssh_key="$RUNNER_TEMP/deploy_key"
mkdir -p "$RUNNER_TEMP"
printf '%s\n' "$DEPLOY_KEY" > "$ssh_key"
chmod 600 "$ssh_key"
ssh_opts=(
-i "$ssh_key"
-p "$deploy_port"
-o BatchMode=yes
-o StrictHostKeyChecking=accept-new
)
ssh "${ssh_opts[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"DEPLOY_PATH=$(printf '%q' \"$deploy_path\") APPLY_PRUNE=$(printf '%q' \"${APPLY_PRUNE:-false}\") bash -se" <<'EOF'
set -euo pipefail set -euo pipefail
./.gitea/workflows/ssh-run.sh validate
apply-k8s: repo="${DEPLOY_PATH:-/srv/homelab}"
needs: [validate]
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Apply Kubernetes manifests if [ ! -d "$repo/.git" ]; then
shell: bash echo "Repository not found at $repo"
run: | exit 1
set -euo pipefail fi
./.gitea/workflows/ssh-run.sh apply-k8s
apply-compose: git -C "$repo" fetch origin main
needs: [validate] git -C "$repo" reset --hard origin/main
runs-on: [self-hosted, linux, arch, homelab, prod]
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Redeploy docker compose stacks # Runtime selection: a service is k8s-managed when $SERVICE/k8s/active
shell: bash # exists. Otherwise it is compose-managed, and only k8s/routing/*
run: | # manifests (external Services / EndpointSlices / ServersTransport /
set -euo pipefail # Ingresses that route to docker backends) are applied.
./.gitea/workflows/ssh-run.sh apply-compose # migrate: touch SERVICE/k8s/active (+ move routing files up)
# rollback: rm SERVICE/k8s/active
collect_k8s() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
! -path '*/routing/*' ! -path '*/overlays/*' \
! -name 'kustomization.y*ml' ! -name '*.example.y*ml' \
! -name '*values.y*ml' ! -name 'patch-*.y*ml' \
| sort
}
collect_k8s_inactive() {
find "$1" -type f \( -name '*.yaml' -o -name '*.yml' \) \
\( -name 'namespace.y*ml' -o -path '*/routing/*' \) \
! -path '*/overlays/*' ! -name '*.example.y*ml' \
| sort
}
mapfile -t compose_stacks < <(
find "$repo" -type f \( -name 'compose.yaml' -o -name 'compose.yml' \) | sort
)
mapfile -t k8s_manifests < <(
for kd in $(find "$repo" -type d -name k8s ! -path '*/.git/*' | sort); do
if [ -f "$kd/active" ]; then
collect_k8s "$kd"
else
collect_k8s_inactive "$kd"
fi
done
)
echo "== Validate compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " config: $cf"
docker compose -f "$cf" config --quiet
done
echo "== Validate k8s manifests (kubectl dry-run) =="
for m in "${k8s_manifests[@]}"; do
echo " apply --dry-run=client $m"
kubectl apply --dry-run=client -f "$m" >/dev/null
done
echo "== Applying Kubernetes manifests =="
ns_files=()
other_files=()
for m in "${k8s_manifests[@]}"; do
case "$m" in
*/namespace.y?ml) ns_files+=("$m") ;;
*) other_files+=("$m") ;;
esac
done
prune_opts=()
if [ "${APPLY_PRUNE:-false}" = "true" ]; then
prune_opts=(--prune -l app.kubernetes.io/managed-by=homelab-deploy)
fi
if [ "${#ns_files[@]}" -gt 0 ]; then
echo " namespaces first: ${ns_files[*]}"
kubectl apply -f "${ns_files[@]}"
fi
if [ -f "$repo/prometheus-stack/k8s/active" ]; then
echo "== Upgrading kube-prometheus-stack =="
helm upgrade --install prometheus-stack prometheus-community/kube-prometheus-stack \
--namespace prometheus \
--version 86.2.3 \
--values "$repo/prometheus-stack/k8s/grafana-values.yaml" \
--wait
fi
if [ -f "$repo/loki/k8s/active" ]; then
echo "== Upgrading loki/alloy =="
helm repo add grafana https://grafana.github.io/helm-charts >/dev/null 2>&1 || true
helm repo update grafana >/dev/null 2>&1 || true
helm upgrade --install loki grafana/loki \
--version 7.3.0 \
--namespace prometheus \
--values "$repo/loki/k8s/loki-values.yaml" \
--wait
helm upgrade --install alloy grafana/alloy \
--version 1.12.1 \
--namespace prometheus \
--values "$repo/loki/k8s/alloy-values.yaml" \
--wait
fi
if [ "${#other_files[@]}" -gt 0 ]; then
echo " resources: ${other_files[*]}"
kubectl apply "${prune_opts[@]}" -f "${other_files[@]}"
fi
echo "== Redeploying docker compose stacks =="
for cf in "${compose_stacks[@]}"; do
dir=$(dirname "$cf")
if [ -f "$dir/k8s/active" ]; then
echo " skip (k8s-managed): $dir"
continue
fi
echo " compose: $dir"
if grep -Eq '^\s+pull_policy:\s*build\b' "$cf"; then
docker compose -f "$cf" build
docker compose -f "$cf" push
fi
docker compose -f "$cf" up -d --pull always --remove-orphans
done
EOF
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
# usage: ssh-run.sh <stage>
# Runs one deploy-lib.sh stage on the workstation over SSH.
set -euo pipefail
: "${DEPLOY_HOST:?missing DEPLOY_HOST}"
: "${DEPLOY_USER:?missing DEPLOY_USER}"
: "${DEPLOY_KEY:?missing DEPLOY_SSH_KEY}"
deploy_port="${DEPLOY_PORT:-22}"
deploy_path="${DEPLOY_PATH:-/srv/homelab}"
deploy_path="$(printf '%s' "$deploy_path" | tr -d '\"' | tr -d '\r' | xargs)"
ssh_key="$RUNNER_TEMP/deploy_key"
mkdir -p "$RUNNER_TEMP"
printf '%s\n' "$DEPLOY_KEY" > "$ssh_key"
chmod 600 "$ssh_key"
ssh -i "$ssh_key" -p "$deploy_port" \
-o BatchMode=yes -o StrictHostKeyChecking=accept-new \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
"REPO=$deploy_path APPLY_PRUNE=${APPLY_PRUNE:-false} STAGE=$1 bash -se" <<'EOF'
source "$REPO/.gitea/workflows/deploy-lib.sh"
run_stage "$STAGE"
EOF
-7
View File
@@ -21,9 +21,6 @@ checkmk/checkmk/*
downtify/Downtify_downloads downtify/Downtify_downloads
headscale/config/* headscale/config/*
headscale/data/* headscale/data/*
# NetBird local hostnames and generated secrets
netbird/.env
netbird/secrets/
searxng/core-config/* searxng/core-config/*
# Steaming services files # Steaming services files
@@ -107,10 +104,6 @@ temp/*
# kubernetes # kubernetes
*/k8s/*secret* */k8s/*secret*
!*/k8s/*secret*.example !*/k8s/*secret*.example
**/k8s/*secret*
!**/k8s/*secret*.example
# Local-only tweaks, not for upstream
prometheus-stack/k8s/grafana-values.yaml
traefik/k8s/local-tls.yaml traefik/k8s/local-tls.yaml
converters/k8s/config.yaml converters/k8s/config.yaml
convertx/k8s/config.yaml convertx/k8s/config.yaml
-2
View File
@@ -62,8 +62,6 @@ spec:
metadata: metadata:
labels: labels:
app: adguard app: adguard
annotations:
reloader.stakater.com/auto: "true"
spec: spec:
containers: containers:
- name: adguard - name: adguard
-12
View File
@@ -1,12 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: adguard-certs
namespace: adguard
spec:
secretName: adguard-certs
dnsNames:
- dns.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
+3 -5
View File
@@ -7,7 +7,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`dns.forust.xyz`) - match: Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)
kind: Rule kind: Rule
middlewares: middlewares:
- name: crowdsec-bouncer - name: crowdsec-bouncer
@@ -15,13 +15,13 @@ spec:
services: services:
- name: adguard-service - name: adguard-service
port: 3000 port: 3000
- match: (Host(`dns.forust.xyz`)) && PathPrefix(`/dns-query`) - match: (Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)) && PathPrefix(`/dns-query`)
kind: Rule kind: Rule
services: services:
- name: adguard-service - name: adguard-service
port: 3000 port: 3000
tls: tls:
secretName: adguard-certs certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -42,5 +42,3 @@ spec:
services: services:
- name: adguard-service - name: adguard-service
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
-15
View File
@@ -1,15 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: adguard
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: authentik-prod-tls
namespace: authentik
spec:
secretName: authentik-prod-tls
dnsNames:
- auth.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: authentik
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: authentik-server-service - name: authentik-server-service
port: 9000 port: 9000
tls: tls:
secretName: authentik-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: authentik-server-service - name: authentik-server-service
port: 9000 port: 9000
tls:
secretName: internal-wildcard-tls
@@ -1,9 +0,0 @@
crds:
enabled: true
prometheus:
servicemonitor:
enabled: true
interval: 60s
scrapeTimeout: 30s
labels:
release: prometheus-stack
-29
View File
@@ -1,29 +0,0 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
email: bobrovod@national.shitposting.agency
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
class: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
email: bobrovod@national.shitposting.agency
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
class: traefik
@@ -1,30 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIFFjCCAv6gAwIBAgIUetKpTfEDOn2985FFMu6G26itT+wwDQYJKoZIhvcNAQEN
BQAwIzEhMB8GA1UEAxMYaG9tZWxhYiBpbnRlcm5hbCByb290IENBMB4XDTI2MDky
MzEyNDA0N1oXDTM2MDkyMDEyNDA0N1owIzEhMB8GA1UEAxMYaG9tZWxhYiBpbnRl
cm5hbCByb290IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvmNP
ZCOoD8NtNuYJKVXBlTPjX7D7sJCSK5neH7ZbYV5+lmUlEErY8Mik7j37V5k5NfpF
Ig85pOjP7RckTPz5V6ek3yaN40s4AL053sN5ZPauDVYjalaEHTgj5sEMqlLACQWI
yZmJOZspZykae8dIpQnqCoFpRT4FurJ78v4a0ylnFVLMQn/lyCHedwTjkEdtYWYr
ccJy8vQwqkzs/rWvEH1lDqZhennLOrmcCjfonG7D/pruMn4z+6E28p4+ejkRrI6x
luak3KnpT1XMeHtgU21hiRGaMDBchHMFgAhnY1qosymKenXvfTZItwgjZbwa1hJI
GAiDm+jQDKMjzRZ3rH6Xfc0auUcykNz73PpNu1NGm78nndXwCXcXn1LFKNQJ+r1U
sJiyAmUZmXVn4aM4OMf2F38k7wTYIKg7nRGaUkNeKDlNkjA4HvgWw+jwO1KmdHQ/
mOem1rosDWHRK01wg+Gga9mQCnhNhxglg3t/UeSic6uOaRsvaz4qkzHq8MbCujVz
DpKQjqdikYOAXZOs4KlBLWrS7NaK4NzfSD02pBUErh54ruJfY/bWz9KyXzBD/lQZ
VUTKyvUVB0bkVHEdf1jJmX3H4IZRQSF5JPqOBotW6bJI5fEGNBvj9Zxy4nm2WWGz
yyP3uWsQz8U/Wdx9nXZLHInTZBsvgLYtKUAWA30CAwEAAaNCMEAwDgYDVR0PAQH/
BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFEkKm2rxPaK6+O9WD80z
BLC6F9QsMA0GCSqGSIb3DQEBDQUAA4ICAQAnFyHz97Umf5VIu+dKTJid7C73VugJ
TIar/xJBs/4CxP+znBxhJjXygRoyIfzoVGWcB2ZSL//vL78Qlts79K/Imc9a4RFF
wMvCxsRXAEQ4TpeWi3ophPNcs4rhsP+gQKQFtnyKP9519bqpfxp0bTqwOV2o18fn
za7rlQViiEnNV58j7CVoM9+mJvVVfBEX1Km+GyJL9GadzbIQ7FxClVJZefCbft93
zHVk9gDOw8ys1XGSR2OUCyCLinXO6mqS16CmBb2MAKXq/YyH7E0N8iotAPGtfA8V
M/0ddy947rY0xCrtECfWwvGQpJS7NRv/Z9b2jCfXrI5LXmL2nfQRg0y9GE4Vjwr+
WxtGU5jOeFt0jQ+xRzcgG0Op+qK3x55l5LSo2hOcOVYbiHxcHEJFgwNi1ADeBFwb
q/HdysfURSOghqjIpMMAUabBp+DBUg2EUF7pIaUqbdqExFYcr9EYisEMiNsmKmN+
8ZbcOeerFKDQj+t/R0bFXa7UBn2UWsjI8zlR74aa2kLDXwtyz/XlO/FlYm66eBFo
2/eYUSeU+S4ej+wUAs/dvjF7f190/DUQGuwOTlLTahqWDztmhCk7qzbECu56CwKT
E5Ect2P72UleYwdblkVOVd352AmiwEzdOaziIRrPh8uenEknH6JBYPO3mjk7cCg+
GPGcNIBctjdXhg==
-----END CERTIFICATE-----
-33
View File
@@ -1,33 +0,0 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned
spec:
selfSigned: {}
---
# Homelab internal root CA (10y). Install the .crt on clients (see below).
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-ca-root
namespace: cert-manager
spec:
isCA: true
commonName: homelab internal root CA
duration: 87600h
renewBefore: 7200h
secretName: internal-ca-root
privateKey:
algorithm: RSA
size: 4096
issuerRef:
name: selfsigned
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: internal-ca
spec:
ca:
secretName: internal-ca-root
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: cert-manager
File renamed without changes.
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: checkmk-prod-tls
namespace: checkmk
spec:
secretName: checkmk-prod-tls
dnsNames:
- cmk.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: checkmk
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: checkmk-service - name: checkmk-service
port: 5000 port: 5000
tls: tls:
secretName: checkmk-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP kind: IngressRouteTCP
@@ -48,5 +48,3 @@ spec:
services: services:
- name: checkmk-service - name: checkmk-service
port: 5000 port: 5000
tls:
secretName: internal-wildcard-tls
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec: spec:
containers: containers:
- name: cloudflared - name: cloudflared
image: cloudflare/cloudflared:2026.9.3 image: cloudflare/cloudflared:2026.9.1
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
args: args:
- tunnel - tunnel
-42
View File
@@ -1,42 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: convertx-prod-tls
namespace: converters
spec:
secretName: convertx-prod-tls
dnsNames:
- forust.xyz
- www.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: bentopdf-prod-tls
namespace: converters
spec:
secretName: bentopdf-prod-tls
dnsNames:
- pdf.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: converters
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+2 -7
View File
@@ -14,7 +14,7 @@ spec:
- name: convertx-service - name: convertx-service
port: 3000 port: 3000
tls: tls:
secretName: convertx-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -31,9 +31,6 @@ spec:
services: services:
- name: convertx-service - name: convertx-service
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -50,7 +47,7 @@ spec:
- name: bentopdf-service - name: bentopdf-service
port: 8080 port: 8080
tls: tls:
secretName: bentopdf-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -66,5 +63,3 @@ spec:
services: services:
- name: bentopdf-service - name: bentopdf-service
port: 8080 port: 8080
tls:
secretName: internal-wildcard-tls
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
dockmon: dockmon:
image: darthnorse/dockmon:2.5.0 image: darthnorse/dockmon:2.4.5
container_name: dockmon container_name: dockmon
restart: unless-stopped restart: unless-stopped
# ports: # ports:
File renamed without changes.
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: dockmon-prod-tls
namespace: dockmon
spec:
secretName: dockmon-prod-tls
dnsNames:
- dockmon.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: dockmon
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -1
View File
@@ -29,7 +29,7 @@ spec:
spec: spec:
containers: containers:
- name: dockmon - name: dockmon
image: darthnorse/dockmon:2.5.0 image: darthnorse/dockmon:2.4.5
ports: ports:
- containerPort: 443 - containerPort: 443
volumeMounts: volumeMounts:
+1 -3
View File
@@ -26,7 +26,7 @@ spec:
port: 443 port: 443
serversTransport: dockmon-transport serversTransport: dockmon-transport
tls: tls:
secretName: dockmon-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -43,5 +43,3 @@ spec:
- name: dockmon-service - name: dockmon-service
port: 443 port: 443
serversTransport: dockmon-transport serversTransport: dockmon-transport
tls:
secretName: internal-wildcard-tls
+1 -1
View File
@@ -1,7 +1,7 @@
services: services:
downtify: downtify:
container_name: downtify container_name: downtify
image: ghcr.io/henriquesebastiao/downtify:3.1.0 image: ghcr.io/henriquesebastiao/downtify:2.13.0
restart: unless-stopped restart: unless-stopped
# ports: # ports:
# - '7077:8000' # - '7077:8000'
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
spec: spec:
containers: containers:
- name: downtify - name: downtify
image: ghcr.io/henriquesebastiao/downtify:3.1.0 image: ghcr.io/henriquesebastiao/downtify:2.13.0
ports: ports:
- containerPort: 8000 - containerPort: 8000
volumeMounts: volumeMounts:
+1 -3
View File
@@ -17,7 +17,7 @@ spec:
- name: downtify-service - name: downtify-service
port: 8000 port: 8000
tls: tls:
secretName: downtify-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -33,5 +33,3 @@ spec:
services: services:
- name: downtify-service - name: downtify-service
port: 8000 port: 8000
tls:
secretName: internal-wildcard-tls
-1
View File
@@ -1 +0,0 @@
1.56.0
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
redis: redis:
image: redis:8.10.2-alpine image: redis:8.10.1-alpine
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- redis-data:/data - redis-data:/data
@@ -11,7 +11,7 @@ services:
retries: 5 retries: 5
playwright-service: playwright-service:
image: mcr.microsoft.com/playwright:v1.56.0-jammy image: mcr.microsoft.com/playwright:v1.63.0-jammy
restart: unless-stopped restart: unless-stopped
command: npx -y playwright@1.56.0 run-server --port 3000 --path /ws command: npx -y playwright@1.56.0 run-server --port 3000 --path /ws
-77
View File
@@ -1,77 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: edu-master-webinar
namespace: edu-master
labels:
release: prometheus-stack
spec:
groups:
- name: edu_master.webinar
rules:
# No successful webinar check for 5m (~2-3 missed 2-min checks).
# Catches: playwright hangs/timeouts, version skew, site changes, hung job.
- alert: WebinarCheckerNoSuccessfulCheck
expr: |
(time() - webinar_check_last_success_timestamp_seconds > 300)
and (webinar_check_last_run_timestamp_seconds > 0)
for: 2m
labels:
severity: critical
annotations:
summary: "Webinar checker has no successful check for 5m"
description: "edu-master/webinar-checker: last successful webinar check was {{ $value | humanizeDuration }} ago. Checks are failing or hanging (see consecutive failures alert). Notifications about new webinars are NOT being sent."
# Fast path: 3 consecutive failures (~6+ min at 2-min interval).
- alert: WebinarCheckerConsecutiveFailures
expr: |
webinar_check_consecutive_failures >= 3
for: 5m
labels:
severity: critical
annotations:
summary: "Webinar checker failing consecutively"
description: 'edu-master/webinar-checker: {{ $value }} consecutive webinar check failures (timeout / playwright error / page error). Check pod logs (Loki: {namespace="edu-master", container="webinar-checker"}).'
# Metrics endpoint not scraped for 10m: pod down, metrics server dead, or ServiceMonitor broken.
- alert: WebinarCheckerScrapeDown
expr: |
absent(webinar_check_last_run_timestamp_seconds) == 1
for: 10m
labels:
severity: critical
annotations:
summary: "Webinar checker metrics missing"
description: "edu-master/webinar-checker: no metrics series for 10m. Pod may be down, metrics server dead, or ServiceMonitor/Service broken. Webinar checks are unobserved."
# EDU session lost: session-keeper down or credentials expired. Without PHPSESSID every check is skipped.
- alert: EduPhpsessidMissing
expr: |
edu_phpsessid_present == 0
for: 10m
labels:
severity: critical
annotations:
summary: "EDU_PHPSESSID missing"
description: "edu-master: EDU_PHPSESSID absent from redis for 10m. Webinar/diari/schedule checks are all skipped. Check session-keeper logs and EDU credentials."
# Hard deps: checker and playwright deployments unavailable.
- alert: WebinarCheckerDeploymentDown
expr: |
kube_deployment_status_replicas_unavailable{deployment="webinar-checker", namespace="edu-master"} > 0
for: 10m
labels:
severity: critical
annotations:
summary: "Webinar checker deployment unavailable"
description: "edu-master/webinar-checker deployment has {{ $value }} unavailable replica(s) for 10m."
- alert: PlaywrightServiceDown
expr: |
kube_deployment_status_replicas_unavailable{deployment="playwright-service", namespace="edu-master"} > 0
for: 10m
labels:
severity: critical
annotations:
summary: "Playwright service unavailable"
description: "edu-master/playwright-service deployment has {{ $value }} unavailable replica(s) for 10m. All webinar/diari/schedule checks fail without it."
+1 -2
View File
@@ -17,8 +17,7 @@ spec:
spec: spec:
containers: containers:
- name: playwright - name: playwright
# renovate: datasource=docker depName=mcr.microsoft.com/playwright versioning=docker image: mcr.microsoft.com/playwright:v1.63.0-jammy
image: mcr.microsoft.com/playwright:v1.56.0-jammy
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- npx - npx
+1 -1
View File
@@ -18,7 +18,7 @@ spec:
spec: spec:
containers: containers:
- name: redis - name: redis
image: redis:8.10.2-alpine image: redis:8.10.1-alpine
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 6379 - containerPort: 6379
-2
View File
@@ -21,8 +21,6 @@ stringData:
WEBINAR_TELEGRAM_TOKEN: "" WEBINAR_TELEGRAM_TOKEN: ""
WEBINAR_ADMIN_ID: "" WEBINAR_ADMIN_ID: ""
WEBINAR_CHECK_INTERVAL: "60" WEBINAR_CHECK_INTERVAL: "60"
# Prometheus metrics endpoint (scraped via ServiceMonitor, alerts in k8s/alerts.yaml)
METRICS_PORT: "8000"
# Database # Database
REDIS_HOST: "redis" REDIS_HOST: "redis"
REDIS_PORT: "6379" REDIS_PORT: "6379"
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: webinar-checker
namespace: edu-master
labels:
app: edu-master-webinar-checker
spec:
selector:
app: edu-master-webinar-checker
ports:
- name: metrics
port: 8000
targetPort: metrics
protocol: TCP
-16
View File
@@ -1,16 +0,0 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: webinar-checker
namespace: edu-master
labels:
release: prometheus-stack
spec:
selector:
matchLabels:
app: edu-master-webinar-checker
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
+1 -1
View File
@@ -17,7 +17,7 @@ spec:
spec: spec:
initContainers: initContainers:
- name: wait-redis - name: wait-redis
image: redis:8.10.2-alpine image: redis:8.10.1-alpine
command: command:
- /bin/sh - /bin/sh
- -ec - -ec
+1 -5
View File
@@ -19,7 +19,7 @@ spec:
# redis healthy -> session-keeper healthy (EXISTS EDU_PHPSESSID) -> playwright started # redis healthy -> session-keeper healthy (EXISTS EDU_PHPSESSID) -> playwright started
initContainers: initContainers:
- name: wait-deps - name: wait-deps
image: redis:8.10.2-alpine image: redis:8.10.1-alpine
command: command:
- /bin/sh - /bin/sh
- -ec - -ec
@@ -47,10 +47,6 @@ spec:
- name: webinar-checker - name: webinar-checker
image: gcr.forust.xyz/forust/webinar-checker:latest image: gcr.forust.xyz/forust/webinar-checker:latest
imagePullPolicy: Always imagePullPolicy: Always
ports:
- name: metrics
containerPort: 8000
protocol: TCP
envFrom: envFrom:
- secretRef: - secretRef:
name: edu-master-secrets name: edu-master-secrets
+2 -5
View File
@@ -2,11 +2,8 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# renovate: datasource=pypi depName=playwright versioning=pep440 # Install dependencies
ARG PLAYWRIGHT_VERSION=1.56.0 RUN pip install --no-cache-dir pip==25.0.1 && pip install --no-cache-dir playwright==1.56.0 redis==5.2.1 requests==2.32.3 "python-telegram-bot[job-queue]==21.10"
# Install dependencies - PLAYWRIGHT_VERSION is single-source, renovate updates ARG above and all other places via regexManagers
RUN pip install --no-cache-dir pip==25.0.1 && pip install --no-cache-dir playwright==${PLAYWRIGHT_VERSION} redis==5.2.1 requests==2.32.3 "python-telegram-bot[job-queue]==21.10"
COPY checker.py . COPY checker.py .
+144 -277
View File
@@ -1,15 +1,12 @@
import asyncio
import contextlib import contextlib
import json import json
import logging import logging
import os import os
import re import re
import tempfile import tempfile
import threading
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from html import escape from html import escape
from http.server import BaseHTTPRequestHandler, HTTPServer
import redis import redis
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
@@ -51,106 +48,6 @@ USER_AGENT = _env(
) )
WEBINAR_TELEGRAM_TOKEN = _env('WEBINAR_TELEGRAM_TOKEN') WEBINAR_TELEGRAM_TOKEN = _env('WEBINAR_TELEGRAM_TOKEN')
ADMIN_ID = int(_env('WEBINAR_ADMIN_ID', '0')) ADMIN_ID = int(_env('WEBINAR_ADMIN_ID', '0'))
METRICS_PORT = int(_env('METRICS_PORT', '8000'))
# --- Prometheus metrics (stdlib only, no extra deps) ---
# Scraped by prometheus-stack via ServiceMonitor (edu_master/k8s/servicemonitor.yaml).
# Critical alerts in edu_master/k8s/alerts.yaml fire to Telegram via Alertmanager.
_METRICS_LOCK = threading.Lock()
_METRICS = {
'last_run': 0.0, # Unix ts of last check start
'last_success': 0.0, # Unix ts of last successful check
'last_duration': 0.0, # Duration of last check in seconds
'success_total': 0,
'failure_total': 0,
'consecutive_failures': 0,
'phpsessid_present': 1, # 1 if EDU_PHPSESSID found in redis, else 0
}
def _metric_check_start():
with _METRICS_LOCK:
_METRICS['last_run'] = time.time()
def _metric_check_ok(duration: float):
now = time.time()
with _METRICS_LOCK:
_METRICS['last_success'] = now
_METRICS['last_duration'] = duration
_METRICS['success_total'] += 1
_METRICS['consecutive_failures'] = 0
_METRICS['phpsessid_present'] = 1
def _metric_check_fail(duration: float, phpsessid_missing: bool = False):
with _METRICS_LOCK:
_METRICS['last_duration'] = duration
_METRICS['failure_total'] += 1
_METRICS['consecutive_failures'] += 1
_METRICS['phpsessid_present'] = 0 if phpsessid_missing else 1
def _metrics_render() -> bytes:
with _METRICS_LOCK:
m = dict(_METRICS)
lines = [
'# HELP webinar_check_last_run_timestamp_seconds Unix timestamp of last webinar check start.',
'# TYPE webinar_check_last_run_timestamp_seconds gauge',
f'webinar_check_last_run_timestamp_seconds {m["last_run"]}',
'# HELP webinar_check_last_success_timestamp_seconds Unix timestamp of last successful webinar check.',
'# TYPE webinar_check_last_success_timestamp_seconds gauge',
f'webinar_check_last_success_timestamp_seconds {m["last_success"]}',
'# HELP webinar_check_last_duration_seconds Duration of last webinar check in seconds.',
'# TYPE webinar_check_last_duration_seconds gauge',
f'webinar_check_last_duration_seconds {m["last_duration"]}',
'# HELP webinar_check_success_total Total successful webinar checks.',
'# TYPE webinar_check_success_total counter',
f'webinar_check_success_total {m["success_total"]}',
'# HELP webinar_check_failure_total Total failed webinar checks (timeout, playwright error, page error).',
'# TYPE webinar_check_failure_total counter',
f'webinar_check_failure_total {m["failure_total"]}',
'# HELP webinar_check_consecutive_failures Consecutive failed webinar checks (reset on success).',
'# TYPE webinar_check_consecutive_failures gauge',
f'webinar_check_consecutive_failures {m["consecutive_failures"]}',
'# HELP edu_phpsessid_present 1 if EDU_PHPSESSID exists in redis, 0 otherwise.',
'# TYPE edu_phpsessid_present gauge',
f'edu_phpsessid_present {m["phpsessid_present"]}',
]
return ('\n'.join(lines) + '\n').encode()
class _MetricsHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/metrics':
body = _metrics_render()
self.send_response(200)
self.send_header('Content-Type', 'text/plain; version=0.0.4')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
elif self.path in ('/healthz', '/health'):
body = b'ok\n'
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *args):
pass # keep bot logs clean
def start_metrics_server(port: int = METRICS_PORT):
server = HTTPServer(('0.0.0.0', port), _MetricsHandler) # noqa: S104 - k8s ServiceMonitor scrapes pod IP
thread = threading.Thread(target=server.serve_forever, name='metrics-server', daemon=True)
thread.start()
logger.info(f'Metrics server listening on :{port}/metrics')
return server
# Redis Keys # Redis Keys
KEY_WHITELIST = 'bot:whitelist' KEY_WHITELIST = 'bot:whitelist'
@@ -700,66 +597,59 @@ async def _collect_event_times(page) -> dict:
async def fetch_diary_data(phpsessid: str) -> dict | None: async def fetch_diary_data(phpsessid: str) -> dict | None:
logger.info('Fetching diary data via Playwright...') logger.info('Fetching diary data via Playwright...')
try: try:
async with asyncio.timeout(60): async with async_playwright() as p:
async with async_playwright() as p: browser = await p.chromium.connect(PLAYWRIGHT_WS)
browser = await asyncio.wait_for(p.chromium.connect(PLAYWRIGHT_WS), timeout=15) try:
context_browser = await browser.new_context(user_agent=USER_AGENT)
await context_browser.add_cookies(
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}]
)
page = await context_browser.new_page()
try: try:
context_browser = await browser.new_context(user_agent=USER_AGENT) await page.goto(DIARY_URL, wait_until='domcontentloaded')
await context_browser.add_cookies( await page.wait_for_selector('table.calendar', timeout=10000)
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}] await page.wait_for_timeout(1500)
)
page = await context_browser.new_page()
try: table_html = await page.evaluate("""
await asyncio.wait_for(page.goto(DIARY_URL, wait_until='domcontentloaded'), timeout=30) () => {
await page.wait_for_selector('table.calendar', timeout=10000) const t = document.querySelector('table.calendar');
await page.wait_for_timeout(1500) return t ? t.outerHTML : null;
}
table_html = await page.evaluate(""" """)
() => { if not table_html:
const t = document.querySelector('table.calendar'); logger.error('table.calendar not found in DOM')
return t ? t.outerHTML : null;
}
""")
if not table_html:
logger.error('table.calendar not found in DOM')
return None
# Debug: save HTML for troubleshooting
with contextlib.suppress(Exception), open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f: # noqa: S108
f.write(table_html)
month_text, days = _parse_calendar_html(table_html)
# Read event times by opening each event's AJAX popup.
times_by_id = await _collect_event_times(page)
if times_by_id:
for day_data in days.values():
for ev in day_data.get('events', []):
eid = ev.get('id')
if eid and eid in times_by_id:
ev['time'] = times_by_id[eid]
logger.info(
f'Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d["events"])}/{len(days)}'
)
return {'monthFullText': month_text, 'days': days}
except Exception as e:
logger.error(f'Error parsing diary: {e}')
return None return None
finally:
with contextlib.suppress(Exception): # Debug: save HTML for troubleshooting
await asyncio.wait_for(page.close(), timeout=5) with contextlib.suppress(Exception), open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f: # noqa: S108
with contextlib.suppress(Exception): f.write(table_html)
await asyncio.wait_for(context_browser.close(), timeout=5)
month_text, days = _parse_calendar_html(table_html)
# Read event times by opening each event's AJAX popup.
times_by_id = await _collect_event_times(page)
if times_by_id:
for day_data in days.values():
for ev in day_data.get('events', []):
eid = ev.get('id')
if eid and eid in times_by_id:
ev['time'] = times_by_id[eid]
logger.info(
f'Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d["events"])}/{len(days)}'
)
return {'monthFullText': month_text, 'days': days}
except Exception as e:
logger.error(f'Error parsing diary: {e}')
return None
finally: finally:
with contextlib.suppress(Exception): await page.close()
await asyncio.wait_for(browser.close(), timeout=5) await context_browser.close()
except TimeoutError: finally:
logger.error('Diary fetch timed out (60s)') await browser.close()
return None
except Exception as e: except Exception as e:
logger.error(f'Playwright error in diary fetch: {e}') logger.error(f'Playwright error in diary fetch: {e}')
return None return None
@@ -1041,55 +931,48 @@ def _parse_schedule_html(table_html: str) -> dict:
async def fetch_schedule_data(phpsessid: str) -> dict | None: async def fetch_schedule_data(phpsessid: str) -> dict | None:
logger.info('Fetching schedule data via Playwright...') logger.info('Fetching schedule data via Playwright...')
try: try:
async with asyncio.timeout(60): async with async_playwright() as p:
async with async_playwright() as p: browser = await p.chromium.connect(PLAYWRIGHT_WS)
browser = await asyncio.wait_for(p.chromium.connect(PLAYWRIGHT_WS), timeout=15) try:
context_browser = await browser.new_context(user_agent=USER_AGENT)
await context_browser.add_cookies(
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}]
)
page = await context_browser.new_page()
try: try:
context_browser = await browser.new_context(user_agent=USER_AGENT) await page.goto(SCHEDULE_URL, wait_until='domcontentloaded')
await context_browser.add_cookies( await page.wait_for_selector('table.schedule-table', timeout=10000)
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}] await page.wait_for_timeout(1500)
)
page = await context_browser.new_page()
try: table_html = await page.evaluate("""
await asyncio.wait_for(page.goto(SCHEDULE_URL, wait_until='domcontentloaded'), timeout=30) () => {
await page.wait_for_selector('table.schedule-table', timeout=10000) const t = document.querySelector('table.schedule-table');
await page.wait_for_timeout(1500) return t ? t.outerHTML : null;
}
table_html = await page.evaluate(""" """)
() => { if not table_html:
const t = document.querySelector('table.schedule-table'); logger.error('table.schedule-table not found in DOM')
return t ? t.outerHTML : null;
}
""")
if not table_html:
logger.error('table.schedule-table not found in DOM')
return None
debug_path = os.path.join(tempfile.gettempdir(), 'schedule_debug.html')
with contextlib.suppress(Exception), open(debug_path, 'w', encoding='utf-8') as f:
f.write(table_html)
data = _parse_schedule_html(table_html)
logger.info(list(data['weekdays'].keys()))
logger.info(f'Schedule parsed: {len(data["weekdays"])} days, classes={data["classes"]}')
return data
except Exception as e:
logger.error(f'Error parsing schedule: {e}')
return None return None
finally:
with contextlib.suppress(Exception): debug_path = os.path.join(tempfile.gettempdir(), 'schedule_debug.html')
await asyncio.wait_for(page.close(), timeout=5) with contextlib.suppress(Exception), open(debug_path, 'w', encoding='utf-8') as f:
with contextlib.suppress(Exception): f.write(table_html)
await asyncio.wait_for(context_browser.close(), timeout=5)
data = _parse_schedule_html(table_html)
logger.info(list(data['weekdays'].keys()))
logger.info(f'Schedule parsed: {len(data["weekdays"])} days, classes={data["classes"]}')
return data
except Exception as e:
logger.error(f'Error parsing schedule: {e}')
return None
finally: finally:
with contextlib.suppress(Exception): await page.close()
await asyncio.wait_for(browser.close(), timeout=5) await context_browser.close()
except TimeoutError: finally:
logger.error('Schedule fetch timed out (60s)') await browser.close()
return None
except Exception as e: except Exception as e:
logger.error(f'Playwright error in schedule fetch: {e}') logger.error(f'Playwright error in schedule fetch: {e}')
return None return None
@@ -1602,13 +1485,10 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE):
int: Number of webinars found, or None if check failed int: Number of webinars found, or None if check failed
""" """
logger.info('Running webinar check...') logger.info('Running webinar check...')
_t0 = time.time()
_metric_check_start()
phpsessid = redis_client.get(KEY_PHPSESSID) phpsessid = redis_client.get(KEY_PHPSESSID)
if not phpsessid: if not phpsessid:
logger.warning('PHPSESSID missing. Skipping check.') logger.warning('PHPSESSID missing. Skipping check.')
_metric_check_fail(time.time() - _t0, phpsessid_missing=True)
# --- DEBUG LOGGING --- # --- DEBUG LOGGING ---
try: try:
with open('phpsessid_missing.log', 'a') as f: with open('phpsessid_missing.log', 'a') as f:
@@ -1622,88 +1502,78 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE):
content = '' content = ''
try: try:
async with asyncio.timeout(90): async with async_playwright() as p:
async with async_playwright() as p: # Connect to remote Playwright service
# Connect to remote Playwright service browser = await p.chromium.connect(PLAYWRIGHT_WS)
browser = await asyncio.wait_for(p.chromium.connect(PLAYWRIGHT_WS), timeout=15)
try:
# Create browser context with user agent
context_browser = await browser.new_context(user_agent=USER_AGENT)
# Add PHPSESSID cookie
await context_browser.add_cookies(
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}]
)
# Create new page
page = await context_browser.new_page()
try: try:
# Create browser context with user agent # Navigate to webinar page
context_browser = await browser.new_context(user_agent=USER_AGENT) await page.goto(WEBINAR_URL, wait_until='domcontentloaded')
# Add PHPSESSID cookie # Wait for the table to load
await context_browser.add_cookies( await page.wait_for_selector('#meetings table', timeout=10000)
[{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}] await page.wait_for_timeout(2000)
)
# Create new page # Get page content
page = await context_browser.new_page() content = await page.content()
try: # Check if "no webinar" message is present
# Navigate to webinar page if NO_WEBINAR_MARKER not in content:
await asyncio.wait_for(page.goto(WEBINAR_URL, wait_until='domcontentloaded'), timeout=30) logger.info('!!! WEBINAR FOUND !!!')
# Wait for the table to load # Extract webinar details from table rows
await page.wait_for_selector('#meetings table', timeout=10000) rows = page.locator('#meetings table tbody tr')
await page.wait_for_timeout(2000) count = await rows.count()
# Get page content for i in range(count):
content = await page.content() row = rows.nth(i)
text = await row.inner_text()
# Check if "no webinar" message is present if NO_WEBINAR_MARKER not in text:
if NO_WEBINAR_MARKER not in content: # Extract name (topic) from first column
logger.info('!!! WEBINAR FOUND !!!') name_elem = row.locator('td').nth(0)
name = await name_elem.inner_text()
name = name.strip()
# Extract webinar details from table rows # Extract join URL from fourth column
rows = page.locator('#meetings table tbody tr') url_elem = row.locator('td').nth(3).locator('a[href*="/webinar/join/"]').first
count = await rows.count() url = await url_elem.get_attribute('href')
for i in range(count): if name and url:
row = rows.nth(i) current_webinars.append({'name': name, 'url': url, 'text': text.strip()})
text = await row.inner_text() logger.info(f'Found webinar: {name} -> {url}')
else:
logger.info('No webinars found (expected message present)')
if NO_WEBINAR_MARKER not in text: except Exception as e:
# Extract name (topic) from first column logger.error(f'Error checking page: {e}. Saving content for debug.')
name_elem = row.locator('td').nth(0) # If page content is available, save it on error
name = await name_elem.inner_text()
name = name.strip()
# Extract join URL from fourth column
url_elem = row.locator('td').nth(3).locator('a[href*="/webinar/join/"]').first
url = await url_elem.get_attribute('href')
if name and url:
current_webinars.append({'name': name, 'url': url, 'text': text.strip()})
logger.info(f'Found webinar: {name} -> {url}')
else:
logger.info('No webinars found (expected message present)')
except Exception as e:
logger.error(f'Error checking page: {e}. Saving content for debug.')
# If page content is available, save it on error
with contextlib.suppress(Exception):
if page and not content:
content = await page.content()
_metric_check_fail(time.time() - _t0)
return None
finally:
with contextlib.suppress(Exception):
await asyncio.wait_for(page.close(), timeout=5)
with contextlib.suppress(Exception):
await asyncio.wait_for(context_browser.close(), timeout=5)
finally:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await asyncio.wait_for(browser.close(), timeout=5) if page and not content:
content = await page.content()
return None
finally:
await page.close()
await context_browser.close()
finally:
await browser.close()
except TimeoutError:
logger.error('Webinar check timed out after 90s (playwright hang)')
_metric_check_fail(time.time() - _t0)
return None
except Exception as e: except Exception as e:
logger.error(f'Playwright error: {e}') logger.error(f'Playwright error: {e}')
_metric_check_fail(time.time() - _t0)
return None return None
# --- DEBUG LOGGING (Saving last response content) --- # --- DEBUG LOGGING (Saving last response content) ---
@@ -1767,7 +1637,6 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE):
else: else:
logger.info(f'Found {len(current_webinars)} webinar(s), but all are already known') logger.info(f'Found {len(current_webinars)} webinar(s), but all are already known')
_metric_check_ok(time.time() - _t0)
return len(current_webinars) return len(current_webinars)
@@ -1811,8 +1680,6 @@ def main():
job_queue = app.job_queue job_queue = app.job_queue
job_queue.run_repeating(check_webinars_job, interval=WEBINAR_CHECK_INTERVAL, first=10) job_queue.run_repeating(check_webinars_job, interval=WEBINAR_CHECK_INTERVAL, first=10)
start_metrics_server()
logger.info('Bot started polling...') logger.info('Bot started polling...')
app.run_polling() app.run_polling()
-29
View File
@@ -1,29 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gitea-prod-tls
namespace: gitea
spec:
secretName: gitea-prod-tls
dnsNames:
- gcr.forust.xyz
- gitea.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: gitea
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -1
View File
@@ -31,7 +31,7 @@ spec:
spec: spec:
containers: containers:
- name: gitea - name: gitea
image: gitea/gitea:1.27.3 image: docker.gitea.com/gitea:1.27.3
envFrom: envFrom:
- configMapRef: - configMapRef:
name: gitea-config name: gitea-config
+1 -4
View File
@@ -24,7 +24,7 @@ spec:
- name: gitea-service - name: gitea-service
port: 3000 port: 3000
tls: tls:
secretName: gitea-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -45,9 +45,6 @@ spec:
services: services:
- name: gitea-service - name: gitea-service
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP kind: IngressRouteTCP
-29
View File
@@ -1,29 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: glance-prod-tls
namespace: glance
spec:
secretName: glance-prod-tls
dnsNames:
- forust.xyz
- www.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: glance
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -4
View File
@@ -16,7 +16,7 @@ spec:
- name: glance-service - name: glance-service
port: 8080 port: 8080
tls: tls:
secretName: glance-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -35,9 +35,6 @@ spec:
services: services:
- name: glance-service - name: glance-service
port: 8080 port: 8080
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: Middleware kind: Middleware
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
headscale: headscale:
image: headscale/headscale:v0.29.4 image: headscale/headscale:0.29.3
restart: unless-stopped restart: unless-stopped
container_name: headscale-server container_name: headscale-server
command: serve command: serve
-41
View File
@@ -1,41 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: headscale-prod-tls
namespace: headscale
spec:
secretName: headscale-prod-tls
dnsNames:
- hs.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: headplane-prod-tls
namespace: headscale
spec:
secretName: headplane-prod-tls
dnsNames:
- hp.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: headscale
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+2 -7
View File
@@ -38,7 +38,7 @@ spec:
- name: headscale-server-external - name: headscale-server-external
port: 9090 port: 9090
tls: tls:
secretName: headscale-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -64,7 +64,7 @@ spec:
- name: headplane-external - name: headplane-external
port: 3000 port: 3000
tls: tls:
secretName: headplane-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -90,9 +90,6 @@ spec:
services: services:
- name: headscale-server-external - name: headscale-server-external
port: 9090 port: 9090
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -113,5 +110,3 @@ spec:
services: services:
- name: headplane-external - name: headplane-external
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
-42
View File
@@ -1,42 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: forust-homepage-prod-tls
namespace: homepages
spec:
secretName: forust-homepage-prod-tls
dnsNames:
- forust.xyz
- www.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: xdfnx-homepage-prod-tls
namespace: homepages
spec:
secretName: xdfnx-homepage-prod-tls
dnsNames:
- xdfnx.cfd
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: homepages
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+2 -7
View File
@@ -17,7 +17,7 @@ spec:
- name: forust-homepage-service - name: forust-homepage-service
port: 80 port: 80
tls: tls:
secretName: forust-homepage-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -34,9 +34,6 @@ spec:
services: services:
- name: forust-homepage-service - name: forust-homepage-service
port: 80 port: 80
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -56,7 +53,7 @@ spec:
- name: xdfnx-homepage-service - name: xdfnx-homepage-service
port: 80 port: 80
tls: tls:
secretName: xdfnx-homepage-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -72,5 +69,3 @@ spec:
services: services:
- name: xdfnx-homepage-service - name: xdfnx-homepage-service
port: 80 port: 80
tls:
secretName: internal-wildcard-tls
+1 -1
View File
@@ -36,7 +36,7 @@ services:
- proxy - proxy
- kener - kener
redis: redis:
image: redis:8.10.2-alpine image: redis:8.10.1-alpine
container_name: kener-redis container_name: kener-redis
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: kener-service - name: kener-service
port: 3000 port: 3000
tls: tls:
secretName: kener-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: kener-service - name: kener-service
port: 3000 port: 3000
tls:
secretName: internal-wildcard-tls
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
spec: spec:
containers: containers:
- name: redis - name: redis
image: redis:8.10.2-alpine image: redis:8.10.1-alpine
ports: ports:
- containerPort: 6379 - containerPort: 6379
volumeMounts: volumeMounts:
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
metube: metube:
image: ghcr.io/alexta69/metube:2026.09.25 image: ghcr.io/alexta69/metube:2026.09.20
container_name: metube container_name: metube
restart: unless-stopped restart: unless-stopped
# ports: # ports:
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: metube-prod-tls
namespace: metube
spec:
secretName: metube-prod-tls
dnsNames:
- metube.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: metube
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -15,7 +15,7 @@ spec:
- name: metube-service - name: metube-service
port: 8081 port: 8081
tls: tls:
secretName: metube-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -31,5 +31,3 @@ spec:
services: services:
- name: metube-service - name: metube-service
port: 8081 port: 8081
tls:
secretName: internal-wildcard-tls
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
spec: spec:
containers: containers:
- name: metube - name: metube
image: ghcr.io/alexta69/metube:2026.09.25 image: ghcr.io/alexta69/metube:2026.09.20
envFrom: envFrom:
- configMapRef: - configMapRef:
name: metube-config name: metube-config
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
n8n: n8n:
image: docker.n8n.io/n8nio/n8n:2.41.3 image: docker.n8n.io/n8nio/n8n:2.40.3
container_name: n8n container_name: n8n
restart: unless-stopped restart: unless-stopped
environment: environment:
File renamed without changes.
-2
View File
@@ -32,5 +32,3 @@ spec:
services: services:
- name: n8n-service - name: n8n-service
port: 5678 port: 5678
tls:
secretName: internal-wildcard-tls
-15
View File
@@ -1,15 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: n8n
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -1
View File
@@ -27,7 +27,7 @@ spec:
spec: spec:
containers: containers:
- name: n8n - name: n8n
image: docker.n8n.io/n8nio/n8n:2.41.3 image: docker.n8n.io/n8nio/n8n:2.40.3
envFrom: envFrom:
- configMapRef: - configMapRef:
name: n8n-config name: n8n-config
-13
View File
@@ -1,13 +0,0 @@
# 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=
-123
View File
@@ -1,123 +0,0 @@
# 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=<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.
View File
Whitespace-only changes.
-31
View File
@@ -1,31 +0,0 @@
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
-152
View File
@@ -1,152 +0,0 @@
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
-26
View File
@@ -1,26 +0,0 @@
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__"
-28
View File
@@ -1,28 +0,0 @@
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
-160
View File
@@ -1,160 +0,0 @@
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 "$@"
-83
View File
@@ -1,83 +0,0 @@
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
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: netbird
-181
View File
@@ -1,181 +0,0 @@
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
-11
View File
@@ -1,11 +0,0 @@
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"
-32
View File
@@ -1,32 +0,0 @@
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
-96
View File
@@ -1,96 +0,0 @@
# 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 <http://localhost:8000>. 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":"<same value>"}}'
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.
View File
Whitespace-only changes.
-137
View File
@@ -1,137 +0,0 @@
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
-48
View File
@@ -1,48 +0,0 @@
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
-28
View File
@@ -1,28 +0,0 @@
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
-21
View File
@@ -1,21 +0,0 @@
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"
-36
View File
@@ -1,36 +0,0 @@
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
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: netbox
-204
View File
@@ -1,204 +0,0 @@
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
-18
View File
@@ -1,18 +0,0 @@
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"
-56
View File
@@ -1,56 +0,0 @@
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
-82
View File
@@ -1,82 +0,0 @@
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
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
netronome: netronome:
image: ghcr.io/autobrr/netronome:v0.14.1 image: ghcr.io/autobrr/netronome:v0.14.0
restart: unless-stopped restart: unless-stopped
container_name: netronome container_name: netronome
ports: ports:
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: netronome-prod-tls
namespace: netronome
spec:
secretName: netronome-prod-tls
dnsNames:
- nm.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: netronome
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: netronome-service - name: netronome-service
port: 7575 port: 7575
tls: tls:
secretName: netronome-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: netronome-service - name: netronome-service
port: 7575 port: 7575
tls:
secretName: internal-wildcard-tls
+1 -1
View File
@@ -30,7 +30,7 @@ spec:
spec: spec:
containers: containers:
- name: netronome - name: netronome
image: ghcr.io/autobrr/netronome:v0.14.1 image: ghcr.io/autobrr/netronome:v0.14.0
ports: ports:
- name: netronome-port - name: netronome-port
protocol: TCP protocol: TCP
View File
Whitespace-only changes.
View File
Whitespace-only changes.
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: nextcloud-prod-tls
namespace: nextcloud
spec:
secretName: nextcloud-prod-tls
dnsNames:
- nextcloud.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: nextcloud
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+21 -26
View File
@@ -18,7 +18,7 @@ spec:
- name: nextcloud-apache - name: nextcloud-apache
port: 11000 port: 11000
tls: tls:
secretName: nextcloud-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -38,29 +38,26 @@ spec:
services: services:
- name: nextcloud-apache - name: nextcloud-apache
port: 11000 port: 11000
# --- # ---
# Nextcloud AIO # Nextcloud AIO
# apiVersion: traefik.io/v1alpha1 # apiVersion: traefik.io/v1alpha1
# kind: IngressRoute # kind: IngressRoute
# metadata: # metadata:
# name: naio-prod # name: naio-prod
# namespace: nextcloud # namespace: nextcloud
# spec: # spec:
# entryPoints: # entryPoints:
# - websecure # - websecure
# routes: # routes:
# - match: Host(`naio.forust.xyz`) # - match: Host(`naio.forust.xyz`)
# kind: Rule # kind: Rule
# services: # services:
# - name: nextcloud-aio # - name: nextcloud-aio
# port: 8888 # port: 8888
# scheme: https # scheme: https
# serversTransport: insecure-transport # serversTransport: insecure-transport
# tls: # tls:
# certResolver: letsencrypt # certResolver: letsencrypt
tls:
secretName: internal-wildcard-tls
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -78,5 +75,3 @@ spec:
port: 8888 port: 8888
scheme: https scheme: https
serversTransport: insecure-transport serversTransport: insecure-transport
tls:
secretName: internal-wildcard-tls
-28
View File
@@ -1,28 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: portainer-prod-tls
namespace: portainer
spec:
secretName: portainer-prod-tls
dnsNames:
- portainer.forust.xyz
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: internal-wildcard-tls
namespace: portainer
spec:
secretName: internal-wildcard-tls
dnsNames:
- "*.workstation.internal"
- "*.gigaforust.internal"
- workstation.internal
- gigaforust.internal
issuerRef:
name: internal-ca
kind: ClusterIssuer
+1 -3
View File
@@ -16,7 +16,7 @@ spec:
- name: portainer-service - name: portainer-service
port: 9000 port: 9000
tls: tls:
secretName: portainer-prod-tls certResolver: letsencrypt
--- ---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
@@ -32,5 +32,3 @@ spec:
services: services:
- name: portainer-service - name: portainer-service
port: 9000 port: 9000
tls:
secretName: internal-wildcard-tls
-1
View File
@@ -3,4 +3,3 @@ AUTHENTIK_DB_PASSWORD=
GITEA_DB_PASSWORD= GITEA_DB_PASSWORD=
NETRONOME_DB_PASSWORD= NETRONOME_DB_PASSWORD=
PENPOT_DB_PASSWORD= PENPOT_DB_PASSWORD=
STATUSPAGE_DB_PASSWORD=
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more