initial commit
All checks were successful
Build k8s-mgmt-pod image / Build & Push (push) Successful in 2m21s

This commit is contained in:
Michael Trip 2026-06-23 15:06:17 +02:00
commit 4dbcde381f
20 changed files with 1151 additions and 0 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
ADMIN_PASSWORD=change-this-admin-password
USER_PASSWORD=change-this-user-password
ADMIN_SSH_PUBKEY=
USER_SSH_PUBKEY=
HOST_SSH_PORT=2222
HOST_WEBSSH_PORT=3000
ENABLE_BOOTSTRAP_KUBECONFIG=true

156
.github/workflows/build-workflow.yaml vendored Normal file
View file

@ -0,0 +1,156 @@
name: Build k8s-mgmt-pod image
on:
schedule:
- cron: '0 3 * * 6'
push:
branches: ["main"]
paths:
- "Dockerfile"
- ".github/workflows/build-workflow.yaml"
tags: ["v*.*.*"]
pull_request:
branches: ["main"]
workflow_dispatch:
env:
IMAGE_BASE_NAME: k8s-mgmt-pod
jobs:
build:
name: Build & Push
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform & registry
id: platform
run: |
if [[ "${{ github.server_url }}" == "https://github.com" ]]; then
echo "registry=ghcr.io" >> $GITHUB_OUTPUT
echo "name=GitHub" >> $GITHUB_OUTPUT
else
DOMAIN=$(echo "${{ github.server_url }}" | sed 's|https://||')
echo "registry=${DOMAIN}" >> $GITHUB_OUTPUT
echo "name=Forgejo (${DOMAIN})" >> $GITHUB_OUTPUT
fi
- name: Resolve image name
id: image
run: |
REPO=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
echo "name=${{ steps.platform.outputs.registry }}/${REPO}/${{ env.IMAGE_BASE_NAME }}" >> $GITHUB_OUTPUT
- name: Generate build metadata
id: meta
run: |
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
BUILD_DATE_SHORT=$(date -u +'%Y%m%d')
SHORT_SHA=$(git rev-parse --short HEAD)
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
BRANCH_CLEAN=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9._-]/-/g')
echo "build_date=${BUILD_DATE}" >> $GITHUB_OUTPUT
echo "build_date_short=${BUILD_DATE_SHORT}" >> $GITHUB_OUTPUT
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
echo "branch=${BRANCH}" >> $GITHUB_OUTPUT
echo "branch_clean=${BRANCH_CLEAN}" >> $GITHUB_OUTPUT
echo "build_version=${BUILD_DATE_SHORT}-${SHORT_SHA}" >> $GITHUB_OUTPUT
- name: Generate Docker tags
id: tags
run: |
IMAGE="${{ steps.image.outputs.name }}"
BRANCH="${{ steps.meta.outputs.branch }}"
VERSION="${{ steps.meta.outputs.build_version }}"
DATE="${{ steps.meta.outputs.build_date_short }}"
BRANCH_CLEAN="${{ steps.meta.outputs.branch_clean }}"
if [[ "$BRANCH" == "main" ]]; then
TAGS="${IMAGE}:latest
${IMAGE}:${VERSION}
${IMAGE}:${DATE}"
else
TAGS="${IMAGE}:dev-${BRANCH_CLEAN}-latest
${IMAGE}:dev-${BRANCH_CLEAN}-${VERSION}"
fi
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "$TAGS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request' && github.server_url == 'https://github.com'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Forgejo Container Registry
if: github.event_name != 'pull_request' && github.server_url != 'https://github.com'
uses: docker/login-action@v3
with:
registry: ${{ steps.platform.outputs.registry }}
username: ${{ github.actor }}
password: ${{ secrets.FORGEJOTOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.tags.outputs.tags }}
labels: |
org.opencontainers.image.title=k8s-mgmt-pod
org.opencontainers.image.description=Kubernetes management container with lfk and SSH access
org.opencontainers.image.url=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ steps.meta.outputs.build_date }}
org.opencontainers.image.version=${{ steps.meta.outputs.build_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Job summary
if: always()
run: |
STATUS="${{ job.status }}"
if [[ "$STATUS" == "success" ]]; then
ICON="✅"
else
ICON="❌"
fi
echo "# ${ICON} Build ${STATUS^}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| | |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
echo "| **Platform** | ${{ steps.platform.outputs.name }} |" >> $GITHUB_STEP_SUMMARY
echo "| **Registry** | \`${{ steps.platform.outputs.registry }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Image** | \`${{ steps.image.outputs.name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Branch** | \`${{ steps.meta.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ steps.meta.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | \`${{ steps.meta.outputs.build_version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Built at** | \`${{ steps.meta.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ github.event_name }}" != "pull_request" ]]; then
echo "### Published tags" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.tags.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
else
echo "> Built but **not pushed** (pull request)" >> $GITHUB_STEP_SUMMARY
fi

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

107
Dockerfile Normal file
View file

