From e0f8272e5dfbd615dcc8997a919429a20246fdb3 Mon Sep 17 00:00:00 2001 From: Martijn Pepping <111153+mpepping@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:43:51 +0200 Subject: [PATCH] v5 improvements --- .github/workflows/ci.yml | 57 +++++ .github/workflows/registry-cleanup.yml | 3 + .hadolint.yaml | 5 + AGENTS.md | 59 +++++ Dockerfile | 31 ++- LICENSE | 21 ++ Makefile | 27 +- README.md | 338 ++++++++++++++++++++----- include/etc/bash/motd.sh | 8 + include/etc/motd | 14 + include/etc/profile.d/bin-paths.sh | 12 +- include/etc/profile.d/motd.sh | 13 + include/usr/local/bin/podshell-motd | 10 + k8s/pod.yaml | 31 +++ k8s/sidecar.yaml | 41 +++ test/smoke.sh | 73 ++++++ 16 files changed, 679 insertions(+), 64 deletions(-) create mode 100644 .hadolint.yaml create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 include/etc/bash/motd.sh create mode 100644 include/etc/motd create mode 100644 include/etc/profile.d/motd.sh create mode 100755 include/usr/local/bin/podshell-motd create mode 100644 k8s/pod.yaml create mode 100644 k8s/sidecar.yaml create mode 100755 test/smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 395d168..7a5ad32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,57 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + lint: + name: Lint + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Lint Dockerfile + uses: hadolint/hadolint-action@v3.3.0 + with: + dockerfile: Dockerfile + config: .hadolint.yaml + + - name: Lint shell scripts + run: | + sudo apt-get update && sudo apt-get install -y shellcheck + shellcheck test/smoke.sh include/etc/profile.d/*.sh include/etc/bash/*.sh \ + include/usr/local/bin/_add_binenv include/usr/local/bin/_add_dbin \ + include/usr/local/bin/podshell-motd + + smoke-test: + name: Build and smoke test + needs: lint + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4.1.0 + + - name: Build image for the local platform + uses: docker/build-push-action@v7.2.0 + with: + context: . + platforms: linux/amd64 + load: true + tags: podshell:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run smoke tests + run: ./test/smoke.sh podshell:ci + build-and-push: + name: Build and push + needs: smoke-test runs-on: ubuntu-24.04 permissions: contents: read @@ -41,6 +91,11 @@ jobs: with: cosign-release: "v2.2.3" + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + with: + platforms: arm64,amd64 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4.1.0 with: @@ -79,6 +134,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + provenance: mode=max + sbom: true - name: Verify multi-platform manifest if: github.event_name != 'pull_request' diff --git a/.github/workflows/registry-cleanup.yml b/.github/workflows/registry-cleanup.yml index 7fe8bbc..fd92fd6 100644 --- a/.github/workflows/registry-cleanup.yml +++ b/.github/workflows/registry-cleanup.yml @@ -2,6 +2,9 @@ name: Registry cleanup on: workflow_dispatch: +# NOTE: the build publishes SBOM and provenance attestations, which show up as +# untagged manifests in GHCR. They are referenced by the image index, so only +# run this cleanup deliberately and keep enough versions around. jobs: house-keeping: runs-on: ubuntu-latest diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000..d1242dd --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,5 @@ +--- +ignored: + # Pinning apk package versions is not practical here: the image tracks the + # current Alpine release and is rebuilt regularly. + - DL3018 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e77d879 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# AGENTS.md + +Guidance for AI coding agents (and new contributors) working in this repository. + +## What this project is + +`podshell` is a small Alpine-based container image with debug and development tooling, meant to be +shelled into: as a throwaway pod, an ephemeral `kubectl debug` container, a sidecar, a privileged +daemonset, or plain `docker run`. It runs as the unprivileged user `podshell` (uid/gid 1000) with +passwordless `sudo`, so it works under restrictive admission policies. + +Two runtime package managers keep the image small: [`binenv`](https://github.com/devops-works/binenv) +and [`dbin`](https://github.com/xplshn/dbin). Prefer them over adding large or niche packages to the +image. + +## Layout + +| Path | Purpose | +|---|---| +| `Dockerfile` | Single-stage Alpine build: apk packages, user creation, binenv/dbin bootstrap | +| `include/` | Overlay copied to `/` in the image (profile scripts, motd, sudoers, helper scripts) | +| `include/usr/local/bin/_add_binenv`, `_add_dbin` | Bootstrap scripts run at build time as the `podshell` user | +| `include/etc/profile.d/*.sh` | Sourced by login shells | +| `include/etc/bash/motd.sh` | Sourced by `/etc/bash/bashrc` for interactive shells (`kubectl exec -it -- bash`) | +| `k8s/` | Ready-to-use manifests: pod, sidecar, privileged daemonset and deployment | +| `test/smoke.sh` | Post-build verification of identity, PATH and bundled tooling | +| `.github/workflows/ci.yml` | Smoke test job, then multi-arch build, push and cosign signing | + +## Build and test commands + +```bash +make build # local platform build, tags ghcr.io/mpepping/podshell:latest +make build-all # multi-arch buildx build (amd64 + arm64), no push - mirrors CI +make lint # hadolint on the Dockerfile +make smoke # run test/smoke.sh against the built image +make start # interactive shell in the built image +``` + +There are no unit tests; `test/smoke.sh` is the test suite. Run it after any change to the +`Dockerfile` or `include/`. + +## Conventions + +- Keep the apk package list alphabetically sorted. +- Every shipped tool needs a check in `test/smoke.sh` and a row in the README "Included tooling" + table. +- Shell scripts under `include/` must be POSIX `sh` compatible unless they live in `/etc/bash/`, + and should carry a `# shellcheck shell=...` directive. +- Non-interactive invocations (`kubectl exec pod -- some-command`) must keep stdout clean: the motd + is only printed for interactive shells, guarded by `PODSHELL_MOTD_SHOWN`. +- `PATH` is set both via `ENV` in the Dockerfile (for non-login shells) and via + `include/etc/profile.d/bin-paths.sh` (for `su`/`sudo -i`). Keep both in sync. +- Both `linux/amd64` and `linux/arm64` must build; architecture detection in the bootstrap scripts + maps `x86_64` to `amd64` and `aarch64` to `arm64`. + +## Release flow + +Pushing to `main` publishes `:main` and `:sha-*` tags. Pushing a `*.*.*` tag publishes semver tags +plus `latest`. All pushed images are signed keylessly with cosign via GitHub OIDC. diff --git a/Dockerfile b/Dockerfile index 9c4fd00..e3467bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,22 +12,39 @@ LABEL org.opencontainers.image.source="https://github.com/mpepping/podshell" LABEL org.opencontainers.image.ref.name="ghcr.io/mpepping/podshell" RUN apk add --no-cache \ + apache2-utils \ atop \ bash \ bash-completion \ bat \ bind-tools \ + bridge-utils \ + conntrack-tools \ curl \ + drill \ + ethtool \ + file \ + fping \ + git \ htop \ iftop \ iperf3 \ iproute2 \ + ipset \ + iptables \ + iputils \ + ipvsadm \ jq \ + less \ lsblk \ lsof \ man-db \ man-pages \ mtr \ + ncurses \ + netcat-openbsd \ + nftables \ + ngrep \ nmap \ openssh-client \ openssl \ @@ -39,18 +56,28 @@ RUN apk add --no-cache \ strace \ sudo \ tcpdump \ + tcptraceroute \ tmux \ + traceroute \ + tree \ + util-linux-misc \ vim \ virt-what \ + websocat \ wget -ADD include/ / +COPY include/ / RUN usermod -s /bin/bash root && \ addgroup -g 1000 podshell && \ adduser -D -u 1000 -G podshell -s /bin/bash -g "Podshell User" podshell && \ su - podshell -c "/usr/local/bin/_add_binenv" && \ - su - podshell -c "/usr/local/bin/_add_dbin --install /home/podshell/.local/bin/dbin" + su - podshell -c "/usr/local/bin/_add_dbin --install /home/podshell/.local/bin/dbin" && \ + chmod -R g=u /home/podshell /etc/motd + +# Keep binenv/dbin on PATH for every entrypoint, including non-login shells +# such as `kubectl exec -it -- binenv install `. +ENV PATH="/home/podshell/.local/bin:/home/podshell/.binenv:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" USER 1000 WORKDIR /home/podshell diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a58f915 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Martijn Pepping + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index f275664..4975d66 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ # vim:ft=make: APP_NAME=ghcr.io/mpepping/podshell OS_NAME := $(shell uname -s | tr A-Z a-z) +PLATFORMS ?= linux/amd64,linux/arm64 # Auto-detect container runtime CONTAINER_RUNTIME := $(shell which container 2>/dev/null || which docker 2>/dev/null || which podman 2>/dev/null || echo "") @@ -11,15 +12,32 @@ endif help: ## This help. - @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z0-9_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) .DEFAULT_GOAL := help -.PHONY: help build push pull clean start stop test runtime +.PHONY: help build build-amd64 build-arm64 build-all lint push pull clean start stop test smoke runtime -build: ## Build the image +build: ## Build the image for the local platform $(CONTAINER_RUNTIME) build -t $(APP_NAME):latest . +build-amd64: ## Build the image for linux/amd64 + $(CONTAINER_RUNTIME) build --platform linux/amd64 -t $(APP_NAME):latest . + +build-arm64: ## Build the image for linux/arm64 + $(CONTAINER_RUNTIME) build --platform linux/arm64 -t $(APP_NAME):latest . + +build-all: ## Build multi-platform (see PLATFORMS), without pushing (mirrors CI) + docker buildx build --platform $(PLATFORMS) --output "type=image,push=false" --file ./Dockerfile . + +lint: ## Lint the Dockerfile (hadolint) and shell scripts (shellcheck) + $(CONTAINER_RUNTIME) run --rm -i -v $(PWD)/.hadolint.yaml:/.config/hadolint.yaml \ + ghcr.io/hadolint/hadolint hadolint --config /.config/hadolint.yaml - < Dockerfile + $(CONTAINER_RUNTIME) run --rm -v $(PWD):/mnt -w /mnt koalaman/shellcheck:stable \ + test/smoke.sh include/etc/profile.d/*.sh include/etc/bash/*.sh \ + include/usr/local/bin/_add_binenv include/usr/local/bin/_add_dbin \ + include/usr/local/bin/podshell-motd + push: ## Push the image ifneq ($(findstring container,$(CONTAINER_RUNTIME)),) $(CONTAINER_RUNTIME) image push $(APP_NAME):latest @@ -51,6 +69,9 @@ test: ## Test the container build $(CONTAINER_RUNTIME) run -it --rm $(APP_NAME):latest \ "env | sort && binenv version && dbin info" +smoke: ## Run the smoke test suite against the built image + CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) ./test/smoke.sh $(APP_NAME):latest + runtime: ## Show detected container runtime and OS @echo "Using container runtime: $(CONTAINER_RUNTIME) on $(OS_NAME)" diff --git a/README.md b/README.md index 5615f5e..930f810 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,354 @@ # podshell +[![Container Image](https://github.com/mpepping/podshell/actions/workflows/ci.yml/badge.svg)](https://github.com/mpepping/podshell/actions/workflows/ci.yml) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/mpepping/podshell) _A simple and small container environment for development and debug purposes._ -Podshell is a small set of userland tools you can shell into. The container starts as a regular user (`podshell`, uid `1000`) to play nice with potential admission policies. A set of [useful packages](./Dockerfile) is already installed to provide a functional shell. The package list is not exhaustive, but can be extended at runtime via either [`binenv`](https://github.com/devops-works/binenv) or [`dbin`](https://github.com/xplshn/dbin): +Podshell is a small set of userland tools you can shell into, to debug containers, pods, nodes and +networks. It starts as a regular user (`podshell`, uid `1000`) to play nice with admission policies +like the [restricted Pod Security Standard](https://kubernetes.io/docs/concepts/security/pod-security-standards/), +and `sudo` is available for the tasks that need root. + +```bash +# Throwaway pod in Kubernetes +kubectl run -it --rm --restart=Never --image=ghcr.io/mpepping/podshell:latest shell + +# Debug a running pod, without restarting it +kubectl debug -it --image=ghcr.io/mpepping/podshell:latest + +# Debug a node +kubectl debug node/ -it --image=ghcr.io/mpepping/podshell:latest + +# Share a running container's network namespace +docker run -it --rm --net container: ghcr.io/mpepping/podshell:latest +``` + +## Why podshell -- Run [`binenv`](https://github.com/devops-works/binenv) to install various packages from their original GitHub release repositories, by running `binenv update`, `binenv search` and `binenv install `. -- Run [`dbin`](https://github.com/xplshn/dbin) to install various static binaries from the [Toolpacks](https://github.com/Azathothas/Toolpacks) repository, by running `dbin install`, `dbin search`, `dbin list` and `dbin run`. +Containers are isolated in their own namespaces: their own network stack, processes and mounts. +Production images are (rightfully) minimal and ship no shell tooling. Podshell lets you _enter_ +those namespaces with a full toolbox, without changing or rebuilding the workload you are debugging. -In a podshell, you can use `sudo` to switch to root if needed. That should be sufficient to run debugging or development tasks that may need root. Optionally, you can run the container as root, by setting `securityContext.runAsUser: 0` in a container spec. +- **Debug a container or pod** without baking tools into its image +- **Debug a node** without installing anything on the host +- **Stay small and unprivileged** by default, and escalate only when you need to +- **Extend at runtime** with [`binenv`](https://github.com/devops-works/binenv) and + [`dbin`](https://github.com/xplshn/dbin) instead of shipping every tool under the sun ## Usage -**Imperative** as a Pod in Kubernetes and removed on exit: +### Kubernetes ```bash +# Throwaway pod, removed on exit kubectl run -it --rm --restart=Never --image=ghcr.io/mpepping/podshell:latest shell -``` - -Or start it in the background and exec into it later: -```bash +# Long-running pod you can exec into later kubectl run shell --image=ghcr.io/mpepping/podshell:latest -- sleep 86400 kubectl exec -it shell -- bash + +# Ephemeral container in an existing pod (shares its network namespace) +kubectl debug -it --image=ghcr.io/mpepping/podshell:latest + +# Ephemeral container that also shares the target container's process namespace +kubectl debug -it --image=ghcr.io/mpepping/podshell:latest --target + +# Debug a node; the node filesystem is mounted at /host +kubectl debug node/ -it --image=ghcr.io/mpepping/podshell:latest + +# Throwaway pod on the host network namespace +kubectl run shell -it --rm --restart=Never \ + --image=ghcr.io/mpepping/podshell:latest \ + --overrides='{"spec":{"hostNetwork":true,"dnsPolicy":"ClusterFirstWithHostNet"}}' + +# Imperative deployment one-liner +kubectl create deployment shell --image=ghcr.io/mpepping/podshell:latest -- sleep infinity ``` -**Debugging an existing pod** using an ephemeral container: +Ready-to-use manifests live in [`k8s/`](./k8s): + +| Manifest | Description | +|---|---| +| [`k8s/pod.yaml`](./k8s/pod.yaml) | Plain unprivileged pod, passes the `restricted` Pod Security Standard | +| [`k8s/sidecar.yaml`](./k8s/sidecar.yaml) | Podshell as a sidecar sharing an application pod | +| [`k8s/daemonset.yaml`](./k8s/daemonset.yaml) | Privileged daemonset on every node, host filesystem at `/host` | +| [`k8s/deployment.yaml`](./k8s/deployment.yaml) | Privileged deployment, host filesystem at `/host` | ```bash -kubectl debug -it --image=ghcr.io/mpepping/podshell:latest +kubectl apply -f k8s/pod.yaml +kubectl exec -it podshell -- bash ``` -**Debugging a node** using an ephemeral container: +### Docker and Podman ```bash -kubectl debug node/ -it --image=ghcr.io/mpepping/podshell:latest +# Interactive shell +docker run -it --rm ghcr.io/mpepping/podshell:latest +podman run -it --rm ghcr.io/mpepping/podshell:latest + +# Share another container's network namespace: same interfaces, same localhost +docker run -it --rm --net container: ghcr.io/mpepping/podshell:latest + +# Use the host network namespace +docker run -it --rm --net host ghcr.io/mpepping/podshell:latest + +# Packet capture with the required capabilities +docker run -it --rm --net container:web \ + --cap-add NET_RAW --cap-add NET_ADMIN \ + ghcr.io/mpepping/podshell:latest 'sudo tcpdump -nni any port 80' + +# Enter the Docker Desktop / hypervisor VM +docker run -it --rm --privileged --pid=host ghcr.io/mpepping/podshell:latest \ + 'sudo nsenter -t 1 -m -u -i -n -- bash' ``` -**Declarative** as a Pod in Kubernetes: +### Docker Compose + +Attach podshell to a service's network namespace, for example to capture traffic to a pcap file: ```yaml -kubectl apply -f - </dev/null | openssl x509 -noout -dates + +# Simple HTTP load test +ab -n 1000 -c 20 http://my-service/ + +# WebSocket endpoint +websocat wss://echo.websocket.org ``` -## Building +### Interfaces, routing and MTU -[![Container Image](https://github.com/mpepping/podshell/actions/workflows/ci.yml/badge.svg)](https://github.com/mpepping/podshell/actions/workflows/ci.yml) +```bash +ip -br addr # interfaces and addresses +ip route get 10.0.0.10 # which route would be used +ip -s link # per interface errors and drops +ip neigh # ARP/NDP table +arping -I eth0 10.0.0.1 # layer 2 reachability +sudo ethtool -S eth0 # NIC statistics +brctl show # bridges (docker0, cni0, ...) +tracepath my-service # path MTU discovery +``` -Run `make` or see the [`Makefile`](/Makefile). +### Processes, files and the node + +```bash +htop / atop # interactive process and resource views +ps auxf # process tree (needs shareProcessNamespace or hostPID) +sudo lsof -p # open files and sockets of a process +sudo strace -p -f # syscall trace +lsblk # block devices (privileged pods) +virt-what # which hypervisor are we on +sudo skopeo inspect docker://ghcr.io/mpepping/podshell:latest # registry/image inspection +``` + +## Included tooling + +| Area | Tools | +|---|---| +| Shell and editing | `bash`, `bash-completion`, `vim`, `tmux`, `less`, `bat`, `ripgrep`, `tree`, `file`, `git`, `man` | +| DNS | `bind-tools` (`dig`, `host`, `nslookup`), `drill` | +| Network analysis | `tcpdump`, `ngrep`, `iftop`, `nmap`, `socat`, `netcat-openbsd`, `websocat` | +| Connectivity | `iputils` (`ping`, `arping`, `tracepath`), `fping`, `mtr`, `traceroute`, `tcptraceroute`, `curl`, `wget` | +| Performance | `iperf3`, `apache2-utils` (`ab`), `htop`, `atop`, `procps` | +| Routing and firewalling | `iproute2` (`ip`, `ss`, `tc`), `bridge-utils`, `ethtool`, `iptables`, `nftables`, `ipset`, `ipvsadm`, `conntrack-tools` | +| Security and TLS | `openssl`, `openssh-client` | +| System and containers | `strace`, `lsof`, `lsblk`, `shadow`, `sudo`, `virt-what`, `skopeo`, `jq` | + +The full list lives in the [`Dockerfile`](./Dockerfile). Images are built for `linux/amd64` and +`linux/arm64`, and signed with [cosign](https://github.com/sigstore/cosign): + +```bash +cosign verify ghcr.io/mpepping/podshell:latest \ + --certificate-identity-regexp 'https://github.com/mpepping/podshell/.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +## Extending at runtime + +The package list is deliberately not exhaustive. Anything missing can be installed inside a running +podshell: + +```bash +# binenv: binaries from their upstream GitHub releases +binenv update +binenv search kubectl +binenv install kubectl + +# dbin: static binaries from the Toolpacks repository +dbin search yq +dbin install yq +dbin run --transparent gping +``` + +Both install into `$HOME` and are already on `$PATH`. Use `sudo` for anything that needs root, or +run the container as root with `securityContext.runAsUser: 0`. + +## Building ```shell ❯ make help This help. -build Build the image +build Build the image for the local platform +build-amd64 Build the image for linux/amd64 +build-arm64 Build the image for linux/arm64 +build-all Build multi-platform (see PLATFORMS), without pushing (mirrors CI) +lint Lint the Dockerfile (hadolint) and shell scripts (shellcheck) push Push the image +pull Pull the image clean Remove the image start Start the container stop Stop the container test Test the container build +smoke Run the smoke test suite against the built image +runtime Show detected container runtime and OS ``` -## Feedback +CI builds every pull request, runs [`test/smoke.sh`](./test/smoke.sh) against the resulting image +and, on `main` and tags, pushes signed multi-platform images to `ghcr.io/mpepping/podshell`. + +## Contributing + +Issues and PRs are welcome. When adding a tool, please: + +- Explain why it is not redundant with something already in the image, and prefer `binenv`/`dbin` + for niche or large tooling +- Add the package alphabetically to the `apk add` block in the [`Dockerfile`](./Dockerfile) +- Add it to the **Included tooling** table and, when useful, a **Troubleshooting recipes** snippet +- Add a check to [`test/smoke.sh`](./test/smoke.sh) +- Verify that `make build-all` succeeds for both `linux/amd64` and `linux/arm64` + +## License -Open an [issue or PR](https://github.com/mpepping/podshell/issues). +[MIT](./LICENSE) diff --git a/include/etc/bash/motd.sh b/include/etc/bash/motd.sh new file mode 100644 index 0000000..1008bf0 --- /dev/null +++ b/include/etc/bash/motd.sh @@ -0,0 +1,8 @@ +# shellcheck shell=bash +# Sourced by /etc/bash/bashrc for interactive bash shells, which covers +# `kubectl exec -it -- bash` (an interactive, non-login shell). + +if [ -x /usr/local/bin/podshell-motd ]; then + /usr/local/bin/podshell-motd + export PODSHELL_MOTD_SHOWN=1 +fi diff --git a/include/etc/motd b/include/etc/motd new file mode 100644 index 0000000..3592c57 --- /dev/null +++ b/include/etc/motd @@ -0,0 +1,14 @@ + + _ _ _ _ + _ __ ___ __| |__| |_ ___| | | + | '_ \/ _ \/ _` (_-< ' \/ -_) | | + | .__/\___/\__,_/__/_||_\___|_|_| + |_| Welcome to podshell -- github.com/mpepping/podshell + + A shell for debug and development purposes. You are `podshell` (uid 1000), + use `sudo` when you need root. + + Extend at runtime: binenv update && binenv search && binenv install + dbin search && dbin install + Hide this banner: touch ~/.hushlogin + diff --git a/include/etc/profile.d/bin-paths.sh b/include/etc/profile.d/bin-paths.sh index b6efa06..908ecab 100755 --- a/include/etc/profile.d/bin-paths.sh +++ b/include/etc/profile.d/bin-paths.sh @@ -1,4 +1,12 @@ # shellcheck shell=sh +# Make sure binenv and dbin installed binaries are on PATH, also for shells +# that do not inherit the image PATH (su, sudo -i, ...). Idempotent. -export PATH="/home/podshell/.local/bin:/home/podshell/.binenv:$PATH" - +for _dir in "${HOME:-/home/podshell}/.local/bin" "${HOME:-/home/podshell}/.binenv"; do + case ":${PATH}:" in + *":${_dir}:"*) ;; + *) PATH="${_dir}:${PATH}" ;; + esac +done +unset _dir +export PATH diff --git a/include/etc/profile.d/motd.sh b/include/etc/profile.d/motd.sh new file mode 100644 index 0000000..43959c7 --- /dev/null +++ b/include/etc/profile.d/motd.sh @@ -0,0 +1,13 @@ +# shellcheck shell=sh +# Show the banner on interactive login shells only, so non-interactive usage +# (`kubectl exec pod -- some-command`) keeps a clean stdout. + +case $- in +*i*) + if [ -x /usr/local/bin/podshell-motd ]; then + /usr/local/bin/podshell-motd + PODSHELL_MOTD_SHOWN=1 + export PODSHELL_MOTD_SHOWN + fi + ;; +esac diff --git a/include/usr/local/bin/podshell-motd b/include/usr/local/bin/podshell-motd new file mode 100755 index 0000000..4692eb0 --- /dev/null +++ b/include/usr/local/bin/podshell-motd @@ -0,0 +1,10 @@ +#!/bin/sh +# Print the podshell banner once per shell session. +# Suppressed when ~/.hushlogin exists or PODSHELL_NO_MOTD is set. + +[ -n "${PODSHELL_MOTD_SHOWN:-}" ] && exit 0 +[ -n "${PODSHELL_NO_MOTD:-}" ] && exit 0 +[ -f "${HOME:-/}/.hushlogin" ] && exit 0 +[ -r /etc/motd ] || exit 0 + +cat /etc/motd diff --git a/k8s/pod.yaml b/k8s/pod.yaml new file mode 100644 index 0000000..935604a --- /dev/null +++ b/k8s/pod.yaml @@ -0,0 +1,31 @@ +--- +# A plain, unprivileged podshell pod. Handy when your cluster enforces the +# "restricted" Pod Security Standard. Exec into it with: +# kubectl exec -it podshell -- bash +apiVersion: v1 +kind: Pod +metadata: + name: podshell + labels: + app: podshell +spec: + containers: + - name: podshell + image: ghcr.io/mpepping/podshell:latest + imagePullPolicy: Always + command: ["sleep", "infinity"] + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault + resources: + requests: + memory: "64Mi" + cpu: "10m" + limits: + memory: "512Mi" + terminationGracePeriodSeconds: 0 diff --git a/k8s/sidecar.yaml b/k8s/sidecar.yaml new file mode 100644 index 0000000..7471a48 --- /dev/null +++ b/k8s/sidecar.yaml @@ -0,0 +1,41 @@ +--- +# Run podshell as a sidecar next to an application container. Both containers +# share the same network namespace, so podshell sees exactly the traffic, +# interfaces and localhost ports of the application: +# kubectl exec -it deploy/nginx-podshell -c podshell -- bash +# > curl -sv http://localhost/ +# > sudo tcpdump -nni any port 80 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-podshell + labels: + app: nginx-podshell +spec: + replicas: 1 + selector: + matchLabels: + app: nginx-podshell + template: + metadata: + labels: + app: nginx-podshell + spec: + containers: + - name: nginx + image: nginx:alpine + ports: + - containerPort: 80 + - name: podshell + image: ghcr.io/mpepping/podshell:latest + command: ["sleep", "infinity"] + securityContext: + # NET_RAW+NET_ADMIN are only needed for tcpdump/nmap-style tooling + capabilities: + add: ["NET_RAW", "NET_ADMIN"] + resources: + requests: + memory: "64Mi" + cpu: "10m" + limits: + memory: "512Mi" diff --git a/test/smoke.sh b/test/smoke.sh new file mode 100755 index 0000000..8d4c41a --- /dev/null +++ b/test/smoke.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Smoke test for the podshell image. Verifies that the expected tooling is +# present and usable as the unprivileged `podshell` user. +# +# Usage: test/smoke.sh [image] (default: ghcr.io/mpepping/podshell:latest) +# +# Single quotes around the checks are intentional: those expressions are +# evaluated inside the container, not by this script. +# shellcheck disable=SC2016 + +set -uo pipefail + +IMAGE="${1:-${IMAGE:-ghcr.io/mpepping/podshell:latest}}" +RUNTIME="${CONTAINER_RUNTIME:-docker}" + +FAILED=0 + +run() { + "${RUNTIME}" run --rm --entrypoint /bin/bash "${IMAGE}" -lc "$1" +} + +check() { + local desc="$1" cmd="$2" out + if out="$(run "${cmd}" 2>&1)"; then + printf ' ok %s\n' "${desc}" + else + printf ' FAIL %s\n%s\n' "${desc}" "${out}" + FAILED=$((FAILED + 1)) + fi +} + +echo "==> Testing image: ${IMAGE}" + +echo "--> Runtime identity" +check "runs as uid 1000" '[ "$(id -u)" = "1000" ]' +check "runs as user podshell" '[ "$(id -un)" = "podshell" ]' +check "home is /home/podshell" '[ "$HOME" = "/home/podshell" ]' +check "login shell is bash" '[ -n "$BASH_VERSION" ]' +check "sudo to root works" '[ "$(sudo id -u)" = "0" ]' + +echo "--> Runtime package managers" +check "binenv on PATH" 'command -v binenv >/dev/null && binenv version' +check "dbin on PATH" 'command -v dbin >/dev/null' + +echo "--> Bundled tooling" +TOOLS=( + ab arping atop bat brctl bridge bash conntrack curl dig drill ethtool file + fping git host htop iftop iperf3 ip iptables ipset ipvsadm jq less lsblk lsns + lsof man mtr nc nft ngrep nmap nsenter openssl ping ps rg scp skopeo socat ss + strace sudo tc tcpdump tcptraceroute tmux tput traceroute tree unshare vim + virt-what websocat wget +) +# Checked in a single container run, to keep the suite fast. +missing="$(run "for t in ${TOOLS[*]}; do command -v \$t >/dev/null || echo \$t; done")" +if [ -n "${missing}" ]; then + for tool in ${missing}; do + printf ' FAIL %s not found\n' "${tool}" + FAILED=$((FAILED + 1)) + done +else + printf ' ok %s tools present\n' "${#TOOLS[@]}" +fi + +echo "--> Behaviour" +check "motd shown on interactive login" 'grep -q hushlogin /etc/motd' +check "non-interactive shell is quiet" '[ -z "$(bash -lc "true")" ]' + +echo +if [ "${FAILED}" -gt 0 ]; then + echo "==> ${FAILED} check(s) failed" + exit 1 +fi +echo "==> All checks passed"