Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9af6ca1
feat(hardware): add hardware/ tree for physical-device deployment
chrisguida May 9, 2026
d1ec7db
feat(hardware): harden reproducible Orange Pi image build
Vidarte-Alberto May 16, 2026
78f1ffd
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto May 18, 2026
1fc5352
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto May 20, 2026
68efac4
feat(docs): add hardware image build guide for OrangePi Zero 2W.
Vidarte-Alberto May 20, 2026
fafbe8d
feat(hardware): add multi-board image build support for OPi and RPi Z…
Vidarte-Alberto May 20, 2026
f3d5d7e
fix(hardware): add missing board argument and BOARD_SHORT_NAME valida…
Vidarte-Alberto May 20, 2026
acd67e4
fix(hardware): fix RPi first-boot wizard, partition detection, and wi…
Vidarte-Alberto May 20, 2026
b9c15b4
docs(hardware): fix outdated script name in OPi board README.
Vidarte-Alberto May 20, 2026
a11105e
docs(hardware): fix incomplete host prerequisites in board READMEs
Vidarte-Alberto May 22, 2026
11d21ad
Merge pull request #573 from olympus-btc/development
Sharmaz May 25, 2026
d707359
Merge pull request #610 from olympus-btc/development
Sharmaz Jun 21, 2026
c45d1e4
merge(development): integrate development into feat/reproducible-imag…
Vidarte-Alberto Jun 23, 2026
4f3d7c1
feat(image): add macOS Docker build wrapper and fix loop partition ha…
Vidarte-Alberto Jun 23, 2026
2f9d531
fix(image): prevent loop device number recycling conflict in expand p…
Vidarte-Alberto Jun 23, 2026
0a0750f
Merge pull request #616 from olympus-btc/development
Sharmaz Jun 23, 2026
76d2fea
Merge branch 'development' of github.com:Vidarte-Alberto/ambrosia int…
Vidarte-Alberto Jun 23, 2026
c0080e7
Merge branch 'main' of github.com:Vidarte-Alberto/ambrosia into feat/…
Vidarte-Alberto Jun 23, 2026
f0a3384
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto Jul 7, 2026
edaeb1b
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto Jul 7, 2026
37ca602
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto Jul 7, 2026
cf50d4d
docs(hardware): explain image build pipeline step by step in HOW-IT-W…
JordyPirata Jul 10, 2026
3dd896d
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto Aug 7, 2026
ebe42d6
fix(hardware): make SSH access reliable on device images
Vidarte-Alberto Aug 10, 2026
28ae72c
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto Aug 10, 2026
26d6796
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto Aug 24, 2026
90c2b6d
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto Sep 2, 2026
3a1fbde
Merge remote-tracking branch 'origin/development' into feat/reproduci…
Vidarte-Alberto Sep 2, 2026
d185def
refactor(hardware): use board.conf for board configuration
Vidarte-Alberto Sep 2, 2026
9038e43
fix(hardware): allow Docker builds to access Git and loop devices
Vidarte-Alberto Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@
CLAUDE.md
GEMINI.md
Claude.md
.claude
*PLAN.md
.codex
AGENTS.md
.claude
*PLAN.md
client/.codex

# Python cache
__pycache__/
*.py[cod]

# Image build artifacts
hardware/image/out/
8 changes: 3 additions & 5 deletions client/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Build stage
FROM node:22-slim AS builder
FROM node:24-slim AS builder
WORKDIR /app
ARG NEXT_PUBLIC_PORT_API=9154
ENV NEXT_PUBLIC_PORT_API=${NEXT_PUBLIC_PORT_API}
Expand All @@ -11,11 +11,9 @@ RUN chmod +x package-client.sh
RUN NO_ZIP=1 ./package-client.sh

# Runtime stage
FROM node:22-slim AS runtime
FROM node:24-slim AS runtime
WORKDIR /app
COPY package*.json ./
ENV NODE_ENV=production
RUN npm i --omit=dev && npm cache clean --force
COPY --from=builder /tmp/ambrosia-client-dist/ .
EXPOSE 3000
CMD ["npm", "run", "start"]
CMD ["node", "server.js"]
56 changes: 28 additions & 28 deletions client/package-client.sh
Original file line number Diff line number Diff line change
@@ -1,54 +1,54 @@
#!/bin/bash
set -e
set -euo pipefail

