Package isolated legacy IPMI browser with Podman runtime verification
This commit is contained in:
commit
e757978a1a
14 files changed
+473
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
.chart-packages/
|
||||
__pycache__/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
data:
|
||||
supervisord.conf: |
|
||||
{{ .Files.Get "files/supervisord.conf" | indent 4 }}
|
||||
@@ -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}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
@@ -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 "$@"
|
||||
Executable
+7
@@ -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"
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user