@ -0,0 +1,107 @@
ARG NODE_IMAGE=node:22-bookworm-slim
FROM ${NODE_IMAGE} AS webssh2-builder
ENV DEBIAN_FRONTEND=noninteractive
ARG WEBSSH2_VERSION=5.0.1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /opt/webssh2 \
&& curl -fsSLo "/tmp/webssh2-${WEBSSH2_VERSION}.tar.gz" "https://github.com/billchurch/webssh2/releases/download/webssh2-server-v${WEBSSH2_VERSION}/webssh2-${WEBSSH2_VERSION}.tar.gz" \
&& curl -fsSLo "/tmp/webssh2-${WEBSSH2_VERSION}.tar.gz.sha256" "https://github.com/billchurch/webssh2/releases/download/webssh2-server-v${WEBSSH2_VERSION}/webssh2-${WEBSSH2_VERSION}.tar.gz.sha256" \
&& cd /tmp \
&& sha256sum -c "webssh2-${WEBSSH2_VERSION}.tar.gz.sha256" \
&& tar -xzf "/tmp/webssh2-${WEBSSH2_VERSION}.tar.gz" -C /opt/webssh2 \
&& cd /opt/webssh2 \
&& npm ci --omit=dev \
&& rm -f "/tmp/webssh2-${WEBSSH2_VERSION}.tar.gz" "/tmp/webssh2-${WEBSSH2_VERSION}.tar.gz.sha256" \
&& npm cache clean --force
FROM debian:bookworm-slim
ARG LFK_VERSION=0.14.9
ARG KUBECTL_VERSION=1.31.0
ARG HELM_VERSION=3.16.2
ARG K9S_VERSION=0.40.10
ARG WEBSSH2_VERSION=5.0.1
ENV DEBIAN_FRONTEND=noninteractive \
WEB_SSH_PORT=3000 \
WEBSSH2_LISTEN_PORT=3001 \
WEBSSH2_SSH_HOST=127.0.0.1 \
WEBSSH2_SSH_PORT=22 \
WEBSSH2_HEADER_TEXT=k8s-mgmt-pod \
WEBSSH2_SSH_HOSTKEY_ENABLED=false
COPY --from=webssh2-builder /usr/local /usr/local
COPY --from=webssh2-builder /opt/webssh2 /opt/webssh2
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash-completion \
ca-certificates \
curl \
tar \
nginx-light \
netcat-openbsd \
openssh-client \
openssh-server \
openssl \
sudo \
tini \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /var/run/sshd /var/lib/k8s-mgmt-pod /etc/ssh/sshd_config.d \
&& useradd --create-home --shell /bin/bash admin \
&& useradd --create-home --shell /bin/bash user \
&& printf 'admin ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/90-admin-nopasswd \
&& chmod 0440 /etc/sudoers.d/90-admin-nopasswd \
&& curl -fsSLo "/tmp/lfk_${LFK_VERSION}_linux_amd64.deb" "https://github.com/janosmiko/lfk/releases/download/v${LFK_VERSION}/lfk_${LFK_VERSION}_linux_amd64.deb" \
&& curl -fsSLo /tmp/lfk.checksums "https://github.com/janosmiko/lfk/releases/download/v${LFK_VERSION}/checksums.txt" \
&& cd /tmp \
&& grep " lfk_${LFK_VERSION}_linux_amd64.deb$" /tmp/lfk.checksums | sha256sum -c - \
&& dpkg -i "/tmp/lfk_${LFK_VERSION}_linux_amd64.deb" \
&& rm -f "/tmp/lfk_${LFK_VERSION}_linux_amd64.deb" /tmp/lfk.checksums \
&& curl -fsSLo /usr/local/bin/kubectl "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
&& curl -fsSLo /tmp/kubectl.sha256 "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" \
&& echo "$(cat /tmp/kubectl.sha256) /usr/local/bin/kubectl" | sha256sum -c - \
&& chmod 0755 /usr/local/bin/kubectl \
&& curl -fsSLo "/tmp/helm-v${HELM_VERSION}-linux-amd64.tar.gz" "https://get.helm.sh/helm-v${HELM_VERSION}-linux-amd64.tar.gz" \
&& curl -fsSLo "/tmp/helm-v${HELM_VERSION}-linux-amd64.tar.gz.sha256sum" "https://get.helm.sh/helm-v${HELM_VERSION}-linux-amd64.tar.gz.sha256sum" \
&& echo "$(awk '{print $1}' /tmp/helm-v${HELM_VERSION}-linux-amd64.tar.gz.sha256sum) /tmp/helm-v${HELM_VERSION}-linux-amd64.tar.gz" | sha256sum -c - \
&& tar -xzf "/tmp/helm-v${HELM_VERSION}-linux-amd64.tar.gz" -C /tmp \
&& install -m 0755 /tmp/linux-amd64/helm /usr/local/bin/helm \
&& curl -fsSLo "/tmp/k9s_Linux_amd64.tar.gz" "https://github.com/derailed/k9s/releases/download/v${K9S_VERSION}/k9s_Linux_amd64.tar.gz" \
&& tar -xzf "/tmp/k9s_Linux_amd64.tar.gz" -C /tmp \
&& install -m 0755 /tmp/k9s /usr/local/bin/k9s \
&& mkdir -p /etc/bash_completion.d \
&& kubectl completion bash > /etc/bash_completion.d/kubectl \
&& helm completion bash > /etc/bash_completion.d/helm \
&& chmod 0644 /etc/bash_completion.d/kubectl /etc/bash_completion.d/helm \
&& rm -f /tmp/kubectl.sha256 \
&& rm -rf /tmp/linux-amd64 \
&& rm -f \
"/tmp/helm-v${HELM_VERSION}-linux-amd64.tar.gz" \
"/tmp/helm-v${HELM_VERSION}-linux-amd64.tar.gz.sha256sum" \
/tmp/k9s \
/tmp/README.md \
/tmp/LICENSE \
/tmp/k9s_Linux_amd64.tar.gz \
&& rm -f /etc/ssh/ssh_host_*
COPY docker/sshd/10-lfk-base.conf /etc/ssh/sshd_config.d/10-lfk-base.conf
COPY docker/nginx/webssh.conf /etc/nginx/conf.d/webssh.conf
COPY docker/profile/20-k8s-tools-completion.sh /etc/profile.d/20-k8s-tools-completion.sh
COPY docker/motd /etc/motd
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /usr/local/bin/entrypoint.sh \
&& chmod 0644 /etc/profile.d/20-k8s-tools-completion.sh
EXPOSE 22 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD nc -z 127.0.0.1 22 || exit 1
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]