TAG="0.8.0-beta"
PACKAGE_NAME="ambrosia-client-dist"
CLIENT_DIST_DIR="${CLIENT_DIST_DIR:-/tmp/$PACKAGE_NAME}"

echo "=== Packaging Next.js application for distribution ==="

# 1. Clean
echo "Cleaning previous builds..."
npm run clean
rm -rf .next node_modules dist

# Verify if the dist directory exists, if not, create it
if [ ! -d "./dist" ]; then
echo "Creating dist directory in client/..."
mkdir -p ./dist
fi

# 2. Install dependencies
echo "Installing dependencies..."
npm i
# 3. Build the application
npm ci --silent

echo "Building the Next.js application..."
npm run build

# 4. Create temporary directory for the package
PACKAGE_NAME="ambrosia-client-dist"
rm -rf "/tmp/$PACKAGE_NAME"
mkdir -p "/tmp/$PACKAGE_NAME"
echo "Preparing standalone distribution tree..."
rm -rf "$CLIENT_DIST_DIR"
mkdir -p "$CLIENT_DIST_DIR/.next"
mkdir -p "$CLIENT_DIST_DIR/node_modules/@swc" "$CLIENT_DIST_DIR/node_modules"

# 5. Copy necessary files for production
echo "Copying build artifacts..."
cp -r .next "/tmp/$PACKAGE_NAME/"
cp -r public "/tmp/$PACKAGE_NAME/"
cp package.json "/tmp/$PACKAGE_NAME/"
cp package-lock.json "/tmp/$PACKAGE_NAME/"
cp next.config.mjs "/tmp/$PACKAGE_NAME/"
cp -r .next/standalone/. "$CLIENT_DIST_DIR/"
cp -r .next/static "$CLIENT_DIST_DIR/.next/"
cp -r public "$CLIENT_DIST_DIR/"

# 6. Copy installation script
echo "Copying installation script..."
# Next standalone occasionally omits runtime helpers that are still resolved
# dynamically at runtime on ARM targets. Copy the minimal helper packages
# explicitly to keep the bundle self-contained.
cp -r node_modules/@swc/helpers "$CLIENT_DIST_DIR/node_modules/@swc/"
cp -r node_modules/tslib "$CLIENT_DIST_DIR/node_modules/"

DIST_FILE="ambrosia-client-$TAG.tar.gz"

# 7. Create compressed file if not NO_ZIP=1
if [ "${NO_ZIP:-0}" != "1" ]; then
DIST_FILE="ambrosia-client-$TAG.tar.gz"
echo "Creating distribution file: $DIST_FILE..."
cd /tmp
tar -czf "$DIST_FILE" "$PACKAGE_NAME"
cd -
# 6. Clean up
mv "/tmp/$DIST_FILE" ./dist/
tar -C "$(dirname "$CLIENT_DIST_DIR")" -czf "$DIST_FILE" "$(basename "$CLIENT_DIST_DIR")"
mv "$DIST_FILE" ./dist/
fi

echo ""
echo "✅ Packaging complete!"
echo "Your distribution package is located at: client/dist/$DIST_FILE"
echo "Packaging complete!"
if [ "${NO_ZIP:-0}" = "1" ]; then
echo "Your distribution package is located at: $CLIENT_DIST_DIR"
else
echo "Your distribution package is located at: client/dist/$DIST_FILE"
fi
22 changes: 22 additions & 0 deletions hardware/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Hardware

Resources for running Ambrosia on physical devices. For software-only installs (laptop, server, any machine), see [`doc/installation.md`](../doc/installation.md).

This tree organises content by how the device got into the user's hands:

## DIY (per-board)

You bought parts and want to build a unit yourself. Today's flow is a manual narrative — flash an OS, run install steps by hand. The long-term goal is a single command that produces a **reproducible image** (e.g., via Nix), which you flash to an SD card and boot. The per-board guides below double as the spec for what that build must produce.

- [`rpi/`](rpi/) — Raspberry Pi Zero 2W
- [`opi/`](opi/) — OrangePi Zero 2W

New boards plug in here as siblings.

## Preinstalled

You received a device with Ambrosia already configured (built by an operator from one of the per-board flows above).

- [`preinstalled/`](preinstalled/) — buyer take-home flow, captive portal, and operator provisioning tooling.

A buyer can verify their preinstalled device matches the published reproducible build by hash-comparing — that's the link between the two paths, not a separate one.
162 changes: 162 additions & 0 deletions hardware/image/HOW-IT-WORKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# How the image build works

