From e757978a1a12621488cfa2bc9be825fc10935420 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Fri, 30 Oct 2026 21:18:05 +0100 Subject: [PATCH] Package isolated legacy IPMI browser with Podman runtime verification --- .gitea/workflows/release.yaml | 105 ++++++++++++++++++++++++++++ .gitignore | 2 + README.md | 47 +++++++++++++ chart/Chart.yaml | 6 ++ chart/files/supervisord.conf | 28 ++++++++ chart/templates/configmap.yaml | 7 ++ chart/templates/deployment.yaml | 108 +++++++++++++++++++++++++++++ chart/templates/ingress.yaml | 20 ++++++ chart/templates/networkpolicy.yaml | 19 +++++ chart/templates/service.yaml | 13 ++++ chart/values.yaml | 18 +++++ install.sh | 5 ++ scripts/chart-version.sh | 7 ++ test.py | 88 +++++++++++++++++++++++ 14 files changed, 473 insertions(+) create mode 100644 .gitea/workflows/release.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 chart/Chart.yaml create mode 100644 chart/files/supervisord.conf create mode 100644 chart/templates/configmap.yaml create mode 100644 chart/templates/deployment.yaml create mode 100644 chart/templates/ingress.yaml create mode 100644 chart/templates/networkpolicy.yaml create mode 100644 chart/templates/service.yaml create mode 100644 chart/values.yaml create mode 100755 install.sh create mode 100755 scripts/chart-version.sh create mode 100755 test.py diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..f78f529 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,105 @@ +name: Validate, publish and promote Helm chart + +on: + push: + branches: [main] + +env: + GITOPS_REPOSITORY: feedc0de/homelab-gitops + +jobs: + validate: + name: Validate chart + runs-on: linux_amd64 + steps: + - name: Check out source and tags + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate chart and smoke-test restricted browser with Podman + run: ./test.py --runtime + + publish: + name: Publish and promote chart + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: linux_amd64 + steps: + - name: Check out source and tags + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Select immutable chart version + run: | + set -euo pipefail + printf 'CHART_VERSION=%s\n' "$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}" + + - name: Package and publish chart + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "${PACKAGE_USERNAME}" + test -n "${PACKAGE_TOKEN}" + install -d .chart-packages + helm package chart \ + --destination .chart-packages \ + --version "${CHART_VERSION}" + chart_file=".chart-packages/oldipmi-${CHART_VERSION}.tgz" + test -f "${chart_file}" + curl --fail --silent --show-error \ + --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --request POST \ + --upload-file "${chart_file}" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + + - name: Check that this is still current main + id: current-main + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + if [ "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}" ]; then + echo "promote=true" >> "${GITEA_OUTPUT}" + else + echo "This build is no longer current main; skipping promotion" + echo "promote=false" >> "${GITEA_OUTPUT}" + fi + + - name: Check out GitOps repository + if: steps.current-main.outputs.promote == 'true' + uses: actions/checkout@v4 + with: + repository: ${{ env.GITOPS_REPOSITORY }} + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + + - name: Commit chart promotion + if: steps.current-main.outputs.promote == 'true' + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh oldipmi "${CHART_VERSION}" + git config user.name "feedc0de" + git config user.email "feedc0de@users.noreply.code.brunner.ninja" + git add bootstrap/oldipmi.yaml + if git diff --cached --quiet; then + echo "GitOps already selects ${CHART_VERSION}" + exit 0 + fi + git commit -m "Deploy oldipmi chart ${CHART_VERSION}" + for attempt in 1 2 3; do + if git push origin HEAD:main; then + exit 0 + fi + if [ "${attempt}" = 3 ]; then + echo "GitOps promotion failed after three attempts" >&2 + exit 1 + fi + git fetch origin main + git rebase origin/main + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67360e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.chart-packages/ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b290ad2 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Old IPMI browser chart + +Independent adapter chart for [solarkennedy/ipmi-kvm-docker](https://github.com/solarkennedy/ipmi-kvm-docker). +The public package is available from `https://brunner.ninja/charts`. + +This intentionally runs obsolete Chromium and Java for legacy BMC compatibility. +Authentication and container/network isolation reduce exposure; they cannot make +an obsolete browser safe against every exploit. Use a dedicated VM instead if +sharing the Kubernetes host kernel is outside your risk tolerance. + +The browser runs as UID/GID 1000 with a read-only root filesystem, zero Linux +capabilities, no privilege escalation, RuntimeDefault seccomp, no service-account +token, and bounded ephemeral writable mounts. A short init container runs as +UID 0 with the same restrictions solely to copy bundled noVNC assets out of the +upstream image's private `/root` directory. It downloads nothing. X11 TCP is +disabled and VNC listens on localhost; only noVNC HTTP/WebSocket is served. +Chromium retains upstream's `--no-sandbox` compatibility flag. + +One shared desktop is used by every authorized visitor. Closing your tab does +not log out of the BMC. Sign out in the legacy browser after use. Pod replacement +clears the browser profile, downloads, and BMC sessions; there is no authoritative +data or PVC to back up. Do not save BMC passwords in the browser. + +NetworkPolicy denies all traffic by default. Supply `networkPolicy.ingressFrom` +for the authenticated ingress controller and `networkPolicy.egress` or an +additional platform policy for intended destinations. Public DNS is used without +Kubernetes search suffixes. Never expose the Service directly or enable an +Ingress without authentication. The chart does not install an identity provider. + +Validate with `./test.py`; use `./test.py --runtime` for a Podman smoke test of +the rendered container configuration, actual Chromium/X11 processes, HTTP and +WebSocket/RFB handshake. It requires Helm, Python with PyYAML, and Podman. +`./install.sh -f your-values.yaml` is a manual homelab recovery entry point; +normal deployments are owned by Argo CD. + +Upstream publishes only the mutable `latest` tag. `Chart.yaml.appVersion` is +its canonical image tag, consumed by both containers. The exact upstream tag +`latest` is also the base Git tag. Since this is not SemVer, `git describe +--tags --long` maps reversibly to `0.0.0-latest--g`, including +count zero. Chart.yaml.version is a packaging placeholder, never a release +counter. CI tests, derives that immutable chart version, packages and publishes +it, then promotes only `bootstrap/oldipmi.yaml` in homelab-gitops. Images pull +anonymously. CI uses PACKAGE_USERNAME/PACKAGE_TOKEN and GITOPS_TOKEN, with no +Kubernetes credentials, on the established Podman-capable linux_amd64 runner. + +[Homelab configuration](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/oldipmi) +selects the chart, domain, authentication middleware, and Cilium network policy. diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..8d34a57 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: oldipmi +description: Isolated legacy Java IPMI browser using the upstream solarkennedy image +type: application +version: 0.0.0 +appVersion: latest diff --git a/chart/files/supervisord.conf b/chart/files/supervisord.conf new file mode 100644 index 0000000..129dedb --- /dev/null +++ b/chart/files/supervisord.conf @@ -0,0 +1,28 @@ +[supervisord] +nodaemon=true +logfile=/dev/stdout +logfile_maxbytes=0 +pidfile=/tmp/supervisord.pid +childlogdir=/tmp + +[program:X11] +command=/usr/bin/Xvfb :0 -screen 0 %(ENV_RES)s -nolisten tcp +autorestart=true + +[program:x11vnc] +command=/usr/bin/x11vnc -localhost -forever -shared -nopw +autorestart=true + +[program:novnc] +command=/usr/bin/python /opt/novnc/site/utils/websockify --web /opt/novnc/site 8080 localhost:5900 +autorestart=true + +[program:fluxbox] +command=/usr/bin/fluxbox +autorestart=true + +[program:chromium] +command=/usr/bin/chromium-browser --no-sandbox --no-first-run +# The upstream browser requires this legacy compatibility option. Pod security +# and network isolation are the security boundary, not its browser sandbox. +autorestart=true diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml new file mode 100644 index 0000000..225449c --- /dev/null +++ b/chart/templates/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }} +data: + supervisord.conf: | +{{ .Files.Get "files/supervisord.conf" | indent 4 }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..e2792a2 --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,108 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + spec: + automountServiceAccountToken: false + enableServiceLinks: false + dnsPolicy: None + dnsConfig: + nameservers: {{ .Values.dnsServers | toJson }} + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + initContainers: + # Only this offline copy runs as root to read the upstream /root tree. + # The browser and Java never run as root. No downloads or custom image. + - name: prepare-novnc + image: docker.io/solarkennedy/ipmi-kvm-docker:{{ .Chart.AppVersion }} + imagePullPolicy: IfNotPresent + command: [/bin/sh, -ec] + args: + - cp -R /root/novnc /opt/novnc/site; chmod -R a+rX /opt/novnc/site + securityContext: + runAsUser: 0 + runAsNonRoot: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: + - name: novnc + mountPath: /opt/novnc + containers: + - name: browser + image: docker.io/solarkennedy/ipmi-kvm-docker:{{ .Chart.AppVersion }} + imagePullPolicy: IfNotPresent + workingDir: /home/ipmi + command: [/usr/bin/supervisord, -c, /etc/oldipmi/supervisord.conf] + env: + - name: HOME + value: /home/ipmi + - name: RES + value: {{ .Values.resolution | quote }} + - name: PYTHONDONTWRITEBYTECODE + value: '1' + ports: + - name: http + containerPort: 8080 + startupProbe: + httpGet: {path: /vnc.html, port: http} + failureThreshold: 30 + periodSeconds: 5 + readinessProbe: + httpGet: {path: /vnc.html, port: http} + livenessProbe: + httpGet: {path: /vnc.html, port: http} + periodSeconds: 20 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + resources: +{{ toYaml .Values.resources | indent 12 }} + volumeMounts: + - name: config + mountPath: /etc/oldipmi + readOnly: true + - name: novnc + mountPath: /opt/novnc + readOnly: true + - name: home + mountPath: /home/ipmi + - name: tmp + mountPath: /tmp + - name: shm + mountPath: /dev/shm + volumes: + - name: config + configMap: + name: {{ .Release.Name }} + - name: novnc + emptyDir: {sizeLimit: 32Mi} + - name: home + emptyDir: {sizeLimit: 1Gi} + - name: tmp + emptyDir: {sizeLimit: 256Mi} + - name: shm + emptyDir: {medium: Memory, sizeLimit: 128Mi} diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 0000000..cf81fc9 --- /dev/null +++ b/chart/templates/ingress.yaml @@ -0,0 +1,20 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Release.Name }} + annotations: +{{ toYaml .Values.ingress.annotations | indent 4 }} +spec: + ingressClassName: {{ .Values.ingress.className }} + rules: + - host: {{ required "ingress.host is required" .Values.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ .Release.Name }} + port: {name: http} +{{- end }} diff --git a/chart/templates/networkpolicy.yaml b/chart/templates/networkpolicy.yaml new file mode 100644 index 0000000..9d50b15 --- /dev/null +++ b/chart/templates/networkpolicy.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }} + annotations: + argocd.argoproj.io/sync-wave: '-1' +spec: + podSelector: {} + policyTypes: [Ingress, Egress] + ingress: + {{- if .Values.networkPolicy.ingressFrom }} + - from: +{{ toYaml .Values.networkPolicy.ingressFrom | indent 8 }} + ports: + - protocol: TCP + port: 8080 + {{- else }} [] + {{- end }} + egress: {{ .Values.networkPolicy.egress | toJson }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..d26c5ae --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Release.Name }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: {{ .Chart.Name }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + port: 8080 + targetPort: http diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..63b2f15 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,18 @@ +resolution: 1600x1000x24 +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: '2' + memory: 2Gi +ingress: + enabled: false + className: '' + host: '' + annotations: {} +# Public resolvers intentionally omit Kubernetes DNS and search domains. +dnsServers: [1.1.1.1, 9.9.9.9] +networkPolicy: + ingressFrom: [] + egress: [] diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3565c43 --- /dev/null +++ b/install.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")" +[ "$(kubectl config current-context)" = kubernetes-admin@kubernetes ] || { echo 'Unexpected Kubernetes context' >&2; exit 1; } +exec helm upgrade --install oldipmi ./chart --namespace "${NAMESPACE:-oldimpi}" --create-namespace --wait --timeout 5m "$@" diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..4c0e67d --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +# Upstream publishes only "latest". Preserve that exact Git tag and map it +# reversibly to SemVer 0.0.0-latest--g, including the first commit. +description=$(git describe --tags --long --match latest --dirty) +case "$description" in *-dirty) echo 'Refusing dirty package' >&2; exit 1;; esac +printf '0.0.0-%s\n' "$description" diff --git a/test.py b/test.py new file mode 100755 index 0000000..4bb20b3 --- /dev/null +++ b/test.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Check rendered security relationships; optionally smoke-test that exact runtime.""" +import base64, json, os, pathlib, socket, subprocess, sys, tempfile, time, urllib.request +import yaml +ROOT = pathlib.Path(__file__).resolve().parent +os.chdir(ROOT) +def run(*args, **kw): + return subprocess.check_output(args, text=True, **kw).strip() +subprocess.run(['helm', 'lint', 'chart'], check=True) +docs = list(yaml.safe_load_all(run('helm', 'template', 'smoke', 'chart'))) +objects = {x['kind']: x for x in docs} +pod = objects['Deployment']['spec']['template']['spec'] +app = pod['containers'][0] +assert not pod['automountServiceAccountToken'] and not pod['enableServiceLinks'] +assert pod['securityContext']['runAsNonRoot'] +assert pod['securityContext']['seccompProfile']['type'] == 'RuntimeDefault' +assert pod['dnsPolicy'] == 'None' and not pod['dnsConfig'].get('searches') +assert all(not x.get(k) for k in ['hostNetwork', 'hostPID', 'hostIPC'] for x in [pod]) +assert not any('hostPath' in x or 'persistentVolumeClaim' in x for x in pod['volumes']) +for container in pod['initContainers'] + pod['containers']: + s = container['securityContext'] + assert s['readOnlyRootFilesystem'] and not s['allowPrivilegeEscalation'] + assert s['capabilities']['drop'] == ['ALL'] +assert app['image'] == pod['initContainers'][0]['image'] +assert objects['NetworkPolicy']['spec']['ingress'] == [] +assert objects['NetworkPolicy']['spec']['egress'] == [] +assert objects['Service']['spec']['type'] == 'ClusterIP' +assert objects['Service']['spec']['selector'] == objects['Deployment']['spec']['selector']['matchLabels'] +assert set(['startupProbe', 'readinessProbe', 'livenessProbe']) <= app.keys() +assert not any(x['kind'] == 'Ingress' for x in docs) +print('Rendered security and service relationships passed', flush=True) +if '--runtime' not in sys.argv: + sys.exit(0) +with tempfile.TemporaryDirectory(prefix='oldipmi-test-') as tmp: + os.chmod(tmp, 0o755) + dirs = {} + for volume in pod['volumes']: + path = pathlib.Path(tmp) / volume['name'] + path.mkdir(mode=0o777) + path.chmod(0o777) + dirs[volume['name']] = path + if 'configMap' in volume: + for name, data in objects['ConfigMap']['data'].items(): + (path / name).write_text(data) + def container_args(container): + args = ['podman', 'run', '--read-only', '--read-only-tmpfs=false', '--cap-drop=ALL', + '--security-opt=no-new-privileges', '--userns=keep-id', + '--user', str(container['securityContext'].get('runAsUser', pod['securityContext']['runAsUser']))] + for mount in container['volumeMounts']: + args += ['-v', str(dirs[mount['name']]) + ':' + mount['mountPath'] + (':ro' if mount.get('readOnly') else '')] + return args + init = pod['initContainers'][0] + subprocess.run(container_args(init) + ['--rm', '--network=none', init['image']] + init['command'] + init['args'], check=True) + args = container_args(app) + ['-d', '--workdir', app['workingDir'], '-p', '127.0.0.1::' + str(app['ports'][0]['containerPort'])] + for env in app['env']: + args += ['-e', env['name'] + '=' + env['value']] + cid = run(*args, app['image'], *app['command']) + try: + port = int(run('podman', 'port', cid).split(':')[-1]) + for attempt in range(30): + try: + with urllib.request.urlopen(f'http://127.0.0.1:{port}' + app['readinessProbe']['httpGet']['path'], timeout=2) as response: + assert response.status == 200 + break + except Exception: + time.sleep(1) + else: + raise AssertionError('noVNC did not start') + # A real WebSocket upgrade must reach the local X11 VNC server. + with socket.create_connection(('127.0.0.1', port), timeout=10) as ws: + key = base64.b64encode(os.urandom(16)).decode() + ws.sendall((f'GET /websockify HTTP/1.1\r\nHost: localhost:{port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: binary\r\n\r\n').encode()) + received = b'' + while b'RFB ' not in received: + data = ws.recv(4096) + assert data, 'VNC handshake closed early' + received += data + assert b'101 Switching Protocols' in received + status = run('podman', 'exec', cid, 'cat', '/proc/1/status') + assert 'NoNewPrivs:\t1' in status and 'CapEff:\t0000000000000000' in status + assert run('podman', 'exec', cid, 'id', '-u') != '0' + processes = run('podman', 'exec', cid, 'ps', '-eo', 'args') + assert '/usr/lib/chromium-browser/chromium-browser' in processes + assert '/usr/bin/Xvfb' in processes + print('Non-root read-only runtime, browser, HTTP, WebSocket/RFB, and zero capabilities passed') + finally: + subprocess.run(['podman', 'rm', '-f', cid], check=True, stdout=subprocess.DEVNULL) + subprocess.run(container_args(init) + ['--rm', '--network=none', init['image'], '/bin/sh', '-ec', 'rm -rf /opt/novnc/site'], check=True)