212
README.md Normal file
View file

@ -0,0 +1,212 @@
# k8s-mgmt-pod
`k8s-mgmt-pod` is a Debian-based Kubernetes bastion image with:
- `lfk` preinstalled from a pinned GitHub release `.deb`
- `kubectl` preinstalled from a pinned upstream binary release
- `helm` preinstalled from a pinned upstream binary release
- `k9s` preinstalled from a pinned GitHub release tarball
- `kubectl` and `helm` bash autocompletion enabled for both `admin` and `user`
- local `openssh-server` on port `22`
- a browser-based SSH client on port `3000`
- a custom ASCII-art MOTD on login
- two provisioned users: `admin` and `user`
## Why WebSSH2
This image uses [WebSSH2](https://github.com/billchurch/webssh2) for browser access instead of a generic web terminal wrapper. WebSSH2 is an actively maintained SSH client that speaks SSH to the local `sshd` in the same container, which matches the bastion use case better than wrapping a shell command directly.
One caveat is worth stating clearly: browser-based SSH tools do not keep the private-key handshake entirely in the browser. If a user chooses key-based login in the WebSSH2 UI, the key material is passed to the server-side SSH client for the actual SSH authentication flow.
## Ports
- `22`: native SSH into the container's local `sshd`
- `3000`: browser UI entrypoint; `/` and `/ssh` are forced to a fixed local WebSSH2 session for `127.0.0.1` and rewritten back to that host if changed in the URL
## Runtime environment variables
The entrypoint configures authentication on every container start. A user can have both SSH public keys and a password at the same time.
| Variable | Meaning |
|---|---|
| `ADMIN_SSH_PUBKEY` | One or more newline-separated SSH public keys for `admin` |
| `USER_SSH_PUBKEY` | One or more newline-separated SSH public keys for `user` |
| `ADMIN_PASSWORD` | Optional password for `admin`; can be used with or without `ADMIN_SSH_PUBKEY` |
| `USER_PASSWORD` | Optional password for `user`; can be used with or without `USER_SSH_PUBKEY` |
| `ENABLE_BOOTSTRAP_KUBECONFIG` | Set to `true` to copy a mounted kubeconfig into both users' homes at startup; defaults to `false` |
| `BOOTSTRAP_KUBECONFIG` | Path to the mounted kubeconfig source file when bootstrap is enabled; defaults to `/bootstrap/kubeconfig/config` |
Behavior per user:
1. If `*_SSH_PUBKEY` is non-empty, the entrypoint writes `authorized_keys`, fixes ownership and permissions, and enables SSH key login for that user.
2. If `*_PASSWORD` is set, the entrypoint also sets that password, even when a public key is present.
3. If no password is supplied but a public key is present, the entrypoint locks password login for that user and leaves key-based login enabled.
4. If neither a public key nor a password is supplied, the entrypoint generates a random password, stores it in the container state directory, applies it, and logs it once to container stdout with a clear label.
If both users end up key-only, `sshd` is configured for public-key-only login. If either user has an explicit or generated password, `sshd` enables password and keyboard-interactive authentication so both native SSH and WebSSH2 can use the same underlying auth methods.
## Build
```bash
docker build --load -t k8s-mgmt-pod:local .
```
If your Docker CLI is backed by Buildx with the `docker-container` driver, a plain `docker build` may finish successfully but leave the image only in the build cache. `--load` imports the result into your local Docker image store so `docker run k8s-mgmt-pod:local` works afterward.
Pinned build arguments can be overridden when you need to bump releases:
```bash
docker build --load \
--build-arg LFK_VERSION=0.14.9 \
--build-arg KUBECTL_VERSION=1.31.0 \
--build-arg HELM_VERSION=3.16.2 \
--build-arg K9S_VERSION=0.40.10 \
--build-arg WEBSSH2_VERSION=5.0.1 \
-t k8s-mgmt-pod:local .
```
## Run with Docker Compose
The included [docker-compose.yaml](docker-compose.yaml) enables kubeconfig bootstrap for local use. It mounts your host `${HOME}/.kube` directory read-only and copies `${HOME}/.kube/config` into both `/home/admin/.kube/config` and `/home/user/.kube/config` at container startup with the correct ownership and `0600` permissions.
That bootstrap is explicitly opt-in and defaults to off in the container itself, so it does not run in Kubernetes unless you deliberately enable it.
Start it with:
```bash
docker compose up --build
```
Or detached:
```bash
docker compose up -d --build
docker compose logs -f
```
If `2222` or `3000` is already in use on your host, override the published ports:
```bash
HOST_SSH_PORT=32222 HOST_WEBSSH_PORT=33000 docker compose up -d --build
```
You can override the default passwords or inject SSH public keys through your shell environment before starting Compose:
```bash
export ADMIN_PASSWORD='change-this-admin-password'
export USER_PASSWORD='change-this-user-password'
export ADMIN_SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)"
export USER_SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)"
docker compose up -d --build
```
You can also start from the example environment file:
```bash
cp .env.example .env
docker compose up -d --build
```
If `${HOME}/.kube/config` is missing on the host, the container logs that bootstrap was skipped and continues normally.
## Run with SSH public keys
```bash
docker run --rm \
-p 2222:22 \
-p 3000:3000 \
-e ADMIN_SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" \
-e USER_SSH_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample user@example" \
k8s-mgmt-pod:local
```
Then connect either with native SSH:
```bash
ssh admin@localhost -p 2222
```
Or in a browser at `http://localhost:3000/`.
## Run with password fallback
```bash
docker run --rm \
-p 2222:22 \
-p 3000:3000 \
-e ADMIN_PASSWORD='change-this-admin-password' \
-e USER_PASSWORD='change-this-user-password' \
k8s-mgmt-pod:local
```
If that fails with a message like `unable to upgrade to tcp, received 500`, the container may still be fine and the error may be coming from the attached run path in your Docker frontend. Try detached mode and inspect the logs instead:
```bash
docker run -d --name k8s-mgmt-pod \
-p 2222:22 \
-p 3000:3000 \
-e ADMIN_PASSWORD='change-this-admin-password' \
-e USER_PASSWORD='change-this-user-password' \
k8s-mgmt-pod:local
docker logs -f k8s-mgmt-pod
```
If Docker reports `bind: address already in use`, change the host-side ports or let Docker choose them automatically:
```bash
docker run --rm \
-p 127.0.0.1::22 \
-p 127.0.0.1::3000 \
-e ADMIN_PASSWORD='change-this-admin-password' \
-e USER_PASSWORD='change-this-user-password' \
k8s-mgmt-pod:local
```
## Run with both SSH key and password enabled
```bash
docker run --rm \
-p 2222:22 \
-p 3000:3000 \
-e ADMIN_SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" \
-e ADMIN_PASSWORD='change-this-admin-password' \
-e USER_SSH_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample user@example" \
-e USER_PASSWORD='change-this-user-password' \
k8s-mgmt-pod:local
```
With that configuration, each user can authenticate with either their SSH key or their password.
## Run with generated password fallback
If you omit both the public-key and password variables for a user, the container generates a random password for that user and prints it once on startup.
```bash
docker run --rm \
-p 2222:22 \
-p 3000:3000 \
-e ADMIN_SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" \
k8s-mgmt-pod:local
```
Retrieve the generated password from logs immediately:
```bash
docker logs <container-name>
```
## CI publishing
The repository includes [.github/workflows/build-workflow.yaml](.github/workflows/build-workflow.yaml), modeled on the requested reference workflow. It:
- runs weekly, on `main`, on matching tags, on pull requests, and manually
- detects GitHub vs Forgejo and picks the correct registry and token
- builds `linux/amd64` only
- pushes only for non-PR runs
- tags `main` as `latest`, date-based, and date-plus-short-sha
- tags non-`main` branches with `dev-<branch>-*`
## Kubernetes deployment
Plain manifests live under [k8s/README.md](k8s/README.md) and the rest of the [k8s](k8s) directory.

20
docker-compose.yaml Normal file
View file

@ -0,0 +1,20 @@
services:
k8s-mgmt-pod:
image: k8s-mgmt-pod:local
build:
context: .
dockerfile: Dockerfile
container_name: k8s-mgmt-pod
ports:
- "${HOST_SSH_PORT:-2222}:22"
- "${HOST_WEBSSH_PORT:-3000}:3000"
environment:
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-this-admin-password}
USER_PASSWORD: ${USER_PASSWORD:-change-this-user-password}
ADMIN_SSH_PUBKEY: ${ADMIN_SSH_PUBKEY:-}
USER_SSH_PUBKEY: ${USER_SSH_PUBKEY:-}
ENABLE_BOOTSTRAP_KUBECONFIG: ${ENABLE_BOOTSTRAP_KUBECONFIG:-true}
BOOTSTRAP_KUBECONFIG: /bootstrap/kubeconfig/config
volumes:
- ${HOME}/.kube:/bootstrap/kubeconfig:ro
restart: unless-stopped