A beginner-friendly walkthrough of what `hardware/image/build/` actually does, step by step. For the commands to run a build, see [`README.md`](README.md).

## The big picture

We do **not** build an operating system from scratch. We take the board vendor's official Debian image, open it up on the build machine as if it were a real disk, install the full Ambrosia stack inside it, wipe everything that must be unique per device, and compress the result. What comes out is a flashable SD card image that configures itself on first boot.

```mermaid
flowchart LR
subgraph inputs [Inputs]
SRC["Ambrosia source\n(this repo)"]
BASE["Official vendor image\n(Debian Bookworm)"]
PHX["Phoenixd release\n(ACINQ, ARM64)"]
end

subgraph phase1 ["Phase 1 — build-artifacts.sh"]
JAR["gradlew jar\n→ ambrosia.jar"]
CLIENT["Next.js standalone build\n(cross-compiled for ARM64)"]
end

STAGING[("out/staging/\nserver/ + client/ + manifest")]

subgraph phase2 ["Phase 2 — assemble-image.sh (root)"]
ASM["Mount base image,\ninstall everything inside,\nclean unique state"]
end

subgraph outputs [Outputs in out/]
IMG["ambrosia-<board>-<version>.img.gz"]
SHA[".sha256"]
MAN[".manifest.json"]
end

SRC --> JAR --> STAGING
SRC --> CLIENT --> STAGING
STAGING --> ASM
BASE --> ASM
PHX --> ASM
ASM --> IMG & SHA & MAN
```

Two scripts, two phases:

| Phase | Script | Runs as | What it does |
|---|---|---|---|
| 1 | `build-artifacts.sh` | your user | Compiles the Kotlin server and the Next.js client into `out/staging/` |
| 2 | `assemble-image.sh` | root | Takes the vendor image + staging artifacts and assembles the final SD image |

`build-docker.sh` is just a wrapper that runs both phases inside a privileged Debian container — needed on macOS (no Linux kernel), convenient on Linux (keeps your host clean).

## Key concepts (glossary)

If these four ideas are clear, the rest of the pipeline is easy to follow:

- **Disk image (`.img`)** — a single file that is a byte-for-byte copy of an entire disk: partition table + partitions + filesystems. Flashing it with `dd` copies those bytes onto a real SD card.
- **Loop device (`losetup`)** — a Linux mechanism that makes a regular file behave like a block device (`/dev/loopN`). It lets us mount the partitions *inside* the `.img` file as if the SD card were plugged in.
- **chroot** — "change root": runs commands as if the mounted image's filesystem were `/`. That is how we run `apt-get install` *inside* the image without booting it.
- **QEMU user emulation (`qemu-aarch64-static`)** — the image contains ARM64 binaries, but the build machine is usually x86_64. Copying this emulator into the image lets the chroot execute ARM binaries transparently. Same trick, via Docker, lets Phase 1 compile the client's native ARM64 `node_modules` on an x86 host.

## Phase 0 — the board definition

Every supported board is a directory under `boards/<board-id>/` containing exactly three files:

```
boards/opi-zero-2w/
├── board.conf # which vendor image, which phoenixd version, apt repos, runtime user
├── packages.txt # flat list of Debian packages to install
└── README.md # where to download the vendor image
```

The build scripts contain **no board-specific logic** — adding a new board means adding a new directory, nothing else.

## Phase 1 — build the Ambrosia artifacts

```mermaid
flowchart TD
A["server/: ./gradlew jar"] --> B["staging/server/ambrosia.jar"]
C["client/: copy source to temp workspace"] --> D{"host CPU?"}
D -- "ARM64" --> E["run package-client.sh directly"]
D -- "x86_64" --> F["run package-client.sh inside\nnode:24-bookworm --platform linux/arm64\n(QEMU-emulated container)"]
E --> G["Next.js standalone output:\nserver.js + .next/static + minimal node_modules"]
F --> G
G --> H["sanity checks: server.js must be real\nJavaScript, not an ELF binary"]
H --> I["staging/client/ + manifest.json\n(version, commit, SHA-256)"]
```

Why the ELF check? Some `node_modules` contain native binaries. If cross-compilation silently fails, you get x86 or corrupt files where JavaScript should be — and the device won't boot the client. The build refuses to continue if it detects that.

The version label comes from `git describe --tags` (falling back to `client/package.json`), overridable with `--version`.

## Phase 2 — assemble the image

