diff --git a/examples/osm-taxiways/Dockerfile b/examples/osm-taxiways/Dockerfile new file mode 100644 index 0000000..cd39812 --- /dev/null +++ b/examples/osm-taxiways/Dockerfile @@ -0,0 +1,49 @@ +# =========================================================================== +# OSM taxiway bastion — self-contained OSM export box (broker mode) +# =========================================================================== +# A whitelisted SSH box for an OSM taxiway export pipeline. A client may run ONLY +# the three taxiway commands (allowed-commands.list), validated whole-line and +# exec'd shell-free by bastion-broker — the same broker the base docker-bastion +# image uses. +# +# WHY UBUNTU 22.04 (not the Alpine base image): the export needs `osmium` +# (osmium-tool), which Alpine does not package (only header-only libosmium). And +# GDAL matters: newer ogr2ogr (Debian trixie's GDAL 3.10) SEGFAULTS on the +# OSM→GeoJSON step, while Ubuntu 22.04's GDAL 3.4.1 + osmium 1.14 is exactly the +# known-good toolchain that produced the existing exports. So this flavor is a +# small standalone image that vendors the broker + a broker-only sshd entrypoint. +# +# taxiways-list → ls the "-taxiways.geojson" exports present +# taxiways-export → run export-icao-geojson.sh (osmium/ogr2ogr/jq) +# taxiways-cat → base64 the export so it tunnels over ssh cleanly +# =========================================================================== +FROM ubuntu:22.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssh-server \ + osmium-tool \ + gdal-bin \ + jq \ + bash \ + coreutils \ + tini \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /run/sshd /etc/bastion \ + && useradd -m -u 1000 -s /bin/sh agent \ + # Unlock the account (useradd leaves the password '!'-locked). Debian's + # glibc openssh with UsePAM no refuses a locked account even for pubkey; + # '*' = valid-but-passwordless, so pubkey login works (password auth is + # off anyway). Alpine's musl build doesn't need this, hence the base image. + && usermod -p '*' agent + +# The broker (allowlist gate, verbatim from docker-bastion/scripts) + the three +# wrappers it may exec + the broker-only entrypoint. +COPY bastion-broker /usr/local/bin/bastion-broker +COPY bin/taxiways-list bin/taxiways-export bin/taxiways-cat /usr/local/bin/ +COPY entrypoint /usr/local/bin/entrypoint +RUN chmod 0755 /usr/local/bin/bastion-broker /usr/local/bin/taxiways-list \ + /usr/local/bin/taxiways-export /usr/local/bin/taxiways-cat /usr/local/bin/entrypoint + +EXPOSE 22 +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint"] diff --git a/examples/osm-taxiways/README.md b/examples/osm-taxiways/README.md new file mode 100644 index 0000000..8b1abd1 --- /dev/null +++ b/examples/osm-taxiways/README.md @@ -0,0 +1,78 @@ +# OSM taxiway bastion (broker mode) + +A **self-contained, whitelisted** SSH box for an OSM taxiway export pipeline. It lets a +backend fetch/generate airport ground-layout GeoJSON without handing it a shell or root: +the bastion only ever runs **three** commands, validated whole-line against a regex +allowlist and exec'd **shell-free** (no `sh -c`, so nothing an ICAO argument contains can +become a shell operator or a path). + +``` +ssh agent@ + └─ bastion-broker (key auth; matches request vs allowed-commands.list) + ├─ taxiways-list → ls *-taxiways.geojson in /osm-maps + ├─ taxiways-export → export-icao-geojson.sh (osmium/ogr2ogr/jq) + └─ taxiways-cat → base64 -taxiways.geojson (ssh-safe tunnel) +``` + +The OSM toolchain (`osmium`, `ogr2ogr`, `jq`) is baked into the image (see `Dockerfile`), +so the export runs **inside** this container against the mounted `osm-maps` directory +(which holds a region PBF + `export-icao-geojson.sh`). No docker socket, no second +container — the only host data reachable is that one directory. + +## Files + +| file | purpose | +|------|---------| +| `Dockerfile` | Debian base (Alpine has no `osmium-tool`) + `osmium-tool`/`gdal-bin`/`jq` + openssh + the wrappers | +| `bastion-broker` | the allowlist gate, verbatim from `docker-bastion/scripts` (whole-line ERE match → shell-free exec) | +| `entrypoint` | broker-only sshd: key-only, single non-root user, every session forced through the broker | +| `bin/taxiways-{list,export,cat}` | the only executables the allowlist lets a client run | +| `allowed-commands.list` | the regex allowlist (`taxiways-export [A-Z]{4}`, …) — the security boundary | +| `docker-compose.yml` | broker-mode service, osm-maps + allowlist + keys mounts, port 6770 | + +> This flavor is **Debian standalone** (not `FROM blaxsoftware/bastion`) because the export needs +> `osmium`, which Alpine doesn't package. It reuses the base repo's `bastion-broker` unchanged; only +> the sshd bootstrap is a compact broker-only `entrypoint` instead of the Alpine `start-container`. + +## Deploy (on the host holding your osm-maps dir) + +```bash +# 1) Authorize the backend's agent key +mkdir -p docker-data/bastion/users.d +cp /path/to/agent.pub docker-data/bastion/users.d/backend.pub + +# 2) Let the bastion's `agent` user write into osm-maps (export writes files + work/) +AGENT_UID=$(docker run --rm blaxsoftware/bastion:latest id -u agent) +chown -R "$AGENT_UID" /srv/osm-maps # point at your actual osm-maps dir + +# 3) Confirm the osm-maps dir has the script + PBF +ls /srv/osm-maps/export-icao-geojson.sh /srv/osm-maps/*.osm.pbf + +# 4) Build + run +docker compose up -d --build +``` + +To publish the image for reuse: + +```bash +docker build --platform linux/amd64 -t blaxsoftware/bastion-osm-taxiways:latest . +docker push blaxsoftware/bastion-osm-taxiways:latest +``` + +## Test + +```bash +ssh -p 6770 agent@ taxiways-list # → list (maybe empty) +ssh -p 6770 agent@ taxiways-export EDDF # → runs the export (minutes) +ssh -p 6770 agent@ taxiways-cat EDDF | base64 -d | head # → GeoJSON +ssh -p 6770 agent@ 'rm -rf /' # → "command not permitted", exit 126 +ssh -p 6770 agent@ taxiways-export eddf # → refused (lowercase ≠ [A-Z]{4}) +``` + +## Backend wiring + +The backend stores the private key whose `*.pub` you dropped in `users.d/`, and connects +as `agent@:6770`, sending one of the three commands per connection. `taxiways-cat` +returns base64 that the backend decodes into its own storage; `taxiways-export` runs the +(minutes-long) OSM export. A pure-PHP SSH client (e.g. phpseclib) works fine — no `ssh` +binary is required on the backend side. diff --git a/examples/osm-taxiways/allowed-commands.list b/examples/osm-taxiways/allowed-commands.list new file mode 100644 index 0000000..39857c1 --- /dev/null +++ b/examples/osm-taxiways/allowed-commands.list @@ -0,0 +1,16 @@ +# =========================================================================== +# OSM taxiway broker allowlist +# =========================================================================== +# One extended-regex (ERE) rule per line; a request is permitted only if it +# matches WHOLE-LINE (anchored ^…$). Blank lines and # comments are ignored, +# and this file is re-read on every request (bind mount → edit without a +# restart). Matched requests are word-split and exec'd WITHOUT a shell, so the +# [A-Z]{4} class is the containment boundary, not just a filter — no ICAO +# argument can ever carry an operator or a path. +# +# NO COMMAND_PREFIX is used: the three wrappers are the whole surface. +# =========================================================================== + +taxiways-list +taxiways-export [A-Z]{4} +taxiways-cat [A-Z]{4} diff --git a/examples/osm-taxiways/bastion-broker b/examples/osm-taxiways/bastion-broker new file mode 100755 index 0000000..713cd69 --- /dev/null +++ b/examples/osm-taxiways/bastion-broker @@ -0,0 +1,106 @@ +#!/bin/sh +# =========================================================================== +# bastion-broker — command-allowlist gate for docker-bastion "broker mode". +# +# Invoked once per session with the client-requested command: +# • SSH: the force-command wrapper passes "$SSH_ORIGINAL_COMMAND". +# • HTTP: the CGI passes the X-Bastion-Command request header. +# +# The requested command is matched — anchored, WHOLE-LINE — against a set of +# extended-regex (ERE) rules. On a match it is word-split (NO shell, so the +# metacharacters ; | & ` $( ) < > are literal arguments, never operators) +# and exec'd, optionally behind a trusted COMMAND_PREFIX. No match → refused. +# +# --------------------------------------------------------------------------- +# Security model +# --------------------------------------------------------------------------- +# The regex rules are the ENTIRE authorization boundary for what a client may +# run. Two properties keep that boundary tight: +# +# 1. Whole-line anchoring (grep -x). A rule must match the request from +# first character to last; it can never match a substring. +# 2. Shell-free execution. The validated request is split on whitespace and +# exec'd directly — there is no `sh -c`. A sloppy rule therefore cannot +# escalate to command injection; the worst case is that an allowed +# program receives an odd-looking argument. +# +# Still: write rules with restrictive argument classes (e.g. [^[:space:]]+), +# never a bare `.*`. Values that must reach the target program intact cannot +# contain whitespace (word-splitting) — generate passwords/tokens from a +# space-free alphabet (hex, base64url) on the caller side. +# +# Config is read from boot-written FILES, never the environment: sshd does +# not propagate the daemon's env to a ForceCommand session, so anything the +# broker needs at session time must live on disk. +# =========================================================================== +set -u +export HOME=/home/agent +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +ENV_RULES=/etc/bastion/allowed-commands.env # snapshot of $ALLOWED_COMMANDS (written at boot) +LIVE_RULES=/etc/bastion/allowed-commands.list # optional live bind-mount (re-read every session) +PREFIX_FILE=/etc/bastion/command-prefix # optional trusted prefix (written at boot) + +# $1 wins (SSH wrapper / HTTP CGI pass it explicitly); fall back to the env +# var sshd sets so the broker also works as a bare ForceCommand target. +REQ="${1:-${SSH_ORIGINAL_COMMAND:-}}" + +log() { printf '[broker] %s\n' "$1" >&2; } + +refuse() { + # $1 = reason for the audit log (may include the request), + # $2 = sanitized message shown to the client. + log "DENY: $1" + printf 'bastion: %s\n' "$2" >&2 + exit 126 +} + +# --- 1) Empty request ----------------------------------------------------- +[ -n "$REQ" ] || refuse "" "no command supplied (broker mode expects a command)" + +# --- 2) Reject anything spanning more than one line ----------------------- +# grep matches line-by-line; a multi-line request could slip a benign first +# line past the allowlist while carrying a second, malicious line that the +# shell-free exec would still pass along. Refuse outright. +if [ "$(printf '%s' "$REQ" | tr -dc '\n\r' | wc -c)" -ne 0 ]; then + refuse "" "command not permitted" +fi + +# --- 3) Match against the rule set --------------------------------------- +# Sources are concatenated; comments (#…) and blank lines are dropped and +# each rule is trimmed of surrounding whitespace (so indented YAML block +# lines work). grep -Eqx => ERE, whole-line (implicit ^…$ anchoring), quiet. +collect_rules() { + for src in "$ENV_RULES" "$LIVE_RULES"; do + [ -f "$src" ] || continue + awk '{ sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, "") } NF && $0 !~ /^#/' "$src" + done +} + +matched=0 +while IFS= read -r pat; do + [ -n "$pat" ] || continue + if printf '%s' "$REQ" | grep -Eqx -- "$pat"; then + matched=1 + break + fi +done <&2; exit 2 ;; +esac + +cd "$OSM_DIR" 2>/dev/null || { echo "osm-maps dir not mounted at $OSM_DIR" >&2; exit 4; } +f="${icao}-taxiways.geojson" +[ -f "$f" ] || { echo "no export for $icao (run taxiways-export $icao first)" >&2; exit 3; } + +exec base64 "$f" diff --git a/examples/osm-taxiways/bin/taxiways-export b/examples/osm-taxiways/bin/taxiways-export new file mode 100755 index 0000000..391840d --- /dev/null +++ b/examples/osm-taxiways/bin/taxiways-export @@ -0,0 +1,20 @@ +#!/bin/sh +# Run the OSM taxiway export for one ICAO (osmium/ogr2ogr/jq over the Europe PBF). +# Broker rule: `taxiways-export [A-Z]{4}`. Writes -taxiways.geojson into the +# osm-maps dir. This takes MINUTES on a cold cache. +# +# The ICAO is re-validated here (defence in depth — the allowlist already pins +# it to [A-Z]{4}, and the broker exec's us shell-free so $1 can't be an operator). +set -eu +OSM_DIR=/osm-maps + +icao="${1:-}" +case "$icao" in + [A-Z][A-Z][A-Z][A-Z]) ;; + *) echo "invalid ICAO: '$icao'" >&2; exit 2 ;; +esac + +cd "$OSM_DIR" 2>/dev/null || { echo "osm-maps dir not mounted at $OSM_DIR" >&2; exit 4; } +[ -x ./export-icao-geojson.sh ] || { echo "export-icao-geojson.sh missing in $OSM_DIR" >&2; exit 5; } + +exec ./export-icao-geojson.sh "$icao" diff --git a/examples/osm-taxiways/bin/taxiways-list b/examples/osm-taxiways/bin/taxiways-list new file mode 100755 index 0000000..4999e85 --- /dev/null +++ b/examples/osm-taxiways/bin/taxiways-list @@ -0,0 +1,13 @@ +#!/bin/sh +# List the taxiway GeoJSON exports currently present in the osm-maps dir. +# Broker rule: `taxiways-list` (no args). Output: one "-taxiways.geojson" +# per line (empty if none yet). +# +# OSM_DIR is hardcoded to the mount point below: sshd does NOT propagate the +# container's environment into a ForceCommand session, so we can't read it from +# `environment:` — mount the osm-maps dir at exactly this path. +set -eu +OSM_DIR=/osm-maps + +cd "$OSM_DIR" 2>/dev/null || { echo "osm-maps dir not mounted at $OSM_DIR" >&2; exit 4; } +ls -1 *-taxiways.geojson 2>/dev/null || true diff --git a/examples/osm-taxiways/docker-compose.yml b/examples/osm-taxiways/docker-compose.yml new file mode 100644 index 0000000..c0badd5 --- /dev/null +++ b/examples/osm-taxiways/docker-compose.yml @@ -0,0 +1,57 @@ +# =========================================================================== +# OSM taxiway bastion — broker mode, self-contained OSM export box +# =========================================================================== +# Deploy on the host that holds your osm-maps directory (region/planet PBF + +# export-icao-geojson.sh). A backend connects over SSH as `agent` and may run +# ONLY the three whitelisted taxiway commands (see allowed-commands.list); every +# request is validated and exec'd shell-free by bastion-broker. +# +# Backend side (its own .env / config): +# host = agent@ +# port = 6770 (whatever you publish below) +# auth = a private key whose *.pub is dropped in docker-data/bastion/users.d/ +# +# Setup: +# 1. Drop the backend's agent public key: +# mkdir -p docker-data/bastion/users.d +# cp /path/to/agent.pub docker-data/bastion/users.d/backend.pub +# 2. Make the osm-maps dir writable by the bastion's `agent` user (export writes +# -taxiways.geojson + work/ intermediates there): +# AGENT_UID=$(docker run --rm blaxsoftware/bastion:latest id -u agent) +# chown -R "$AGENT_UID" /srv/osm-maps +# 3. Ensure the osm-maps dir contains export-icao-geojson.sh + the region PBF. +# 4. docker compose up -d --build +# 5. Test from a host holding the agent key: +# ssh -p 6770 agent@ taxiways-list +# ssh -p 6770 agent@ taxiways-export EDDF # minutes +# ssh -p 6770 agent@ taxiways-cat EDDF | base64 -d | head +# ssh -p 6770 agent@ 'rm -rf /' # → refused (exit 126) +# =========================================================================== + +services: + osm-taxiways-bastion: + build: . + image: blaxsoftware/bastion-osm-taxiways:latest + container_name: osm-taxiways-bastion + restart: unless-stopped + volumes: + # THE osm-maps dir — the only real host data reachable. Must contain the + # region PBF + export-icao-geojson.sh, and be writable by the agent user. + # Mount at exactly /osm-maps (the wrappers hardcode that path — sshd does + # not propagate container env into a ForceCommand session). Point the LEFT + # side at wherever your osm-maps dir actually lives. + - /srv/osm-maps:/osm-maps + + # Broker allowlist — re-read every request (live edits, no restart). + - ./allowed-commands.list:/etc/bastion/allowed-commands.list:ro + + # Authorized clients — one *.pub per identity (read live). + - ./docker-data/bastion/users.d:/etc/bastion/users.d + + # Persist the SSH host identity across rebuilds (bind mount, never a named + # volume — `down -v` would change the host key and clients would refuse it). + - ./docker-data/bastion/keys:/etc/ssh/keys + ports: + # Bind to an internal interface / front with a VPN if you don't want the + # taxiway bastion on the public internet. + - "6770:22" diff --git a/examples/osm-taxiways/entrypoint b/examples/osm-taxiways/entrypoint new file mode 100755 index 0000000..e8a390a --- /dev/null +++ b/examples/osm-taxiways/entrypoint @@ -0,0 +1,67 @@ +#!/bin/sh +# =========================================================================== +# Broker-only sshd entrypoint for the OSM taxiway bastion. +# =========================================================================== +# Minimal, security-focused: key-only SSH for one non-root user whose every +# session is forced through bastion-broker (client command validated whole-line +# against the allowlist, then exec'd shell-free). No shell, no forwarding, no +# root login. Host keys persist under the mounted /etc/ssh/keys. +# =========================================================================== +set -eu + +SSH_USER=agent +KEYS_DIR=/etc/ssh/keys +USERS_D=/etc/bastion/users.d +ALLOW=/etc/bastion/allowed-commands.list + +# 1) Persistent host identity (bind-mounted dir → survives rebuilds). +mkdir -p "$KEYS_DIR" +for t in rsa ecdsa ed25519; do + f="$KEYS_DIR/ssh_host_${t}_key" + [ -f "$f" ] || ssh-keygen -t "$t" -f "$f" -N "" -q +done +chmod 600 "$KEYS_DIR"/ssh_host_* 2>/dev/null || true + +# 2) authorized_keys from the mounted users.d/*.pub (one client identity per file). +home=$(getent passwd "$SSH_USER" | cut -d: -f6) +mkdir -p "$home/.ssh" +: > "$home/.ssh/authorized_keys" +if [ -d "$USERS_D" ]; then + cat "$USERS_D"/*.pub >> "$home/.ssh/authorized_keys" 2>/dev/null || true +fi +chown -R "$SSH_USER:$SSH_USER" "$home/.ssh" +chmod 700 "$home/.ssh" +chmod 600 "$home/.ssh/authorized_keys" + +# The broker reads this live every request; make sure it exists (empty ⇒ deny all). +[ -f "$ALLOW" ] || { mkdir -p /etc/bastion; : > "$ALLOW"; } + +# 3) ForceCommand wrapper → hand the client's requested command to the broker. +cat > /usr/local/bin/force-command <<'EOF' +#!/bin/sh +exec /usr/local/bin/bastion-broker "${SSH_ORIGINAL_COMMAND:-}" +EOF +chmod 0755 /usr/local/bin/force-command + +# 4) sshd config — key-only, single user, forced through the broker, no forwarding. +cat > /etc/ssh/sshd_config <