33
docker/motd Normal file
View file

@ -0,0 +1,33 @@
╔════════════════════════════════════════════════════════════════╗
║ ║
║ ██╗ ███████╗██╗ ██╗ ██╗ ██╗ █████╗ ███████╗ ║
║ ██║ ██╔════╝██║ ██╔╝ ██║ ██╔╝██╔══██╗██╔════╝ ║
║ ██║ █████╗ █████╔╝ █████╔╝ ╚█████╔╝███████╗ ║
║ ██║ ██╔══╝ ██╔═██╗ ██╔═██╗ ██╔══██╗╚════██║ ║
║ ███████╗██║ ██║ ██╗ ██║ ██╗╚█████╔╝███████║ ║
║ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚════╝ ╚══════╝ ║
║ Kubernetes Management Pod - Jump Box ║
║ ║
╠════════════════════════════════════════════════════════════════╣
║ Toolbelt ║
║ - kubectl : cluster operations and troubleshooting ║
║ - lfk : LFK kubernetes management ║
║ - k9s : interactive cluster navigation ║
║ - helm : chart install, upgrade, and rollback ║
║ - plus : kubectx, kubens, jq, yq, and more ║
║ ║
╠════════════════════════════════════════════════════════════════╣
║ Quick start ║
║ kubectl get pods -A ║
║ k9s ║
║ helm list -A ║
║ lfk --help ║
║ ║
╠════════════════════════════════════════════════════════════════╣
║ Access ║
║ web ssh : browser session backed by local sshd ║
║ users : admin (passwordless sudo) | user (no sudo) ║
║ ║
╚════════════════════════════════════════════════════════════════╝