This is the interesting part. Steps run in this exact order (see the bottom of `assemble-image.sh`):

```mermaid
flowchart TD
P["1 · Prepare: decompress vendor .7z/.xz/.gz\nto a working base.img (downloads cached in out/cache/)"]
E["2 · Expand: truncate +4G, grow last partition\n(parted resizepart + resize2fs) so packages fit"]
M["3 · Mount: losetup the .img, find boot (FAT) and\nroot (ext4) partitions, mount both"]
CH["4 · Chroot setup: bind /dev /proc /sys /run,\ncopy qemu-aarch64-static in (x86 hosts)"]
PK["5 · Packages: add Adoptium + NodeSource apt repos,\ninstall everything in packages.txt inside the chroot"]
U["6 · User: create 'ambrosia' (UID 1001),\ncreate /opt/ambrosia, /etc/ambrosia, /var/lib/ambrosia"]
AR["7 · Ambrosia: rsync staging/server + staging/client\nto /opt/ambrosia/, write launch wrappers"]
PH["8 · Phoenixd: download ACINQ release (version pinned\nin board.conf), install to /usr/local/bin"]
AS["9 · Repo assets: firstboot script, Wi-Fi captive portal,\n6 systemd units, config templates, preseed example"]
EN["10 · Enable services with systemctl --root=\n(no boot required)"]
CL["11 · Clean forbidden state: SSH host keys, machine-id,\nphoenix seed, database, keystore, logs"]
V["12 · Verify: everything required present,\nnothing forbidden present — or abort"]
O["13 · Unmount, gzip, sha256, write manifest"]

P --> E --> M --> CH --> PK --> U --> AR --> PH --> AS --> EN --> CL --> V --> O
```

Two steps deserve a closer look:

**Step 3 — mounting without `-P`.** Normally `losetup -P` creates one device node per partition. Inside Docker Desktop those nodes often don't appear, so the script reads the partition table with `parted` and creates a *separate loop device per partition* using `--offset`/`--sizelimit`. Same result, container-safe.

**Step 11 — why "forbidden state" matters.** If two devices flashed from the same image shared SSH host keys, a machine ID, or — worst of all — a Phoenix wallet seed, they would be impersonatable and would share a Lightning wallet. So the image must contain *zero* device-unique state. Step 12 fails the build if any of it survives. Each device regenerates its own on first boot.

If a build dies midway, an EXIT trap unmounts everything and releases the loop devices; pass `--keep-workdir` to keep the work directory for inspection.

## What ends up inside the image

```
SD card
├── boot partition (FAT)
│ └── ambrosia-device.env.example ← preseed template
└── root partition (ext4, Debian Bookworm)
├── /opt/ambrosia/
│ ├── server/ambrosia.jar ← Kotlin/Ktor backend
│ ├── client/ ← Next.js standalone build
│ └── bin/ ← launch wrappers, firstboot, portal
├── /usr/local/bin/phoenixd ← Lightning node (+ phoenix-cli)
├── /etc/ambrosia/ ← config templates, board-identity
├── /etc/systemd/system/ ← ambrosia, ambrosia-client, phoenixd,
│ caddy, firstboot, wifi-portal
└── /home/ambrosia/ ← runtime data dirs (empty until first boot)
```

Request flow once running: **browser → Caddy (:80) → Next.js client (:3000) → Ambrosia server → Phoenixd**.

## First boot on the device

The image is generic; the device makes itself unique the first time it powers on:

```mermaid
flowchart TD
B["Power on"] --> FB["ambrosia-firstboot.service runs"]
FB --> K["Generate machine ID + SSH host keys"]
K --> PS{"Preseed file on\nboot partition?"}
PS -- yes --> AP["Apply hostname, admin password,\nWi-Fi country, locale — then archive file"]
PS -- no --> DF["Use defaults\n(auto-generated hostname)"]
AP --> INIT["Initialize phoenix.conf + ambrosia.conf\nfrom templates"]
DF --> INIT
INIT --> DONE["Mark firstboot complete\n(never runs again)"]
DONE --> W{"Known Wi-Fi\nreachable?"}
W -- no --> CAP["Start '&lt;hostname&gt;-setup' access point\n→ captive portal at http://10.42.1.1"]
W -- yes --> POS["POS live at http://&lt;hostname&gt;.local"]
CAP --> POS
```

This takes 1–3 minutes and logs to `/var/log/ambrosia-firstboot.log`.
Loading
Loading