feat(examples): OSM taxiway bastion (broker mode)

A whitelisted SSH box for an OSM airport-taxiway export pipeline. A backend may run
only three commands — taxiways-list / taxiways-export <ICAO> / taxiways-cat <ICAO> —
validated whole-line against an allowlist and exec'd shell-free by the vendored
bastion-broker.

Ubuntu 22.04 standalone (not FROM blaxsoftware/bastion): the export needs osmium, which
Alpine doesn't package, and newer GDAL (Debian trixie 3.10) segfaults on the OSM->GeoJSON
step — 22.04's GDAL 3.4.1 + osmium 1.14 is the known-good toolchain. Reuses the base repo's
bastion-broker unchanged; a compact broker-only sshd entrypoint replaces the Alpine
start-container (account unlocked via `usermod -p '*'` so glibc sshd accepts pubkey).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-07-01 14:00:33 +02:00
parent 256a8c4571
commit ec1ef50937
9 changed files with 424 additions and 0 deletions

View File

@ -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 "<ICAO>-taxiways.geojson" exports present
# taxiways-export <ICAO> → run export-icao-geojson.sh <ICAO> (osmium/ogr2ogr/jq)
# taxiways-cat <ICAO> → 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"]

View File

@ -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@<osm-host>
└─ bastion-broker (key auth; matches request vs allowed-commands.list)
├─ taxiways-list → ls *-taxiways.geojson in /osm-maps
├─ taxiways-export <ICAO> → export-icao-geojson.sh <ICAO> (osmium/ogr2ogr/jq)
└─ taxiways-cat <ICAO> → base64 <ICAO>-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@<osm-host> taxiways-list # → list (maybe empty)
ssh -p 6770 agent@<osm-host> taxiways-export EDDF # → runs the export (minutes)
ssh -p 6770 agent@<osm-host> taxiways-cat EDDF | base64 -d | head # → GeoJSON
ssh -p 6770 agent@<osm-host> 'rm -rf /' # → "command not permitted", exit 126
ssh -p 6770 agent@<osm-host> 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@<osm-host>: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.

View File

@ -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}

View File

@ -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 "<empty request>" "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 "<multiline request>" "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 <<RULES
$(collect_rules)
RULES
[ "$matched" -eq 1 ] || refuse "$REQ" "command not permitted"
# --- 4) Execute -----------------------------------------------------------
log "ALLOW: $REQ"
PREFIX=""
[ -f "$PREFIX_FILE" ] && PREFIX="$(cat "$PREFIX_FILE")"
set -f # no pathname expansion when we word-split below
# Intentional, unquoted word-splitting of the trusted prefix + validated
# request. set -f above means * ? [ are NOT globbed; IFS does the splitting.
# shellcheck disable=SC2086
set -- $PREFIX $REQ
[ "$#" -ge 1 ] || refuse "$REQ" "command not permitted"
exec "$@"

View File

@ -0,0 +1,18 @@
#!/bin/sh
# Emit one ICAO's taxiway GeoJSON as base64 (an ascii-safe tunnel over ssh).
# Broker rule: `taxiways-cat [A-Z]{4}`. The backend decodes it into
# storage/taxiways/{icao}-taxiways.geojson.
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; }
f="${icao}-taxiways.geojson"
[ -f "$f" ] || { echo "no export for $icao (run taxiways-export $icao first)" >&2; exit 3; }
exec base64 "$f"

View File

@ -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 <ICAO>-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"

View File

@ -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 "<ICAO>-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

View File

@ -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@<osm-host>
# 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
# <ICAO>-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@<osm-host> taxiways-list
# ssh -p 6770 agent@<osm-host> taxiways-export EDDF # minutes
# ssh -p 6770 agent@<osm-host> taxiways-cat EDDF | base64 -d | head
# ssh -p 6770 agent@<osm-host> '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"

View File

@ -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 <<EOF
Port 22
HostKey $KEYS_DIR/ssh_host_rsa_key
HostKey $KEYS_DIR/ssh_host_ecdsa_key
HostKey $KEYS_DIR/ssh_host_ed25519_key
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers $SSH_USER
ForceCommand /usr/local/bin/force-command
AllowTcpForwarding no
AllowAgentForwarding no
X11Forwarding no
PermitTunnel no
PermitUserEnvironment no
LogLevel VERBOSE
EOF
echo "[osm-taxiways-bastion] sshd starting (broker mode, user=$SSH_USER)"
exec /usr/sbin/sshd -D -e