40
docker/nginx/webssh.conf Normal file
View file

@ -0,0 +1,40 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 3000;
listen [::]:3000;
server_name _;
location = / {
return 302 $scheme://$http_host/ssh/host/127.0.0.1;
}
location = /ssh {
return 302 $scheme://$http_host/ssh/host/127.0.0.1;
}
location = /ssh/host {
return 302 $scheme://$http_host/ssh/host/127.0.0.1;
}
location ~ ^/ssh/host/(?!127\.0\.0\.1(?:/|$)).+ {
return 302 $scheme://$http_host/ssh/host/127.0.0.1;
}
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
}
}

View file

@ -0,0 +1,19 @@
#!/bin/sh
# Enable kubectl and helm completion for interactive bash sessions.
if [ -n "${BASH_VERSION:-}" ]; then
if [ -r /usr/share/bash-completion/bash_completion ]; then
. /usr/share/bash-completion/bash_completion
fi
if [ -r /etc/bash_completion.d/kubectl ]; then
. /etc/bash_completion.d/kubectl
fi
if [ -r /etc/bash_completion.d/helm ]; then
. /etc/bash_completion.d/helm
fi
fi
alias k=kubectl
complete -F __start_kubectl k

View file

@ -0,0 +1,16 @@
Port 22
Protocol 2
PermitRootLogin no
PubkeyAuthentication yes
HostKey /var/lib/k8s-mgmt-pod/ssh_host_rsa_key
HostKey /var/lib/k8s-mgmt-pod/ssh_host_ecdsa_key
HostKey /var/lib/k8s-mgmt-pod/ssh_host_ed25519_key
AuthorizedKeysFile .ssh/authorized_keys
UsePAM yes
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
PermitEmptyPasswords no
PrintMotd no
ClientAliveInterval 300
ClientAliveCountMax 2

223
entrypoint.sh Normal file
View file

