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:
@@ -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 |
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
+122
@@ -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."
|
||||
Executable
+337
@@ -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"
|
||||
Executable
+36
@@ -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
|
||||
Executable
+160
@@ -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)"
|
||||
Reference in New Issue
Block a user