Package isolated legacy IPMI browser with Podman runtime verification
Validate, publish and promote Helm chart / Validate chart (push) Successful in 21s
Validate, publish and promote Helm chart / Publish and promote chart (push) Successful in 2s

This commit is contained in:
feedc0de committed 2026-10-30 21:18:05 +01:00
commit e757978a1a
14 files changed
+473

No files matched your search

+105
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
.chart-packages/
__pycache__/
+47
View File
@@ -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-<count>-g<commit>`, 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.
+6
View File
@@ -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
+28
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}
data:
supervisord.conf: |
{{ .Files.Get "files/supervisord.conf" | indent 4 }}
+108
View File
@@ -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}
+20
View File
@@ -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 }}
+19
View File
@@ -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 }}
+13
View File
@@ -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
+18
View File
@@ -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: []
Executable
+5
View File
@@ -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 "$@"
+7
View File
@@ -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-<count>-g<hash>, 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"
Executable
+88
View File
@@ -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)