@ -0,0 +1,223 @@
#!/bin/bash
set -Eeuo pipefail
STATE_DIR="/var/lib/k8s-mgmt-pod"
GENERATED_PASSWORDS_FILE="${STATE_DIR}/generated-passwords.env"
SSH_DYNAMIC_CONFIG="/etc/ssh/sshd_config.d/20-k8s-mgmt-pod-auth.conf"
PASSWORD_AUTH_REQUIRED=0
SSHD_PID=""
WEBSSH2_PID=""
NGINX_PID=""
BOOTSTRAP_KUBECONFIG="${BOOTSTRAP_KUBECONFIG:-/bootstrap/kubeconfig/config}"
ENABLE_BOOTSTRAP_KUBECONFIG="${ENABLE_BOOTSTRAP_KUBECONFIG:-false}"
ensure_host_keys() {
if [[ ! -f "${STATE_DIR}/ssh_host_rsa_key" ]]; then
ssh-keygen -q -t rsa -b 3072 -N '' -f "${STATE_DIR}/ssh_host_rsa_key"
fi
if [[ ! -f "${STATE_DIR}/ssh_host_ecdsa_key" ]]; then
ssh-keygen -q -t ecdsa -b 256 -N '' -f "${STATE_DIR}/ssh_host_ecdsa_key"
fi
if [[ ! -f "${STATE_DIR}/ssh_host_ed25519_key" ]]; then
ssh-keygen -q -t ed25519 -N '' -f "${STATE_DIR}/ssh_host_ed25519_key"
fi
chmod 0600 "${STATE_DIR}/ssh_host_"*_key
}
mkdir -p "${STATE_DIR}"
touch "${GENERATED_PASSWORDS_FILE}"
chmod 0600 "${GENERATED_PASSWORDS_FILE}"
log() {
printf '[k8s-mgmt-pod] %s\n' "$*"
}
cleanup() {
local exit_code=$?
if [[ -n "${WEBSSH2_PID}" ]] && kill -0 "${WEBSSH2_PID}" 2>/dev/null; then
kill "${WEBSSH2_PID}" 2>/dev/null || true
fi
if [[ -n "${SSHD_PID}" ]] && kill -0 "${SSHD_PID}" 2>/dev/null; then
kill "${SSHD_PID}" 2>/dev/null || true
fi
if [[ -n "${NGINX_PID}" ]] && kill -0 "${NGINX_PID}" 2>/dev/null; then
kill "${NGINX_PID}" 2>/dev/null || true
fi
wait || true
exit "${exit_code}"
}
trap cleanup EXIT INT TERM
store_generated_password() {
local username="$1"
local password="$2"
if grep -q "^${username}=" "${GENERATED_PASSWORDS_FILE}"; then
sed -i "s|^${username}=.*|${username}=${password}|" "${GENERATED_PASSWORDS_FILE}"
else
printf '%s=%s\n' "${username}" "${password}" >> "${GENERATED_PASSWORDS_FILE}"
fi
}
read_generated_password() {
local username="$1"
grep -E "^${username}=" "${GENERATED_PASSWORDS_FILE}" | tail -n 1 | cut -d '=' -f 2-
}
write_authorized_keys() {
local username="$1"
local pubkey_content="$2"
local home_dir="/home/${username}"
local ssh_dir="${home_dir}/.ssh"
local auth_keys="${ssh_dir}/authorized_keys"
mkdir -p "${ssh_dir}"
chmod 0700 "${ssh_dir}"
printf '%s\n' "${pubkey_content}" > "${auth_keys}"
chmod 0600 "${auth_keys}"
chown -R "${username}:${username}" "${ssh_dir}"
}
install_kubeconfig_for_user() {
local username="$1"
local home_dir="/home/${username}"
local kube_dir="${home_dir}/.kube"
local kube_config="${kube_dir}/config"
mkdir -p "${kube_dir}"
install -m 0600 -o "${username}" -g "${username}" "${BOOTSTRAP_KUBECONFIG}" "${kube_config}"
}
install_bootstrap_kubeconfig() {
if [[ "${ENABLE_BOOTSTRAP_KUBECONFIG}" != "true" ]]; then
return
fi
if [[ ! -f "${BOOTSTRAP_KUBECONFIG}" ]]; then
log "Skipping kubeconfig bootstrap because ${BOOTSTRAP_KUBECONFIG} is not present."
return
fi
install_kubeconfig_for_user admin
install_kubeconfig_for_user user
log "Installed mounted kubeconfig for admin and user."
}
remove_authorized_keys() {
local username="$1"
local auth_keys="/home/${username}/.ssh/authorized_keys"
rm -f "${auth_keys}"
}
set_login_password() {
local username="$1"
local password="$2"
printf '%s:%s\n' "${username}" "${password}" | chpasswd
passwd -u "${username}" >/dev/null 2>&1 || true
}
generate_password() {
openssl rand -base64 24 | tr -d '\n'
}
configure_user_auth() {
local username="$1"
local pubkey_var_name="$2"
local password_var_name="$3"
local pubkey_value="${!pubkey_var_name:-}"
local password_value="${!password_var_name:-}"
local generated_password=""
local has_pubkey=0
if [[ -n "${pubkey_value}" ]]; then
write_authorized_keys "${username}" "${pubkey_value}"
has_pubkey=1
log "Configured SSH public key authentication for ${username}."
else
remove_authorized_keys "${username}"
fi
if [[ -n "${password_value}" ]]; then
PASSWORD_AUTH_REQUIRED=1
set_login_password "${username}" "${password_value}"
log "Configured password authentication for ${username} from environment."
return
fi
if [[ "${has_pubkey}" -eq 1 ]]; then
passwd -l "${username}" >/dev/null 2>&1 || true
return
fi
PASSWORD_AUTH_REQUIRED=1
generated_password="$(read_generated_password "${username}")"
if [[ -z "${generated_password}" ]]; then
generated_password="$(generate_password)"
store_generated_password "${username}" "${generated_password}"
log "Generated random password for ${username}: ${generated_password}"
log "Store this password securely now; it is only printed when first generated."
fi
set_login_password "${username}" "${generated_password}"
}
render_sshd_auth_config() {
local password_value="no"
local keyboard_interactive_value="no"
if [[ "${PASSWORD_AUTH_REQUIRED}" -eq 1 ]]; then
password_value="yes"
keyboard_interactive_value="yes"
export WEBSSH2_AUTH_ALLOWED="password,publickey,keyboard-interactive"
else
export WEBSSH2_AUTH_ALLOWED="publickey"
fi
cat > "${SSH_DYNAMIC_CONFIG}" <<EOF
PasswordAuthentication ${password_value}
KbdInteractiveAuthentication ${keyboard_interactive_value}
EOF
}
prepare_runtime() {
mkdir -p /var/run/sshd
ensure_host_keys
install_bootstrap_kubeconfig
configure_user_auth admin ADMIN_SSH_PUBKEY ADMIN_PASSWORD
configure_user_auth user USER_SSH_PUBKEY USER_PASSWORD
render_sshd_auth_config
/usr/sbin/sshd -t
}
start_services() {
/usr/sbin/sshd -D -e &
SSHD_PID=$!
NODE_ENV=production npm --prefix /opt/webssh2 start &
WEBSSH2_PID=$!
nginx -g 'daemon off;' &
NGINX_PID=$!
log "sshd started on port 22."
log "WebSSH2 started on port ${WEBSSH2_LISTEN_PORT}."
log "nginx redirect/proxy started on port ${WEB_SSH_PORT}."
wait -n "${SSHD_PID}" "${WEBSSH2_PID}" "${NGINX_PID}"
}
# Only public key material is accepted via environment variables; never log secret key data.
prepare_runtime
start_services

