diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..56a8621 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,104 @@ +name: Validate and deploy Mumble + +on: + push: + pull_request: + +env: + KUBECTL_VERSION: v1.36.3 + KUBERNETES_API: https://host.containers.internal:6443 + KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 + NAMESPACE: default + +jobs: + validate: + name: Validate manifests + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install kubectl + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl.sha256" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" + printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check + chmod 0700 "${RUNNER_TEMP}/kubectl" + + - name: Validate scripts and Kubernetes resources + run: | + sh -n install.sh + bash -n create-ci-kubeconfig.sh + "${RUNNER_TEMP}/kubectl" create \ + --dry-run=client \ + --validate=false \ + --filename mumble.yaml >/dev/null + + deploy: + name: Deploy to Kubernetes + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install kubectl + run: | + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/kubectl.sha256" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" + printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check + chmod 0700 "${RUNNER_TEMP}/kubectl" + + - name: Configure Kubernetes access + env: + KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }} + run: | + test -n "${KUBE_CONFIG_BASE64}" + printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig" + chmod 0600 "${RUNNER_TEMP}/kubeconfig" + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/kubectl" config set-cluster cluster \ + --server="${KUBERNETES_API}" \ + --tls-server-name="${KUBERNETES_TLS_SERVER_NAME}" + + - name: Apply and verify + run: | + export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" + "${RUNNER_TEMP}/kubectl" apply \ + --dry-run=server \ + --validate=false \ + --filename mumble.yaml + "${RUNNER_TEMP}/kubectl" apply \ + --validate=false \ + --filename mumble.yaml + + for attempt in {1..60}; do + IFS='|' read -r generation observed desired updated available unavailable <<< "$( + "${RUNNER_TEMP}/kubectl" --namespace "${NAMESPACE}" \ + get deployment/mumble \ + --output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}' + )" + + echo "Rollout ${attempt}/60: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${available}/${desired} available" + if [[ "${observed}" == "${generation}" \ + && "${updated}" == "${desired}" \ + && "${available}" == "${desired}" \ + && ( -z "${unavailable}" || "${unavailable}" == "0" ) ]]; then + echo "Mumble deployment successfully rolled out" + exit 0 + fi + + sleep 5 + done + + echo "Mumble rollout did not complete within five minutes" >&2 + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b41f070 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Legacy host configuration contains obsolete plaintext credentials. +mumble-server.ini + +# Never commit generated cluster credentials. +kubeconfig +*.kubeconfig +.env diff --git a/README.md b/README.md index 1b7c30e..4ab11e1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,179 @@ -# mumble-kubernetes +# Mumble on Kubernetes -Configuration files to run my mumble server in kubernetes with cert-manager and L2 LoadBalancer (uses my shared postgresql) \ No newline at end of file +Production deployment for the brunner.ninja Mumble server. It runs the +official Mumble server image with trusted TLS, a MetalLB address, and the +shared PostgreSQL instance in the `default` namespace. + +## Architecture + +- `mumblevoip/mumble-server:v1.5.915-1` runs as an unprivileged user. +- MetalLB advertises `192.168.0.14`; TCP and UDP use port `64738` on the same + LoadBalancer Service. +- `brunner-ninja-tls`, maintained by cert-manager, supplies the certificate for + both `brunner.ninja` and `*.brunner.ninja`. +- A small sidecar watches the mounted certificate and asks Mumble to reload TLS + with `SIGUSR1` after renewal. +- Mumble stores channels, ACLs, registered users, bans, and server state in the + `mumble` database on `postgresql.default.svc.cluster.local`. +- `/data` and the generated INI are ephemeral. No Mumble PVC is needed. +- The single replica uses a `Recreate` strategy because two processes must not + serve the same Mumble virtual server database concurrently. + +The runtime INI is rendered into a mode-`0600` in-memory volume. This avoids an +upstream container-entrypoint behavior that can print `registerPassword` when +configuration is supplied through environment variables. + +## Repository contents + +- `mumble.yaml`: LoadBalancer Service and Mumble Deployment. +- `install.sh`: administrator installation, credential generation, idempotent + PostgreSQL bootstrap, and rollout verification. +- `test.sh`: local syntax and client-side Kubernetes validation. +- `ci-deployer.yaml`: restricted Gitea Actions ServiceAccount, Role, binding, + and revocable token request. +- `create-ci-kubeconfig.sh`: one-time CI identity bootstrap. +- `.gitea/workflows/deploy.yml`: validation on every push and pull request; + deployment after successful validation of `main`. + +The old host-level `mumble-server.ini` is intentionally ignored because it +contains obsolete plaintext credentials. Do not commit kubeconfigs, database +passwords, TLS keys, or generated Kubernetes Secret values. + +## Prerequisites + +- Kubernetes and kubectl. +- MetalLB pool `default` containing the reserved address `192.168.0.14`. +- `default/postgresql` Service and Secret. The Secret must contain + `postgres-password`. +- `default/brunner-ninja-tls`, maintained by the existing cert-manager + `letsencrypt-dns` setup. +- Router forwarding of TCP and UDP `64738` to `192.168.0.14`. On the MikroTik, + the two rules use `dst-address-list=WANs`, which also permits LAN hairpin + connections through `brunner.ninja`. + +## Install or restore manually + +Run the first installation with an administrator kubeconfig: + +```sh +./install.sh +``` + +On the first run, the script creates `default/mumble-secrets` with independent +256-bit random database, SuperUser, and public-registry passwords. It then +creates the PostgreSQL role and database if absent. Later runs preserve the +Secret and database, reset the role to the stored password, reapply ownership +and grants, and safely recreate or update the workload. + +Rotate only the public Mumble registry password with: + +```sh +ROTATE_REGISTRATION_PASSWORD=true ./install.sh +``` + +Retrieve the Mumble administrator password without placing it in Git: + +```sh +kubectl get secret mumble-secrets -n default \ + -o jsonpath='{.data.superuser-password}' | base64 -d; echo +``` + +Connect to `brunner.ninja:64738` or `mumble.brunner.ninja:64738`. + +### Restoring a PostgreSQL backup + +Stop Mumble before replacing its database: + +```sh +kubectl delete deployment mumble -n default +``` + +Restore the `mumble` database and ensure its objects are owned by role +`mumble`, then run `./install.sh`. The bootstrap is idempotent: it does not drop +an existing database or tables. Mumble may migrate an older compatible schema +when it starts. Its channels and registrations remain in PostgreSQL. + +## Validate locally + +```sh +./test.sh +``` + +The test checks both shell scripts and parses the Kubernetes resources without +contacting the cluster. + +## Automatic deployment with Gitea Actions + +The workflow validates every push and pull request. A successful push to +`main` applies `mumble.yaml` and polls the Deployment for up to five minutes. +It does not build a container because this repository deliberately uses the +official, version-pinned Mumble image. + +CI does not invoke `install.sh` and cannot create application credentials or +bootstrap PostgreSQL. Its Role can call `get`, `patch`, and `update` only for +the existing `default/mumble` Service and Deployment. It cannot create or +delete workloads and has no direct Kubernetes Secrets API permission. + +### One-time CI setup + +1. Install Mumble manually with `./install.sh` so the Service, Deployment, + application Secret, role, and database exist. +2. With an administrator kubeconfig active, run: + + ```sh + ./create-ci-kubeconfig.sh + ``` + +3. Copy the single base64 output line into the Gitea repository Actions secret + named `KUBE_CONFIG_BASE64`. +4. Protect `main` and require the validation job before merging. +5. Push a harmless README change and confirm the deploy job reports a healthy + rollout. + +The runner networking in the workflow matches the existing brunner.ninja +runner: API URL `https://host.containers.internal:6443`, with TLS server name +`192.168.0.2`. + +To revoke CI access, delete the token and remove the Gitea secret: + +```sh +kubectl delete secret mumble-deployer-token -n default +``` + +Running `./create-ci-kubeconfig.sh` again recreates the token and prints a new +kubeconfig. + +### Security boundary + +The Role prevents ordinary API reads of Secrets and limits updates to two named +objects. Kubernetes RBAC cannot restrict individual fields inside a Deployment +patch, however. A malicious change merged into `main` could modify the Mumble +pod specification and potentially expose Secrets already referenced by that +workload. Branch protection and review of manifest and workflow changes remain +part of the security boundary. Admission policy would be required to constrain +specific pod fields further. + +## Gitea CI or Argo CD? + +Gitea CI is the recommended deployment mechanism for this repository today. +It is already proven on this cluster, the deployment is only two resources, +and it does not automatically undo deliberate maintenance such as deleting the +Deployment while restoring PostgreSQL. + +| Capability | Gitea Actions used here | Argo CD | +| --- | --- | --- | +| Trigger | Push to `main` | Continuous Git reconciliation/webhook | +| Drift detection | No | Built-in diff and health view | +| Self-healing | No; rerun CI or `install.sh` | Optional automatic reconciliation | +| Deleted Deployment | Stays deleted for maintenance | Recreated when self-heal is enabled | +| Cluster credential | Restricted token per repository | Repository credential plus central Argo controller | +| Rollout view | Action log | Application resource tree, health, sync history, and UI | +| Pruning | Not performed | Optional, with confirmation controls available | +| Secret handling | Existing Secrets remain out of Git | Still needs an external Secret strategy | + +Argo CD becomes attractive when several more workloads should share one +consistent deployment model, when live drift must be visible or automatically +repaired, or when multiple clusters/environments are introduced. It can remove +per-repository Kubernetes deploy tokens because CI only needs to update Git. +Adoption should start with an `AppProject`, explicit namespace/resource limits, +and a maintenance procedure that disables sync before database restores. diff --git a/ci-deployer.yaml b/ci-deployer.yaml new file mode 100644 index 0000000..3813020 --- /dev/null +++ b/ci-deployer.yaml @@ -0,0 +1,58 @@ +# Bootstrap this file once with an administrator identity. CI can update only +# the two existing Mumble resources and cannot call the Kubernetes Secrets API. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mumble-deployer + namespace: default +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: mumble-deployer + namespace: default +rules: + - apiGroups: + - "" + resources: + - services + resourceNames: + - mumble + verbs: + - get + - patch + - update + - apiGroups: + - apps + resources: + - deployments + resourceNames: + - mumble + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: mumble-deployer + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: mumble-deployer +subjects: + - kind: ServiceAccount + name: mumble-deployer + namespace: default +--- +apiVersion: v1 +kind: Secret +metadata: + name: mumble-deployer-token + namespace: default + annotations: + kubernetes.io/service-account.name: mumble-deployer +type: kubernetes.io/service-account-token diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh new file mode 100755 index 0000000..e4b41f0 --- /dev/null +++ b/create-ci-kubeconfig.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +namespace=${NAMESPACE:-default} +service_account=${SERVICE_ACCOUNT:-mumble-deployer} +secret=${TOKEN_SECRET:-mumble-deployer-token} + +kubectl apply --filename "${project_dir}/ci-deployer.yaml" >&2 + +for attempt in {1..30}; do + if kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.token}' 2>/dev/null | grep -q .; then + break + fi + if [[ "${attempt}" == 30 ]]; then + echo "Timed out waiting for the service-account token" >&2 + exit 1 + fi + sleep 1 +done + +workdir=$(mktemp --directory) +trap 'rm -rf "${workdir}"' EXIT + +server=$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}') +kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.ca\.crt}' | base64 --decode > "${workdir}/ca.crt" +token=$(kubectl --namespace "${namespace}" get secret "${secret}" \ + --output=jsonpath='{.data.token}' | base64 --decode) + +export KUBECONFIG="${workdir}/config" +kubectl config set-cluster cluster \ + --server="${server}" \ + --certificate-authority="${workdir}/ca.crt" \ + --embed-certs=true >/dev/null +kubectl config set-credentials "${service_account}" --token="${token}" >/dev/null +kubectl config set-context mumble \ + --cluster=cluster \ + --user="${service_account}" \ + --namespace="${namespace}" >/dev/null +kubectl config use-context mumble >/dev/null + +echo "Store the following single line as the Gitea Actions secret KUBE_CONFIG_BASE64." >&2 +echo "Treat it as a password; do not commit it." >&2 +base64 --wrap=0 "${KUBECONFIG}" +printf '\n' diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..df0009e --- /dev/null +++ b/install.sh @@ -0,0 +1,62 @@ +#!/bin/sh + +set -eu + +NAMESPACE=default +SECRET_NAME=mumble-secrets +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +for command in kubectl openssl; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command not found: $command" >&2 + exit 1 + fi +done + +for dependency in secret/postgresql secret/brunner-ninja-tls; do + if ! kubectl get "$dependency" --namespace "$NAMESPACE" >/dev/null 2>&1; then + echo "Required Kubernetes object not found: $NAMESPACE/$dependency" >&2 + exit 1 + fi +done + +if ! kubectl get secret "$SECRET_NAME" --namespace "$NAMESPACE" >/dev/null 2>&1; then + database_password=$(openssl rand -hex 32) + superuser_password=$(openssl rand -hex 32) + registration_password=$(openssl rand -hex 32) + + kubectl create secret generic "$SECRET_NAME" \ + --namespace "$NAMESPACE" \ + --from-literal="database-password=$database_password" \ + --from-literal="superuser-password=$superuser_password" \ + --from-literal="registration-password=$registration_password" + + unset database_password superuser_password registration_password + echo "Created $NAMESPACE/$SECRET_NAME with random database, SuperUser, and public-registry passwords." +else + echo "Keeping existing $NAMESPACE/$SECRET_NAME." + + registration_password_present=$(kubectl get secret "$SECRET_NAME" \ + --namespace "$NAMESPACE" \ + -o go-template='{{if index .data "registration-password"}}yes{{end}}') + + if [ -z "$registration_password_present" ] || [ "${ROTATE_REGISTRATION_PASSWORD:-false}" = true ]; then + registration_password=$(openssl rand -hex 32) + kubectl patch secret "$SECRET_NAME" \ + --namespace "$NAMESPACE" \ + --type=merge \ + --patch "{\"stringData\":{\"registration-password\":\"$registration_password\"}}" >/dev/null + unset registration_password + echo "Set a new random public-registry password in $NAMESPACE/$SECRET_NAME." + fi + + unset registration_password_present +fi + +kubectl apply -f "$SCRIPT_DIR/mumble.yaml" +kubectl rollout status deployment/mumble --namespace "$NAMESPACE" --timeout=5m + +echo +echo "Mumble is available at mumble.brunner.ninja:64738 (MetalLB 192.168.0.14)." +echo "Retrieve the SuperUser password with:" +echo "kubectl get secret $SECRET_NAME -n $NAMESPACE -o jsonpath='{.data.superuser-password}' | base64 -d; echo" diff --git a/mumble.yaml b/mumble.yaml new file mode 100644 index 0000000..ea9d59e --- /dev/null +++ b/mumble.yaml @@ -0,0 +1,295 @@ +apiVersion: v1 +kind: Service +metadata: + name: mumble + namespace: default + labels: + app.kubernetes.io/name: mumble + annotations: + metallb.io/address-pool: default +spec: + type: LoadBalancer + loadBalancerIP: 192.168.0.14 + externalTrafficPolicy: Local + selector: + app.kubernetes.io/name: mumble + ports: + - name: mumble-tcp + port: 64738 + targetPort: mumble-tcp + protocol: TCP + - name: mumble-udp + port: 64738 + targetPort: mumble-udp + protocol: UDP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mumble + namespace: default + labels: + app.kubernetes.io/name: mumble +spec: + replicas: 1 + # Two Mumble processes must not serve the same virtual server/database. + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: mumble + template: + metadata: + labels: + app.kubernetes.io/name: mumble + spec: + # Allows the certificate watcher to send SIGUSR1 to mumble-server. + shareProcessNamespace: true + automountServiceAccountToken: false + terminationGracePeriodSeconds: 30 + securityContext: + fsGroup: 10000 + fsGroupChangePolicy: OnRootMismatch + initContainers: + - name: render-config + image: busybox:1.37.0 + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -ec + - | + umask 077 + cat > /config/mumble-server.ini <- + awk '$2 ~ /:FCE2$/ && $4 == "0A" { found=1 } + END { exit(found ? 0 : 1) }' /proc/net/tcp /proc/net/tcp6 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + exec: + command: + - /bin/bash + - -ec + - >- + awk '$2 ~ /:FCE2$/ && $4 == "0A" { found=1 } + END { exit(found ? 0 : 1) }' /proc/net/tcp /proc/net/tcp6 + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + runAsUser: 10000 + runAsGroup: 10000 + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 500m + memory: 512Mi + - name: certificate-reloader + image: busybox:1.37.0 + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -ec + - | + previous="$(sha256sum /certs/tls.crt /certs/tls.key)" + while sleep 60; do + current="$(sha256sum /certs/tls.crt /certs/tls.key)" + if [ "$current" != "$previous" ]; then + set -- $(pidof mumble-server) + if [ "$#" -gt 0 ]; then + kill -USR1 "$@" + echo "Reloaded Mumble TLS certificate" + fi + previous="$current" + fi + done + volumeMounts: + - name: certificate + mountPath: /certs + readOnly: true + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + runAsUser: 10000 + runAsGroup: 10000 + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: 1m + memory: 2Mi + limits: + cpu: 20m + memory: 16Mi + volumes: + # PostgreSQL stores Mumble's durable state; /data only holds generated config. + - name: data + emptyDir: {} + - name: config + emptyDir: {} + - name: certificate + secret: + secretName: brunner-ninja-tls + defaultMode: 0440 diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..aee7ded --- /dev/null +++ b/test.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) + +command -v kubectl >/dev/null 2>&1 || { + echo "Required command not found: kubectl" >&2 + exit 1 +} + +sh -n "${project_dir}/install.sh" +bash -n "${project_dir}/create-ci-kubeconfig.sh" +kubectl create \ + --dry-run=client \ + --validate=false \ + --filename "${project_dir}/mumble.yaml" >/dev/null + +echo "Mumble scripts and Kubernetes resources are valid."