feat: add ssh-image/ — key-only Dropbear SSH injection for Spike 2 SD images

enable-ssh.sh turns a factory raw SD image (King Kong LE 0.97) into an
SSH-enabled one: injects the static dropbear/dropbearkey/sftp-server
binaries, the SysV init script (/etc/init.d/sshd), the S30sshd boot
symlink and the caller-supplied public key into the ext3 rootfs
partition with debugfs (no mounting). The input image is never
modified, the output path must be explicit and distinct, and every
byte outside p2 is verified identical afterwards. The public key is an
explicit -k argument — nothing is baked in and private keys are
rejected. --dry-run supported; --force for non-standard p2 geometry.

bin/ is byte-identical to the deployed king_kong_le-0_97_0.ssh image.
validate-image.sh functionally proves a result (boots the image's own
init script under qemu, real ssh pubkey login, password auth refused,
sftp round-trip); build-binaries.sh rebuilds bin/ in Docker.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-13 09:02:32 -04:00
co-authored by Claude Code
parent e8f812ba4a
commit cbdbe75f00
8 changed files with 812 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
# ssh-image — key-only Dropbear SSH for a Stern Spike 2 raw SD image
Turns a **factory** Spike 2 raw SD image (King Kong LE 0.97.0, 16 GiB) into an
SSH-enabled image, reproducing the working setup documented in
`/Users/matt/src/pinball-assets/README.md` sections 1 and 3. The original image
is only ever read; the result is a new image file you choose.
```
enable-ssh.sh -i <factory>.raw -o <factory>.ssh.raw -k <your>.pub
```
No password is ever set or needed — Dropbear is compiled without password
auth. The **public** key is an explicit input (`-k`); no private key is read,
stored, or embedded anywhere by this tool.
## What gets injected (into the ext3 rootfs partition, p2, only)
| Path on the machine | Source here | Mode | Owner |
|---|---|---|---|
| `/usr/local/bin/dropbear` | `bin/dropbear` | 0755 | 0:0 |
| `/usr/local/bin/dropbearkey` | `bin/dropbearkey` | 0755 | 0:0 |
| `/usr/libexec/sftp-server` | `bin/sftp-server` | 0755 | 0:0 |
| `/etc/init.d/sshd` | `files/sshd` | 0755 | 0:0 |
| `/etc/rc5.d/S30sshd` | symlink → `../init.d/sshd` | 0777 | 0:0 |
| `/home/root/.ssh` | new directory | 0700 | 0:0 |
| `/home/root/.ssh/authorized_keys` | your `-k` file | 0600 | 0:0 |
Byte-identical to the deployed `king_kong_le-0_97_0.ssh.16G.sdcard.raw`
(verified against it with `debugfs dump` + sha256 for all three binaries).
- **dropbear / dropbearkey** — 2025.88, static musl armhf. Password auth
compiled out (`DROPBEAR_SVR_PASSWORD_AUTH 0`), `DROPBEAR_REEXEC=0`
(the Spike 2 kernel is 3.14 and predates `execveat`).
- **sftp-server** — static Go binary (pkg/sftp). dropbear dropped its built-in
scp upstream; this makes `scp`/`rsync` work. It lives at
`/usr/libexec/sftp-server` because that is dropbear's compile-time
`SFTPSERVER_PATH` default — no `-s` flag in the init script is needed.
- **`/etc/init.d/sshd`** — SysV init script: on `start` it generates Dropbear
host keys (rsa/ecdsa/ed25519) into `/data/dropbear` if absent (host keys are
per-machine, so they are generated on first boot rather than baked in), then
runs dropbear on port 22.
- **`/etc/rc5.d/S30sshd`** — boot hook; S30 runs before the game's `S95game`.
Everything else in the image — p1 (boot, FAT), the game partition, and every
data/dump byte — is untouched and **verified byte-identical** after the run
(`cmp` over both regions outside p2).
## Requirements
- macOS (Homebrew). The image work uses `cp`, `dd`, `cmp`, `shasum` — all stock.
- `brew install e2fsprogs` — provides `debugfs` and `e2fsck` (1.4x).
- `python3` (system) — for MBR parsing and path resolution.
- For `validate-image.sh` only: Docker (any linux container) — it runs
privileged (bind mounts for /dev, /proc) and uses `qemu-user-static` to
boot the ARM rootfs. `build-binaries.sh` also needs Docker plus network
access (musl.cc, GitHub, codeload).
## Usage
```sh
# 1. make sure you have a public key (the private half never enters this repo)
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/spike2 # if you don't have one
# use ~/.ssh/spike2.pub as the -k input
# 2. dry run: parses the MBR, inspects p2, prints the exact plan, writes nothing
./enable-ssh.sh -i ~/images/king_kong_le-0_97_0.Release.16G.sdcard.raw \
-o /tmp/king_kong_le-0_97_0.ssh.16G.sdcard.raw \
-k ~/.ssh/spike2.pub --dry-run
# 3. real run (APFS clone is instant; a full 16 GiB copy on other volumes takes a while)
./enable-ssh.sh -i ~/images/king_kong_le-0_97_0.Release.16G.sdcard.raw \
-o ~/images/king_kong_le-0_97_0.ssh.16G.sdcard.raw \
-k ~/.ssh/spike2.pub
# 4. optional deep check before flashing (Docker, qemu-arm, real ssh login)
./validate-image.sh -i ~/images/king_kong_le-0_97_0.ssh.16G.sdcard.raw \
-k ~/.ssh/spike2.pub
```
Options: `-b DIR` / `-f DIR` point at alternative binary / init-script
directories (defaults are `./bin` and `./files` beside the script);
`--force` accepts a p2 geometry other than the known King Kong layout
(12 MiB offset, 336 MiB size — the script parses the MBR, it does not assume);
`--overwrite` replaces an existing output file (refused by default);
`--dry-run` writes nothing.
The script refuses to run if: input/output are the same file, the output
already exists, the key file contains a private key or unrecognised lines,
the image has no MBR / no ext3 rootfs partition, or the rootfs fails a
read-only `e2fsck -fn` before **or** after injection.
## Safety model
- The input image is opened read-only and is never written; its size is
re-checked at the end.
- The output is created fresh (`cp -c` APFS clone when possible, else full
copy) and must be a different path from the input.
- Only one region is ever written back: p2, exactly `skip`/`count` in 512-byte
sectors. Afterwards `cmp` proves every byte before and after p2 equals the
input — including wherever the data/dump partitions live.
- Host keys are generated on the machine at first boot (`/data/dropbear`),
never baked into the image.
- **Before flashing over a card that is in use: back up its data partition**
(scores/settings/NVM). The image's `/data` is factory-empty; flashing it
over a live card erases that data. (The original setup lost it once —
restored from a pre-SSH backup via tar-over-ssh.)
- Flash to a **full-size** SD card in the machine's `mmcblk0` slot. Never
flash to the machine's live SD while it is the boot device.
## Validation
`enable-ssh.sh` self-verifies every run (filesystem health before/after,
presence + mode + owner of all injected objects, sha256 of the three
binaries inside the result, and the `cmp` preservation proof).
`validate-image.sh` goes further: it extracts the output image's p2 into a
scratch rootfs tree (debugfs `rdump`, no loop device needed) in a privileged
Docker container, starts dropbear **via the image's own
`/etc/init.d/sshd`** (first-boot path: the image's `dropbearkey` generates
the host keys into `/data/dropbear`), and then from the container
(a) logs in as root over ssh with a throwaway ed25519 key generated inside
the container (your private key is never read or stored), (b) confirms
password auth is refused, and (c) runs `sftp` put/get to prove the
sftp-server wiring. It also statically checks that the deployed
`authorized_keys` in the image equals the `-k` file you gave
`enable-ssh.sh`. The image file is only ever read; all test mutations happen
on a scratch copy of p2.
## Building the binaries (provenance and rebuild)
`bin/dropbear`, `bin/dropbearkey` and `bin/sftp-server` were built once, on
2026-10-11, in the `pinball-assets/.ssh-build/` tree:
| binary | build | notes |
|---|---|---|
| `dropbear`, `dropbearkey` | `dropbear 2025.88`, `./configure --host=arm-linux-musleabihf --disable-zlib --enable-static`, `localoptions.h`: `DROPBEAR_SVR_PASSWORD_AUTH 0`, `DROPBEAR_CLI_PASSWORD_AUTH 0`, `DROPBEAR_SVR_PUBKEY_AUTH 1`, `DROPBEAR_REEXEC 0` | musl.cc `arm-linux-musleabihf` cross toolchain; Debian 12 container |
| `sftp-server` | Go 1.19+ in GOPATH mode from codeload tarballs (`pkg/sftp v1.13.2`, `kr/fs v0.1.0`, `golang.org/x/crypto v0.7.0`, `x/sys v0.7.0`), `GOARCH=arm GOARM=7 CGO_ENABLED=0`, `go build -trimpath -ldflags="-s -w"` | static, stripped |
To rebuild them from this directory (requires Docker):
```sh
./build-binaries.sh # rewrites bin/ and prints file(1) + sha256 for each
```
The originals live on at `pinball-assets/.ssh-build/out-musl/{dropbear,dropbearkey}`
and `pinball-assets/.ssh-build/out-sftp/sftp-server` with the build scripts
`build.sh` (dropbear) and `build-sftp-go.sh` (sftp).
## Files
| Path | What |
|---|---|
| `enable-ssh.sh` | the injection tool (input image + public key → output image) |
| `validate-image.sh` | Docker/qemu functional test of an output image (optional) |
| `build-binaries.sh` | rebuilds `bin/` in a Docker container (optional) |
| `files/sshd` | the SysV init script injected to `/etc/init.d/sshd` |
| `bin/dropbear`, `bin/dropbearkey`, `bin/sftp-server` | the static armhf binaries injected into the image |
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+122
View File
@@ -0,0 +1,122 @@
#!/bin/sh
# build-binaries.sh — rebuild bin/{dropbear,dropbearkey,sftp-server} in a
# Docker container, reproducing the original pinball-assets/.ssh-build builds:
#
# dropbear/dropbearkey : dropbear 2025.88, static musl armhf
# (password auth compiled out, DROPBEAR_REEXEC=0 —
# the Spike 2 kernel 3.14 predates execveat)
# sftp-server : static Go (pkg/sftp), GOARCH=arm GOARM=7,
# built in GOPATH mode from codeload tarballs
# (proxy.golang.org is unreachable on the dev network)
#
# Overwrites bin/ in place and prints file(1) + sha256 for each result.
# Requires docker. The container downloads:
# https://musl.cc/arm-linux-musleabihf-cross.tgz
# https://github.com/mkj/dropbear (tag DROPBEAR_2025.88)
# https://codeload.github.com/pkg/sftp (v1.13.2) + deps
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
DOCKER_IMAGE="debian:12"
command -v docker >/dev/null 2>&1 || { echo "error: docker not found" >&2; exit 1; }
# scratch dir under $HOME (Docker Desktop on macOS mishandles /tmp bind mounts)
WORK=$HOME/.ssh-image-build.$$
mkdir -p "$WORK"
trap 'rm -rf "$WORK"' EXIT
cat > "$WORK/build.sh" <<'BUILD'
#!/bin/sh
set -e
apt-get update -qq
apt-get install -y -qq make wget xz-utils file golang-go >/dev/null
cd /src
# --- dropbear 2025.88, static musl armhf ------------------------------------
if [ ! -d musl-toolchain ]; then
wget -q https://musl.cc/arm-linux-musleabihf-cross.tgz -O musl.tgz
mkdir musl-toolchain && tar xzf musl.tgz -C musl-toolchain --strip-components=1
fi
export PATH=/src/musl-toolchain/bin:$PATH
if [ ! -d dropbear ]; then
wget -q https://github.com/mkj/dropbear/archive/refs/tags/DROPBEAR_2025.88.tar.gz -O dropbear.tar.gz
tar xzf dropbear.tar.gz && mv dropbear-DROPBEAR_2025.88 dropbear
fi
cd dropbear
cat > localoptions.h <<'OPTS'
#define DROPBEAR_SVR_PASSWORD_AUTH 0
#define DROPBEAR_CLI_PASSWORD_AUTH 0
#define DROPBEAR_SVR_PUBKEY_AUTH 1
#define DROPBEAR_REEXEC 0
OPTS
CC=arm-linux-musleabihf-gcc \
./configure --host=arm-linux-musleabihf --disable-zlib --enable-static >/dev/null 2>&1
make clean >/dev/null 2>&1 || true
make -j"$(nproc)" PROGRAMS="dropbear dropbearkey" >/dev/null 2>&1
file dropbear dropbearkey
cp dropbear dropbearkey /out/
# --- sftp-server: static Go, armv7, GOPATH mode from codeload tarballs -------
export GOARCH=arm GOARM=7 GOOS=linux CGO_ENABLED=0 GO111MODULE=off
export GOPATH=/src/gopath
mkdir -p $GOPATH/src/github.com/pkg $GOPATH/src/github.com/kr $GOPATH/src/golang.org/x
cd $GOPATH/src/github.com/pkg
[ -d sftp ] || (wget -q https://codeload.github.com/pkg/sftp/tar.gz/refs/tags/v1.13.2 -O sftp.tgz && mkdir sftp && tar xzf sftp.tgz -C sftp --strip-components=1)
cd $GOPATH/src/github.com/kr
[ -d fs ] || (wget -q https://codeload.github.com/kr/fs/tar.gz/refs/tags/v0.1.0 -O fs.tgz && mkdir fs && tar xzf fs.tgz -C fs --strip-components=1)
cd $GOPATH/src/golang.org/x
[ -d crypto ] || (wget -q https://codeload.github.com/golang/crypto/tar.gz/refs/tags/v0.7.0 -O crypto.tgz && mkdir crypto && tar xzf crypto.tgz -C crypto --strip-components=1)
[ -d sys ] || (wget -q https://codeload.github.com/golang/sys/tar.gz/refs/tags/v0.7.0 -O sys.tgz && mkdir sys && tar xzf sys.tgz -C sys --strip-components=1)
mkdir -p /src/sftp-go && cd /src/sftp-go
cat > main.go <<'GO'
package main
import (
"io"
"log"
"os"
"github.com/pkg/sftp"
)
type rw struct{}
func (rw) Read(p []byte) (int, error) { return os.Stdin.Read(p) }
func (rw) Write(p []byte) (int, error) { return os.Stdout.Write(p) }
func (rw) Close() error { return nil }
func main() {
srv, err := sftp.NewServer(rw{})
if err != nil {
log.Fatal(err)
}
if err := srv.Serve(); err != nil && err != io.EOF {
log.Fatal(err)
}
}
GO
go build -trimpath -ldflags="-s -w" -o /out/sftp-server .
file /out/sftp-server
BUILD
chmod +x "$WORK/build.sh"
mkdir -p "$WORK/out"
echo "[build] running container ($DOCKER_IMAGE) — downloads ~200 MB, takes a few minutes"
if ! docker run --rm -v "$WORK:/src:rw" "$DOCKER_IMAGE" /bin/sh /src/build.sh; then
echo "error: build failed" >&2
exit 1
fi
for B in dropbear dropbearkey sftp-server; do
[ -f "$WORK/out/$B" ] || { echo "error: build did not produce $B" >&2; exit 1; }
cp "$WORK/out/$B" "$SCRIPT_DIR/bin/$B"
chmod 755 "$SCRIPT_DIR/bin/$B"
done
echo "[build] done. bin/ now contains:"
for B in dropbear dropbearkey sftp-server; do
echo " $B: $(file -b "$SCRIPT_DIR/bin/$B" | cut -c1-60)..."
shasum -a 256 "$SCRIPT_DIR/bin/$B" | sed 's/^/ sha256 /'
done
echo "[build] NOTE: rebuilt binaries may differ from the originally deployed ones"
echo "[build] (toolchain versions); if in doubt, keep the shipped bin/ files."
+337
View File
@@ -0,0 +1,337 @@
#!/bin/sh
# enable-ssh.sh — inject key-only Dropbear SSH into a Stern Spike 2 raw SD image.
#
# Reproduces the working setup documented in pinball-assets/README.md (sections 1
# and 3): the ext3 rootfs partition (p2) of a factory Spike 2 image (King Kong
# LE 0.97.0, 16 GiB) is replaced with a copy that additionally contains
# /usr/local/bin/dropbear static musl armhf, 2025.88 — password auth
# compiled out, DROPBEAR_REEXEC=0 (kernel 3.14
# predates execveat)
# /usr/local/bin/dropbearkey static musl armhf
# /usr/libexec/sftp-server static Go sftp server — dropbear's compile-time
# SFTPSERVER_PATH default, so scp/rsync work
# without a -s flag
# /etc/init.d/sshd SysV init: generates host keys into
# /data/dropbear at first boot, dropbear on 22
# /etc/rc5.d/S30sshd boot symlink -> ../init.d/sshd (before S95game)
# /home/root/.ssh/authorized_keys the public key(s) you supply (dir 0700, key 0600)
# Everything else in the image — p1 boot, the game partition, and all data/dump
# bytes — is left bit-for-bit untouched and verified to be so afterwards.
# No private keys are ever read, stored, or embedded.
#
# Usage:
# enable-ssh.sh -i INPUT_IMAGE -o OUTPUT_IMAGE -k PUBLIC_KEY [options]
#
# -i FILE input raw SD image (factory/Release image). NEVER modified.
# -o FILE output raw SD image to create. Must differ from the input.
# -k FILE authorized_keys content: one or more public key lines
# (ssh-ed25519, ssh-rsa, ecdsa-sha2-*, sk-*). No private keys.
# -b DIR dir with dropbear/dropbearkey/sftp-server (default: ./bin)
# -f DIR dir with the sshd init script (default: ./files)
# --dry-run parse and print the plan; write nothing
# --force allow a p2 geometry that differs from the known
# King Kong LE 0.97 layout (12 MiB offset, 336 MiB size)
# --overwrite allow an existing output file to be replaced
# -h help
#
# Exit status: 0 on success (or a complete dry run), non-zero on any error.
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
BINDIR="$SCRIPT_DIR/bin"
FILEDIR="$SCRIPT_DIR/files"
INPUT= OUTPUT= KEYFILE= DRYRUN=0 FORCE=0 OVERWRITE=0
usage() {
cat <<'USAGE'
Usage: enable-ssh.sh -i INPUT_IMAGE -o OUTPUT_IMAGE -k PUBLIC_KEY [options]
-i FILE input raw SD image (factory/Release image). NEVER modified.
-o FILE output raw SD image to create. Must differ from the input.
-k FILE authorized_keys content: one or more public key lines
(ssh-ed25519, ssh-rsa, ecdsa-sha2-*, sk-*). No private keys.
-b DIR dir with dropbear/dropbearkey/sftp-server (default: ./bin)
-f DIR dir with the sshd init script (default: ./files)
--dry-run parse and print the plan; write nothing
--force allow a p2 geometry different from the known layout
--overwrite allow an existing output file to be replaced
-h help
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
-i) INPUT=$2; shift 2 ;;
-o) OUTPUT=$2; shift 2 ;;
-k) KEYFILE=$2; shift 2 ;;
-b) BINDIR=$2; shift 2 ;;
-f) FILEDIR=$2; shift 2 ;;
--dry-run) DRYRUN=1; shift ;;
--force) FORCE=1; shift ;;
--overwrite) OVERWRITE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
err() { echo "error: $*" >&2; exit 1; }
say() { echo "[enable-ssh] $*"; }
sha256() {
if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
else sha256sum "$1" | cut -d' ' -f1; fi
}
realpath() { python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$1"; }
[ -n "$INPUT" ] || { usage >&2; err "missing -i INPUT_IMAGE"; }
[ -n "$OUTPUT" ] || { usage >&2; err "missing -o OUTPUT_IMAGE"; }
[ -n "$KEYFILE" ] || { usage >&2; err "missing -k PUBLIC_KEY"; }
command -v python3 >/dev/null 2>&1 || err "python3 not found (needed for MBR parsing)"
command -v debugfs >/dev/null 2>&1 || err "debugfs not found (brew install e2fsprogs)"
command -v e2fsck >/dev/null 2>&1 || err "e2fsck not found (brew install e2fsprogs)"
# --- inputs -----------------------------------------------------------------
[ -f "$INPUT" ] && [ -r "$INPUT" ] || err "input image not a readable regular file: $INPUT"
IN_REAL=$(realpath "$INPUT")
OUT_REAL=$(realpath "$OUTPUT")
[ "$IN_REAL" != "$OUT_REAL" ] || err "output image must differ from input image ($OUT_REAL)"
if [ -e "$OUTPUT" ]; then
[ "$OVERWRITE" = 1 ] || err "output exists: $OUTPUT (refusing to overwrite; use --overwrite)"
fi
for B in dropbear dropbearkey sftp-server; do
[ -f "$BINDIR/$B" ] || err "missing binary: $BINDIR/$B (see README, 'Building the binaries')"
done
[ -f "$FILEDIR/sshd" ] || err "missing init script: $FILEDIR/sshd"
# public key: one or more public-key lines, nothing that smells private
[ -f "$KEYFILE" ] && [ -r "$KEYFILE" ] || err "public key file not readable: $KEYFILE"
KEYS=$(grep -c '[^[:space:]]' "$KEYFILE" 2>/dev/null) || KEYS=0
[ "$KEYS" -ge 1 ] || err "public key file has no lines: $KEYFILE"
grep -n 'PRIVATE' "$KEYFILE" >/dev/null 2>&1 && \
err "key file contains 'PRIVATE' — refusing (private keys are not allowed here)"
if [ "$(tr -d '\r' < "$KEYFILE" | wc -c | tr -d '[:space:]')" != "$(wc -c < "$KEYFILE" | tr -d '[:space:]')" ]; then
err "key file has CRLF line endings — convert with: tr -d '\\r' < in > out"
fi
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
'') continue ;;
ssh-ed25519*|ssh-rsa*|ecdsa-sha2-nistp256*|ecdsa-sha2-nistp384*|ecdsa-sha2-nistp521*|sk-ssh-ed25519*|sk-ecdsa-sha2-nistp256*)
;;
*) err "not a recognised public key line: $line" ;;
esac
done < "$KEYFILE"
# --- partition table (read-only) ---------------------------------------------
# Known King Kong LE 0.97 layout: p1 FAT32 @4MiB, p2 ext3 rootfs @12MiB,
# 336 MiB (688128 sectors), then the game/data region. We parse the MBR
# rather than assume it.
P2_INFO=$(python3 - "$IN_REAL" <<'PYEOF'
import struct, sys
path = sys.argv[1]
with open(path, "rb") as f:
mbr = f.read(512)
if mbr[510:512] != b"\x55\xaa":
sys.exit("no MBR boot signature (55aa) — not a raw MBR image?")
for i in range(4):
e = mbr[446 + 16*i : 446 + 16*(i+1)]
if not e or e == b"\x00" * 16:
continue
if e[4] == 0x83: # primary Linux partition
lba, size = struct.unpack("<II", e[8:16])
if size:
print(lba, size)
break
else:
sys.exit("no Linux (0x83) partition in MBR")
PYEOF
) || err "MBR parse failed: $P2_INFO"
P2_SECTORS=$(echo "$P2_INFO" | cut -d' ' -f1)
P2_SIZE_SECTORS=$(echo "$P2_INFO" | cut -d' ' -f2)
P2_OFFSET=$((P2_SECTORS * 512))
P2_SIZE=$((P2_SIZE_SECTORS * 512))
KNOWN_OFFSET=12582912 # 12 MiB
KNOWN_SIZE=352321536 # 336 MiB
if [ "$P2_OFFSET" != "$KNOWN_OFFSET" ] || [ "$P2_SIZE" != "$KNOWN_SIZE" ]; then
if [ "$FORCE" = 1 ]; then
say "WARNING: p2 geometry differs from the known King Kong LE 0.97 layout"
say " parsed: offset $P2_OFFSET ($((P2_OFFSET / 1048576)) MiB), size $P2_SIZE ($((P2_SIZE / 1048576)) MiB)"
else
err "p2 geometry (offset $P2_OFFSET, size $P2_SIZE) differs from the known layout (offset $KNOWN_OFFSET, size $KNOWN_SIZE); re-run with --force to proceed"
fi
fi
# input must be at least as large as the end of p2
IN_SIZE=$(wc -c < "$IN_REAL" | tr -d '[:space:]')
[ "$IN_SIZE" -ge $((P2_OFFSET + P2_SIZE)) ] || err "input image too small: $IN_SIZE bytes < p2 end $((P2_OFFSET + P2_SIZE))"
# p2 must be ext3 (magic 0xEF53, stored little-endian as 53 ef, at offset 1080)
MAGIC=$(python3 -c '
import sys
with open(sys.argv[1], "rb") as f:
f.seek(int(sys.argv[2]))
sys.stdout.write(f.read(2).hex())' "$IN_REAL" "$((P2_OFFSET + 1080))")
[ "$MAGIC" = "53ef" ] || err "p2 at offset $P2_OFFSET is not ext3 (magic $MAGIC, expected 53ef)"
say "input : $IN_REAL"
say " size: $IN_SIZE bytes"
say "p2 : offset $P2_OFFSET ($((P2_OFFSET / 1048576)) MiB), size $P2_SIZE ($((P2_SIZE / 1048576)) MiB), ext3"
say "output: $OUT_REAL"
say "key : $KEYFILE ($KEYS line(s))"
# --- dry run -----------------------------------------------------------------
if [ "$DRYRUN" = 1 ]; then
say "dry run — nothing will be written"
WORK=$(mktemp -d "${TMPDIR:-/tmp}/ssh-image.XXXXXX")
trap 'rm -rf "$WORK"' EXIT
say "extracting p2 from input for inspection (read-only) ..."
dd if="$IN_REAL" of="$WORK/p2" bs=512 skip="$P2_SECTORS" count="$P2_SIZE_SECTORS" 2>/dev/null
if debugfs -R 'stat /usr/local/bin/dropbear' "$WORK/p2" 2>/dev/null | grep -q '^Inode:'; then
say "input p2 already contains /usr/local/bin/dropbear (image may already be SSH-enabled)"
else
say "input p2 does not contain dropbear (factory state)"
fi
say "plan:"
say " 1. copy image: $IN_REAL -> $OUT_REAL (APFS clone if possible, else full copy)"
say " 2. extract p2 from the copy, inject with debugfs (no mounting):"
say " /usr/local/bin/dropbear <- $BINDIR/dropbear (0755, 0:0)"
say " /usr/local/bin/dropbearkey <- $BINDIR/dropbearkey (0755, 0:0)"
say " /usr/libexec/sftp-server <- $BINDIR/sftp-server (0755, 0:0)"
say " /etc/init.d/sshd <- $FILEDIR/sshd (0755, 0:0)"
say " /etc/rc5.d/S30sshd -> symlink ../init.d/sshd"
say " /home/root/.ssh (new dir, 0700, 0:0)"
say " /home/root/.ssh/authorized_keys <- $KEYFILE (0600, 0:0)"
say " 3. e2fsck -fn the injected p2 (abort if unhealthy)"
say " 4. write p2 back at offset $P2_OFFSET"
say " 5. verify: p2 region differs from input; every other byte identical (cmp)"
exit 0
fi
# --- real run -----------------------------------------------------------------
WORK=$(mktemp -d "${TMPDIR:-/tmp}/ssh-image.XXXXXX")
trap 'rm -rf "$WORK"' EXIT
say "work dir: $WORK"
say "copying input image -> output (APFS clone if possible, else full copy) ..."
if ! cp -c "$IN_REAL" "$OUT_REAL" 2>/dev/null; then
say "clone not available; doing a full copy (16 GiB — this takes a while)"
cp "$IN_REAL" "$OUT_REAL"
fi
OUT_SIZE=$(wc -c < "$OUT_REAL" | tr -d '[:space:]')
[ "$OUT_SIZE" = "$IN_SIZE" ] || err "output size after copy mismatch: $OUT_SIZE != $IN_SIZE"
say "extracting p2 from the copy ..."
dd if="$OUT_REAL" of="$WORK/p2" bs=512 skip="$P2_SECTORS" count="$P2_SIZE_SECTORS" 2>/dev/null
[ "$(wc -c < "$WORK/p2" | tr -d '[:space:]')" = "$P2_SIZE" ] || err "p2 extraction size mismatch"
say "baseline filesystem check (e2fsck -fn) ..."
e2fsck -fn "$WORK/p2" >/dev/null 2>&1 || err "baseline e2fsck failed on p2 — input rootfs is not healthy; aborting (output left as an unmodified copy)"
# --- inject with debugfs (no mounting) ----------------------------------------
# debugfs `write` forces ownership 0:0 and copies the source file's mode; modes
# are pinned explicitly afterwards with `sif` (decimal 16-bit mode, type bits
# included: file 0755=33261, file 0600=33152, dir 0755=16877, dir 0700=16832).
# `symlink <name> <source>` requires the source to exist in the fs and resolves
# it against debugfs's cwd, so we `cd` into /etc/rc5.d first; the link content
# written is the literal source string.
say "injecting files with debugfs ..."
exists() { debugfs -R "stat $1" "$WORK/p2" 2>/dev/null | grep -q '^Inode:'; }
CMDS="$WORK/cmds"
: > "$CMDS"
exists /usr/libexec || echo "mkdir /usr/libexec" >> "$CMDS"
exists /home/root/.ssh || echo "mkdir /home/root/.ssh" >> "$CMDS"
exists /etc/rc5.d/S30sshd && echo "rm /etc/rc5.d/S30sshd" >> "$CMDS"
echo "write $BINDIR/dropbear /usr/local/bin/dropbear" >> "$CMDS"
echo "write $BINDIR/dropbearkey /usr/local/bin/dropbearkey" >> "$CMDS"
echo "write $BINDIR/sftp-server /usr/libexec/sftp-server" >> "$CMDS"
echo "write $FILEDIR/sshd /etc/init.d/sshd" >> "$CMDS"
echo "write $KEYFILE /home/root/.ssh/authorized_keys" >> "$CMDS"
echo "cd /etc/rc5.d" >> "$CMDS"
echo "symlink S30sshd ../init.d/sshd" >> "$CMDS"
DBG1="$WORK/dbg1.log"
debugfs -w -f "$CMDS" "$WORK/p2" > "$DBG1" 2>&1 || err "debugfs injection failed"
if grep -qiE 'error|failed|denied|not found' "$DBG1"; then
sed 's/^/ /' "$DBG1" >&2
err "debugfs reported errors (log above)"
fi
# pin the exact modes (session 2: non-interactive sif)
SMODES="$WORK/smodes"
: > "$SMODES"
echo "sif /usr/local/bin/dropbear mode 33261" >> "$SMODES" # 0100755
echo "sif /usr/local/bin/dropbearkey mode 33261" >> "$SMODES" # 0100755
echo "sif /usr/libexec/sftp-server mode 33261" >> "$SMODES" # 0100755
echo "sif /etc/init.d/sshd mode 33261" >> "$SMODES" # 0100755
echo "sif /usr/libexec mode 16877" >> "$SMODES" # 0040755
echo "sif /home/root/.ssh mode 16832" >> "$SMODES" # 0040700
echo "sif /home/root/.ssh/authorized_keys mode 33152" >> "$SMODES" # 0100600
DBG2="$WORK/dbg2.log"
debugfs -w -f "$SMODES" "$WORK/p2" > "$DBG2" 2>&1 || err "debugfs mode-setting failed"
if grep -qiE 'error|failed|denied|not found' "$DBG2"; then
sed 's/^/ /' "$DBG2" >&2
err "debugfs reported errors (log above)"
fi
say "post-injection filesystem check (e2fsck -fn) ..."
e2fsck -fn "$WORK/p2" >/dev/null 2>&1 || err "post-injection e2fsck failed — p2 not written back; output image is an unmodified copy"
# sanity: the injected objects must be in place with the right modes and 0:0
check_obj() {
debugfs -R "stat $1" "$WORK/p2" 2>/dev/null | grep -q "Mode: *$2" || err "injected $1 missing or wrong mode (want $2)"
debugfs -R "stat $1" "$WORK/p2" 2>/dev/null | grep -q 'User: 0 Group: 0' || err "injected $1 not owned by 0:0"
}
check_obj /usr/local/bin/dropbear 0755
check_obj /usr/local/bin/dropbearkey 0755
check_obj /usr/libexec/sftp-server 0755
check_obj /etc/init.d/sshd 0755
check_obj /home/root/.ssh 0700
check_obj /home/root/.ssh/authorized_keys 0600
debugfs -R 'stat /etc/rc5.d/S30sshd' "$WORK/p2" 2>/dev/null | grep -q 'Fast link dest: "../init.d/sshd"' \
|| err "boot symlink /etc/rc5.d/S30sshd missing or wrong target"
# binaries in the p2 must match the ones we injected
for B in dropbear dropbearkey sftp-server; do
case "$B" in
dropbear) P=/usr/local/bin/dropbear ;;
dropbearkey) P=/usr/local/bin/dropbearkey ;;
sftp-server) P=/usr/libexec/sftp-server ;;
esac
debugfs -R "dump $P $WORK/check-$B" "$WORK/p2" 2>/dev/null
[ "$(sha256 "$WORK/check-$B")" = "$(sha256 "$BINDIR/$B")" ] || err "injected $B does not match $BINDIR/$B"
rm -f "$WORK/check-$B"
done
say "writing p2 back at offset $P2_OFFSET ..."
dd if="$WORK/p2" of="$OUT_REAL" bs=512 seek="$P2_SECTORS" conv=notrunc 2>/dev/null
# --- verification ---------------------------------------------------------------
say "verifying ..."
OUT_SIZE=$(wc -c < "$OUT_REAL" | tr -d '[:space:]')
[ "$OUT_SIZE" = "$IN_SIZE" ] || err "output size changed: $OUT_SIZE != $IN_SIZE"
# every byte before and after p2 must be identical to the input
say "comparing bytes before p2 (0 .. $P2_OFFSET) ..."
cmp -n "$P2_OFFSET" "$IN_REAL" "$OUT_REAL" || err "bytes before p2 differ — output discarded"
say "comparing bytes after p2 ($((P2_OFFSET + P2_SIZE)) .. end) ..."
cmp -i "$((P2_OFFSET + P2_SIZE))" "$IN_REAL" "$OUT_REAL" || err "bytes after p2 differ (game/data partitions!) — output discarded"
# input must be untouched
IN_SIZE2=$(wc -c < "$IN_REAL" | tr -d '[:space:]')
[ "$IN_SIZE2" = "$IN_SIZE" ] || err "input image size changed — this must not happen"
say "done."
say "output image : $OUT_REAL"
say " sha256 : $(sha256 "$OUT_REAL")"
say ""
say "next steps:"
say " - flash to a full-size SD card and insert into the machine's mmcblk0 slot"
say " - WARNING: the image's /data (scores/settings) is factory-empty; flashing over"
say " a card in use erases it. Back up the live card's data first (see README)."
say " - on first boot the machine generates Dropbear host keys into /data/dropbear"
say " - then: ssh -i <private-key> root@<machine-ip> (key-only, port 22)"
say " - optional deeper check before flashing:"
say " ./validate-image.sh -i $OUT_REAL -k $KEYFILE"
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
DAEMON=/usr/local/bin/dropbear
KEYDIR=/data/dropbear
NAME=dropbear
case "$1" in
start)
echo -n "Starting $NAME: "
mkdir -p $KEYDIR
for T in rsa ecdsa ed25519; do
if [ ! -f $KEYDIR/dropbear_${T}_host_key ]; then
/usr/local/bin/dropbearkey -t $T -f $KEYDIR/dropbear_${T}_host_key
fi
done
$DAEMON \
-r $KEYDIR/dropbear_rsa_host_key \
-r $KEYDIR/dropbear_ecdsa_host_key \
-r $KEYDIR/dropbear_ed25519_host_key
echo "OK"
;;
stop)
echo -n "Stopping $NAME: "
start-stop-daemon -K -n $NAME
echo "OK"
;;
restart)
$0 stop
$0 start
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
esac
exit 0
+160
View File
@@ -0,0 +1,160 @@
#!/bin/sh
# validate-image.sh — functional test of an image produced by enable-ssh.sh.
#
# Extracts the output image's rootfs (p2) into a scratch tree (debugfs
# rdump — no loop device needed) inside a privileged Docker container,
# starts dropbear via the image's OWN /etc/init.d/sshd (which generates
# host keys into /data/dropbear exactly as on first boot), then:
# 1. checks the deployed authorized_keys equals the -k public key you gave
# enable-ssh.sh (static check, outside the container)
# 2. ssh-login as root with a throwaway ed25519 key generated INSIDE the
# container (no private key of yours is ever read or stored)
# 3. confirms password auth is refused
# 4. runs sftp put/get to prove the sftp-server wiring
#
# The image file itself is only read; all test mutations happen on a scratch
# copy of p2 inside the container.
#
# Usage: validate-image.sh -i OUTPUT_IMAGE -k PUBLIC_KEY [--image docker-image]
#
# Requires: docker (linux containers), a volume with ~1 GiB free for the p2
# scratch copy. Takes a few minutes (container setup + key generation).
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
INPUT= KEYFILE= DOCKER_IMAGE="debian:12"
while [ $# -gt 0 ]; do
case "$1" in
-i) INPUT=$2; shift 2 ;;
-k) KEYFILE=$2; shift 2 ;;
--image) DOCKER_IMAGE=$2; shift 2 ;;
-h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "error: unknown argument: $1" >&2; exit 2 ;;
esac
done
err() { echo "error: $*" >&2; exit 1; }
say() { echo "[validate] $*"; }
[ -n "$INPUT" ] && [ -n "$KEYFILE" ] || { err "usage: validate-image.sh -i OUTPUT_IMAGE -k PUBLIC_KEY"; }
[ -f "$INPUT" ] || err "image not found: $INPUT"
[ -f "$KEYFILE" ] || err "public key not found: $KEYFILE"
command -v docker >/dev/null 2>&1 || err "docker not found"
docker info >/dev/null 2>&1 || err "docker daemon not reachable"
# --- parse the MBR (read-only) for p2 ----------------------------------------
P2_INFO=$(python3 - "$INPUT" <<'PYEOF'
import struct, sys
with open(sys.argv[1], "rb") as f:
mbr = f.read(512)
assert mbr[510:512] == b"\x55\xaa", "no MBR boot signature"
for i in range(4):
e = mbr[446 + 16*i : 446 + 16*(i+1)]
if e and e != b"\x00" * 16 and e[4] == 0x83:
lba, size = struct.unpack("<II", e[8:16])
if size:
print(lba, size)
break
else:
sys.exit("no Linux (0x83) partition")
PYEOF
) || err "MBR parse failed"
P2_SECTORS=$(echo "$P2_INFO" | cut -d' ' -f1)
P2_SIZE_SECTORS=$(echo "$P2_INFO" | cut -d' ' -f2)
P2_OFFSET=$((P2_SECTORS * 512))
P2_SIZE=$((P2_SIZE_SECTORS * 512))
WORK=$(mktemp -d "${TMPDIR:-/tmp}/ssh-validate.XXXXXX")
trap 'rm -rf "$WORK"' EXIT
# keep the scratch under $HOME on macOS: Docker Desktop bind-mounts under /tmp
# are unreliable (see pinball-assets/README.md "Gotchas learned"). If $WORK is
# under /tmp, copy it to $HOME.
case "$WORK" in
/tmp/*|/var/folders/*) WORK_HOME="$HOME/.ssh-validate.$$"; mkdir -p "$WORK_HOME"; trap 'rm -rf "$WORK" "$WORK_HOME"' EXIT; WORK="$WORK_HOME" ;;
esac
say "extracting p2 from $INPUT (read-only) -> $WORK/p2.img"
dd if="$INPUT" of="$WORK/p2.img" bs=512 skip="$P2_SECTORS" count="$P2_SIZE_SECTORS" 2>/dev/null
[ "$(wc -c < "$WORK/p2.img" | tr -d '[:space:]')" = "$P2_SIZE" ] || err "p2 extraction size mismatch"
# --- static check: deployed authorized_keys must equal the -k input -----------
say "static check: authorized_keys in the image vs -k file"
debugfs -R "dump /home/root/.ssh/authorized_keys $WORK/deployed-ak" "$WORK/p2.img" 2>/dev/null \
|| err "image has no /home/root/.ssh/authorized_keys (was it built with enable-ssh.sh?)"
# normalise: ignore trailing-newline differences
tr -d '\n' < "$WORK/deployed-ak" > "$WORK/deployed-ak.n"
tr -d '\n' < "$KEYFILE" > "$WORK/keyfile.n"
if ! cmp -s "$WORK/deployed-ak.n" "$WORK/keyfile.n"; then
err "authorized_keys in the image does not match -k ($KEYFILE)"
fi
say " authorized_keys matches"
# --- container test ------------------------------------------------------------
say "running container test ($DOCKER_IMAGE, ~a few minutes) ..."
cat > "$WORK/container-test.sh" <<'TEST'
#!/bin/sh
set -e
apt-get update -qq >/dev/null
apt-get install -y -qq openssh-client qemu-user-static e2fsprogs >/dev/null 2>&1
# extract the rootfs to a scratch tree with debugfs (no loop device needed —
# Docker Desktop for Mac does not provide one; rdump preserves symlinks and modes)
mkdir -p /mnt2
debugfs -R 'rdump / /mnt2' /work/p2.img 2>/dev/null
echo "--- rootfs extracted (scratch copy); sanity ---"
ls -la /mnt2/bin/sh /mnt2/etc/init.d/sshd /mnt2/usr/local/bin/dropbear
# chrooted ARM binaries need an explicit qemu prefix (no binfmt in the container)
cp /usr/bin/qemu-arm-static /mnt2/usr/bin/
mount --bind /dev /mnt2/dev
mount -t proc proc /mnt2/proc
# start dropbear exactly the way the machine will, via the image's own init
# script (first-boot path: generates host keys into /data/dropbear, then runs
# the daemon on port 22)
echo "--- /etc/init.d/sshd start ---"
timeout 300 chroot /mnt2 /usr/bin/qemu-arm-static /bin/sh /etc/init.d/sshd start
sleep 3
ls -la /mnt2/data/dropbear/
echo "--- pubkey login test (throwaway key, user root) ---"
ssh-keygen -t ed25519 -N '' -f /tmp/vkey -q
mkdir -p /mnt2/home/root/.ssh
cp /tmp/vkey.pub /mnt2/home/root/.ssh/authorized_keys
chmod 700 /mnt2/home/root/.ssh; chmod 600 /mnt2/home/root/.ssh/authorized_keys
if ! timeout 90 ssh -i /tmp/vkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-p 22 root@127.0.0.1 'echo LOGIN-OK as $(whoami) on $(hostname); uname -m' > /tmp/sshout 2>&1; then
echo "FAIL: pubkey login did not succeed"; cat /tmp/sshout; exit 1
fi
grep -v Warning /tmp/sshout
echo "--- password auth must be refused ---"
if timeout 30 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o PubkeyAuthentication=no -o NumberOfPasswordPrompts=0 \
-p 22 root@127.0.0.1 'echo SHOULD-NOT-PRINT' 2>/dev/null; then
echo "FAIL: password auth was accepted"; exit 1
fi
echo " (refused, as expected)"
echo "--- sftp put/get test ---"
echo hello-spike2 > /work/sftp-test.txt
if ! timeout 60 sftp -P 22 -i /tmp/vkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
root@127.0.0.1 <<'SFTP' >/dev/null 2>&1
put /work/sftp-test.txt /tmp/sftp-test.txt
get /tmp/sftp-test.txt /work/sftp-back.txt
SFTP
then
echo "FAIL: sftp session failed"
exit 1
fi
cmp /work/sftp-test.txt /work/sftp-back.txt
echo " sftp round-trip OK"
echo "=== VALIDATE-ALL-OK ==="
exit 0
TEST
chmod +x "$WORK/container-test.sh"
if ! docker run --rm --privileged \
-v "$WORK:/work:rw" \
"$DOCKER_IMAGE" \
/bin/sh /work/container-test.sh; then
err "container test failed"
fi
say "PASS: key-only sshd boots from the image's own init script, pubkey login works, password auth refused, sftp works."
say "(scratch p2 and container artifacts deleted; your image file was only read)"