82
k8s/README.md Normal file
View file

@ -0,0 +1,82 @@
# Kubernetes deployment
These manifests deploy `k8s-mgmt-pod` as a single-replica bastion deployment inside its own namespace.
## Before you apply
1. Build and publish the image.
2. Replace the placeholder image in `deployment.yaml` with a pinned tag or digest.
3. Create the real `k8s-mgmt-pod-auth` Secret instead of applying `secret.yaml.example`.
## Create the Secret
Public-key path:
```bash
kubectl -n k8s-mgmt-pod create secret generic k8s-mgmt-pod-auth \
--from-literal=ADMIN_SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" \
--from-literal=USER_SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" \
--from-literal=ADMIN_PASSWORD='' \
--from-literal=USER_PASSWORD=''
```
Password path:
```bash
kubectl -n k8s-mgmt-pod create secret generic k8s-mgmt-pod-auth \
--from-literal=ADMIN_SSH_PUBKEY='' \
--from-literal=USER_SSH_PUBKEY='' \
--from-literal=ADMIN_PASSWORD='change-this-admin-password' \
--from-literal=USER_PASSWORD='change-this-user-password'
```
Mixed key-plus-password path:
```bash
kubectl -n k8s-mgmt-pod create secret generic k8s-mgmt-pod-auth \
--from-literal=ADMIN_SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" \
--from-literal=USER_SSH_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample user@example" \
--from-literal=ADMIN_PASSWORD='change-this-admin-password' \
--from-literal=USER_PASSWORD='change-this-user-password'
```
With that configuration, both `admin` and `user` can authenticate with either their SSH key or their password.
Resolution order is per user:
1. If a public key is supplied, key-based login is enabled.
2. If a password is also supplied, password login is enabled too.
3. If only a public key is supplied, password login is locked for that user.
4. If neither is supplied, the container generates and logs a random password for that user.
## Apply
```bash
kubectl apply -k k8s/
```
If you prefer individual files, apply `namespace.yaml`, `serviceaccount.yaml`, `rbac.yaml`, `deployment.yaml`, `service.yaml`, and `networkpolicy.yaml` after creating the real Secret.
## Safe local access
Port-forward the service instead of exposing it externally by default:
```bash
kubectl -n k8s-mgmt-pod port-forward svc/k8s-mgmt-pod 2222:22 3000:3000
```
Then connect with:
```bash
ssh admin@localhost -p 2222
```
Or open `http://localhost:3000/ssh` in a browser.
## RBAC note
The included Role is the most security-sensitive part of this deployment. It is namespace-scoped and avoids `cluster-admin`, but it still grants broad read/write access to common namespaced resources so `lfk` remains usable as a management tool. In particular, access to `secrets`, `pods/exec`, and workload mutation verbs may be too broad for your environment and should be reduced wherever possible.
## Bastion note
This pod is a privileged operational entry point into the cluster. Treat it like a bastion: restrict who can reach it, monitor access, rotate credentials, and avoid using it as a general-purpose workstation.

92
k8s/deployment.yaml Normal file
View file

