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:
parent
256a8c4571
commit
ec1ef50937
|
|
@ -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"]
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue