From 4dbcde381f77c0aa5431abe5d0b4b8c2b5b7b54a Mon Sep 17 00:00:00 2001 From: Michael Trip Date: Tue, 23 Jun 2026 15:06:17 +0200 Subject: [PATCH] initial commit --- .env.example | 7 + .github/workflows/build-workflow.yaml | 156 +++++++++++++++ .gitignore | 1 + Dockerfile | 107 +++++++++++ README.md | 212 ++++++++++++++++++++ docker-compose.yaml | 20 ++ docker/motd | 33 ++++ docker/nginx/webssh.conf | 40 ++++ docker/profile/20-k8s-tools-completion.sh | 19 ++ docker/sshd/10-lfk-base.conf | 16 ++ entrypoint.sh | 223 ++++++++++++++++++++++ k8s/README.md | 82 ++++++++ k8s/deployment.yaml | 92 +++++++++ k8s/kustomization.yaml | 10 + k8s/namespace.yaml | 7 + k8s/networkpolicy.yaml | 24 +++ k8s/rbac.yaml | 49 +++++ k8s/secret.yaml.example | 15 ++ k8s/service.yaml | 34 ++++ k8s/serviceaccount.yaml | 4 + 20 files changed, 1151 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/build-workflow.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yaml create mode 100644 docker/motd create mode 100644 docker/nginx/webssh.conf create mode 100644 docker/profile/20-k8s-tools-completion.sh create mode 100644 docker/sshd/10-lfk-base.conf create mode 100644 entrypoint.sh create mode 100644 k8s/README.md create mode 100644 k8s/deployment.yaml create mode 100644 k8s/kustomization.yaml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/networkpolicy.yaml create mode 100644 k8s/rbac.yaml create mode 100644 k8s/secret.yaml.example create mode 100644 k8s/service.yaml create mode 100644 k8s/serviceaccount.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..13014af --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.github/workflows/build-workflow.yaml b/.github/workflows/build-workflow.yaml new file mode 100644 index 0000000..3e185a8 --- /dev/null +++ b/.github/workflows/build-workflow.yaml @@ -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<> $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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..70f8ad1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..308526d --- /dev/null +++ b/README.md @@ -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 +``` + +## 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--*` + +## Kubernetes deployment + +Plain manifests live under [k8s/README.md](k8s/README.md) and the rest of the [k8s](k8s) directory. diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..4f81d2e --- /dev/null +++ b/docker-compose.yaml @@ -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 \ No newline at end of file diff --git a/docker/motd b/docker/motd new file mode 100644 index 0000000..134af58 --- /dev/null +++ b/docker/motd @@ -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) ║ + ║ ║ + ╚════════════════════════════════════════════════════════════════╝ + diff --git a/docker/nginx/webssh.conf b/docker/nginx/webssh.conf new file mode 100644 index 0000000..0659012 --- /dev/null +++ b/docker/nginx/webssh.conf @@ -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; + } +} \ No newline at end of file diff --git a/docker/profile/20-k8s-tools-completion.sh b/docker/profile/20-k8s-tools-completion.sh new file mode 100644 index 0000000..b9092e6 --- /dev/null +++ b/docker/profile/20-k8s-tools-completion.sh @@ -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 \ No newline at end of file diff --git a/docker/sshd/10-lfk-base.conf b/docker/sshd/10-lfk-base.conf new file mode 100644 index 0000000..5b59dd1 --- /dev/null +++ b/docker/sshd/10-lfk-base.conf @@ -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 \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..22436d7 --- /dev/null +++ b/entrypoint.sh @@ -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}" <//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: {} diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..3687f1b --- /dev/null +++ b/k8s/kustomization.yaml @@ -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 diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..e954cc7 --- /dev/null +++ b/k8s/namespace.yaml @@ -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" diff --git a/k8s/networkpolicy.yaml b/k8s/networkpolicy.yaml new file mode 100644 index 0000000..78d2c24 --- /dev/null +++ b/k8s/networkpolicy.yaml @@ -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 diff --git a/k8s/rbac.yaml b/k8s/rbac.yaml new file mode 100644 index 0000000..ab13028 --- /dev/null +++ b/k8s/rbac.yaml @@ -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 diff --git a/k8s/secret.yaml.example b/k8s/secret.yaml.example new file mode 100644 index 0000000..e600796 --- /dev/null +++ b/k8s/secret.yaml.example @@ -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" diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..e99c602 --- /dev/null +++ b/k8s/service.yaml @@ -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 diff --git a/k8s/serviceaccount.yaml b/k8s/serviceaccount.yaml new file mode 100644 index 0000000..4537823 --- /dev/null +++ b/k8s/serviceaccount.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: k8s-mgmt-pod