@ -0,0 +1,92 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: k8s-mgmt-pod
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: k8s-mgmt-pod
template:
metadata:
labels:
app.kubernetes.io/name: k8s-mgmt-pod
spec:
serviceAccountName: k8s-mgmt-pod
automountServiceAccountToken: true
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: k8s-mgmt-pod
# Replace this placeholder with a pinned tag or digest in production.
image: ghcr.io/<org>/<repo>/k8s-mgmt-pod:latest
imagePullPolicy: IfNotPresent
ports:
- name: ssh
containerPort: 22
protocol: TCP
- name: webssh
containerPort: 3000
protocol: TCP
env:
- name: ADMIN_SSH_PUBKEY
valueFrom:
secretKeyRef:
name: k8s-mgmt-pod-auth
key: ADMIN_SSH_PUBKEY
optional: true
- name: USER_SSH_PUBKEY
valueFrom:
secretKeyRef:
name: k8s-mgmt-pod-auth
key: USER_SSH_PUBKEY
optional: true
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: k8s-mgmt-pod-auth
key: ADMIN_PASSWORD
optional: true
- name: USER_PASSWORD
valueFrom:
secretKeyRef:
name: k8s-mgmt-pod-auth
key: USER_PASSWORD
optional: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
tcpSocket:
port: 22
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 22
initialDelaySeconds: 15
periodSeconds: 20
volumeMounts:
- name: state
mountPath: /var/lib/k8s-mgmt-pod
securityContext:
# sshd still runs as root here because it needs to bind port 22 and switch to the authenticated user session.
# The tradeoff is deliberate: keep the container functional, then narrow capabilities instead of forcing a fragile rootless sshd setup.
runAsUser: 0
runAsGroup: 0
runAsNonRoot: false
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "NET_BIND_SERVICE", "SETGID", "SETUID"]
volumes:
- name: state
emptyDir: {}

10
k8s/kustomization.yaml Normal file
View file

@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: k8s-mgmt-pod
resources:
- namespace.yaml
- serviceaccount.yaml
- rbac.yaml
- deployment.yaml
- service.yaml
- networkpolicy.yaml

7
k8s/namespace.yaml Normal file
View file

@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: k8s-mgmt-pod
labels:
app.kubernetes.io/name: k8s-mgmt-pod
k8s-mgmt-pod-access: "false"

24
k8s/networkpolicy.yaml Normal file
View file

@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: k8s-mgmt-pod
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: k8s-mgmt-pod
policyTypes:
- Ingress
ingress:
# Starting point only. Adjust these selectors to the namespaces or pods that should reach the bastion.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: k8s-mgmt-pod
- namespaceSelector:
matchLabels:
k8s-mgmt-pod-access: "true"
ports:
- protocol: TCP
port: 22
- protocol: TCP
port: 3000

49
k8s/rbac.yaml Normal file
View file

@ -0,0 +1,49 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: k8s-mgmt-pod
rules:
# This is intentionally broader than the strict minimum so lfk and kubectl are usable.
# Scope it down to the smallest verb and resource set your operators actually need.
- apiGroups: [""]
resources:
- configmaps
- endpoints
- events
- persistentvolumeclaims
- pods
- pods/exec
- pods/log
- secrets
- serviceaccounts
- services
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources:
- daemonsets
- deployments
- replicasets
- statefulsets
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["batch"]
resources:
- cronjobs
- jobs
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["networking.k8s.io"]
resources:
- ingresses
- networkpolicies
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: k8s-mgmt-pod
subjects:
- kind: ServiceAccount
name: k8s-mgmt-pod
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: k8s-mgmt-pod

15
k8s/secret.yaml.example Normal file
View file

@ -0,0 +1,15 @@
# Example only. Do not commit a real Secret object with live credentials or keys.
# Create the real secret out-of-band with kubectl, Sealed Secrets, External Secrets,
# or your preferred secret manager.
apiVersion: v1
kind: Secret
metadata:
name: k8s-mgmt-pod-auth
type: Opaque
stringData:
ADMIN_SSH_PUBKEY: |
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample admin@example
USER_SSH_PUBKEY: |
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample user@example
ADMIN_PASSWORD: "change-this-admin-password"
USER_PASSWORD: "change-this-user-password"

34
k8s/service.yaml Normal file
View file

@ -0,0 +1,34 @@
apiVersion: v1
kind: Service
metadata:
name: k8s-mgmt-pod
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: k8s-mgmt-pod
ports:
- name: ssh
port: 22
targetPort: ssh
protocol: TCP
- name: webssh
port: 3000
targetPort: webssh
protocol: TCP
# Expose externally only if you explicitly want SSH and web SSH reachable from outside the cluster.
# apiVersion: v1
# kind: Service
# metadata:
# name: k8s-mgmt-pod-external
# spec:
# type: LoadBalancer
# selector:
# app.kubernetes.io/name: k8s-mgmt-pod
# ports:
# - name: ssh
# port: 22
# targetPort: ssh
# - name: webssh
# port: 3000
# targetPort: webssh

4
k8s/serviceaccount.yaml Normal file
View file

@ -0,0 +1,4 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: k8s-mgmt-pod