Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
145 changes: 145 additions & 0 deletions .github/workflows/firmware.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Unit tests and firmware builds on every push and pull request. A tag `v<version>` (e.g. v2.0.0)
# additionally publishes a GitHub release with the flash images and release.json, which the
# NetIgnite server reads for its web installer and OTA updates.
#
# Optional repository secret FIRMWARE_SIGNING_KEY: a PEM ECDSA P-256 private key
# (scripts/generate_signing_key.sh). With it, releases include firmware.sig.

name: Firmware

on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
workflow_dispatch:

env:
PLATFORMIO_VERSION: "6.2.0"

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install "platformio==${PLATFORMIO_VERSION}"
- run: pio test -e native

build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # git describe for the version
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/cache@v4
with:
path: ~/.platformio
key: platformio-${{ runner.os }}-${{ hashFiles('platformio.ini') }}
- run: pip install "platformio==${PLATFORMIO_VERSION}"

- name: Version
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "NETIGNITE_FW_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
fi

- run: pio run -e esp32dev -e esp32dev-qemu
- run: python scripts/check_size.py

- name: Sign
if: startsWith(github.ref, 'refs/tags/v')
env:
FIRMWARE_SIGNING_KEY: ${{ secrets.FIRMWARE_SIGNING_KEY }}
run: |
if [ -n "$FIRMWARE_SIGNING_KEY" ]; then
printf '%s' "$FIRMWARE_SIGNING_KEY" > signing-key.pem
openssl dgst -sha256 -sign signing-key.pem -out firmware.sig .pio/build/esp32dev/firmware.bin
rm signing-key.pem
echo "SIGNATURE_ARG=--signature firmware.sig" >> "$GITHUB_ENV"
else
echo "::warning::FIRMWARE_SIGNING_KEY isn't set; the release is unsigned."
fi

- name: Collect
run: |
python scripts/collect_release.py "${NETIGNITE_FW_VERSION:-dev}" release ${SIGNATURE_ARG:-}
cp .pio/build/esp32dev/firmware.factory.bin release/firmware-factory.bin

- uses: actions/upload-artifact@v4
with:
name: firmware
path: release/
retention-days: 14

- uses: actions/upload-artifact@v4
with:
name: firmware-qemu
path: |
.pio/build/esp32dev-qemu/firmware.factory.bin
.pio/build/esp32dev-qemu/firmware.bin
retention-days: 3

# Boots the firmware in Espressif's QEMU and tests Improv, the console and OTA updates
# (test/emulator).
emulator:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 45
env:
QEMU_URL: https://github.com/espressif/qemu/releases/download/esp-develop-9.2.2-20260417/qemu-xtensa-softmmu-esp_develop_9.2.2_20260417-x86_64-linux-gnu.tar.xz
QEMU_SHA256: 0eecb2a34a5586c0e59110f77b9343b7b336e82fdb0e1a30e1dc1bab8a547e35
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: firmware-qemu
path: qemu-firmware
- uses: oven-sh/setup-bun@v2
- name: QEMU
run: |
sudo apt-get update && sudo apt-get install -y libsdl2-2.0-0 libslirp0
curl -sSL -o qemu.tar.xz "$QEMU_URL"
echo "$QEMU_SHA256 qemu.tar.xz" | sha256sum -c -
tar -xf qemu.tar.xz
- name: Improv and console
working-directory: test/emulator
run: |
bun install --frozen-lockfile
bun improv-test.ts "$GITHUB_WORKSPACE/qemu/bin/qemu-system-xtensa" "$GITHUB_WORKSPACE/qemu-firmware/firmware.factory.bin" "$RUNNER_TEMP"
- name: OTA updates
working-directory: test/emulator
run: |
bun ota-test.ts "$GITHUB_WORKSPACE/qemu/bin/qemu-system-xtensa" "$GITHUB_WORKSPACE/qemu-firmware/firmware.factory.bin" "$GITHUB_WORKSPACE/qemu-firmware/firmware.bin" "$RUNNER_TEMP"
- uses: actions/upload-artifact@v4
if: failure()
with:
name: emulator-serial-log
path: |
${{ runner.temp }}/improv-transcript.log
${{ runner.temp }}/ota-transcript.log
retention-days: 7

# Tags only, after the unit, build and emulator checks passed.
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [build, emulator]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: firmware
path: release
- uses: softprops/action-gh-release@v2
with:
files: release/*
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
17 changes: 13 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@
.vscode/launch.json
.vscode/ipch

src/config/*
!src/config/*sample*
# Generated at build time by data/cert/run_gen_script.py
*x509_crt_bundle.bin

# Release output (scripts/collect_release.py)
release/

# Never commit signing keys or other secrets
*signing-key*.pem
*secret*
!*secrets.hpp.template
*x509_crt_bundle.bin

# Local leftovers of the v1 Electron installer (removed in v2; replaced by the web installer)
/installer/

# v1 configuration (compiled-in Wi-Fi password and agent secret). v2 keeps settings in NVS.
src/config/
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Changelog

## 2.0.0 (2026-10-21)

The first stable release of the ESP32 firmware v2, for NetIgnite server 2.0.0. No changes since
2.0.0-beta.2; everything it can do is listed below.

### Upgrading from v1

Boards with the v1 firmware (built with PlatformIO and `config.h`) can't connect to a v2 server.
Install 2.0.0 once over USB with the dashboard's web installer (the agent's **Install** tab, in
Chrome, Edge or Firefox 151+): the partition table changed for updates over the network. Later
versions install over the network.

## 2.0.0-beta.2 (2026-10-21)

Needs NetIgnite server 2.0.0-beta.2 or newer for updates, the console and SSH.

### New

- **Firmware updates over the network** (`OTA_BEGIN`): the agent downloads the image from its
server, checks size and SHA-256 and, if the firmware has a public key compiled in
(`keys/firmware-signing.pub.pem`), the ECDSA P-256 signature. The new firmware is on probation
until it reaches the server; if it crashes or doesn't get there within 5 minutes, the previous
firmware starts again and the agent reports the failed version.
- **Console** for the dashboard: the last 16 KB of log lines (including ESP-IDF's messages), a live
log stream, current diagnostics, log level, server address, host name and IP settings (a new
server address or IP setting is only saved once the agent reaches the server with it), restart
and factory reset.
- **SSH logins** to devices in the agent's network (libssh): status checks that only authenticate,
and shutdown/reboot with a trusted host key and fixed commands.
- The web installer can set the agent's name.

### Changed

- Enrollment sends the agent kind; a token of a Linux agent is rejected without being used up.
- Firmware size with libssh: 79% of an OTA slot.

### Tests

- The emulator tests install updates over the serial port and check signature and SHA-256
rejection, probation and rollback in QEMU; CI runs them.

## 2.0.0-beta.1 (2026-10-20)

Firmware v2 for NetIgnite server v2. Flash it once over USB (or with the server's web installer):
the partition table changed for over-the-air updates.

- Settings in NVS instead of a compiled-in `config.h`: Wi-Fi (Improv or the serial console),
server URL, enrollment token, static IP, name, log level.
- Enrollment with a single-use token; the agent generates its own secret.
- Agent protocol v2: Wake-on-LAN, status checks by IPv6 link-local (from the MAC), IPv4, IPv6,
host name and TCP port.
- Serial console commands (`help`, `status`, `setup`, `wifi`, `server`, `enroll`, `ip`, `name`,
`log`, `reboot`, `factory-reset`).
- Two 1.875 MB app slots; pioarduino (Arduino-ESP32 3.3, ESP-IDF 5.5).
- Releases with the flash images and `release.json` for the server's web installer.
128 changes: 128 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# NetIgnite ESP32 Agent

Firmware that turns an ESP32 into a [NetIgnite](https://netignite.dev) agent: it joins your Wi-Fi,
keeps a connection to your NetIgnite server, sends Wake-on-LAN packets, checks whether devices in
its network are online and can shut them down over SSH. It updates itself over the network.

> **v2 (in development).** Works with NetIgnite server v2. Configuration no longer lives in
> `config.h`: the agent is set up from the browser with the server's web installer, or over the
> serial port.

## Hardware

- A classic ESP32 board (PlatformIO `esp32dev`, 4 MB flash).
- 2.4 GHz Wi-Fi (WPA/WPA2/WPA3-Personal).
- In the same network (subnet) as the devices it wakes: Wake-on-LAN broadcasts don't cross routers.

## Set up an agent

The easiest way is the server's **web installer**: in the NetIgnite dashboard, **Agents → Add
agent → ESP32**, then the agent's **Install** tab. It flashes the firmware from the browser
(Chrome, Edge or Firefox 151+, over HTTPS) together with an NVS image that holds the server URL,
the agent's name and a single-use enrollment token, and then sends your Wi-Fi settings to the
ESP32 with [Improv Wi-Fi](https://www.improv-wifi.com/serial/). The Wi-Fi password never reaches
the server. The installer also changes the Wi-Fi of an installed agent.

Without the installer, over the serial port:

1. In the NetIgnite dashboard: **Agents → Add agent → ESP32**.
2. Flash the firmware (see [Build and flash](#build-and-flash)) and open the serial console
(115200 baud, e.g. `pio device monitor`).
3. Connect to Wi-Fi:
```text
wifi "My Network" "my password"
```
4. On the agent's page in the dashboard, click **Create setup command** and paste it:
```text
setup https://app.netignite.dev netignite_enroll_…
```

The agent enrolls once with that single-use token. It generates its own secret with the hardware
random number generator; the server only stores a hash of it, and it is never shown or printed.

## Console commands

| Command | |
| --- | --- |
| `status` | Firmware, network, server and connection state |
| `setup <server-url> <enrollment-token>` | Connect to a NetIgnite server |
| `wifi <ssid> [password]` | Connect to a network; kept only if the connection works (quote names with spaces) |
| `server <url>` | Change the server address |
| `enroll <enrollment-token>` | Set the agent up again with a new token |
| `ip dhcp` / `ip static <ip> <gateway> <netmask> [dns1] [dns2]` | IPv4 configuration (DHCP is the default) |
| `name <device-name>` | Host name |
| `log <error\|warn\|info\|debug>` | Log level |
| `reboot` | Restart |
| `factory-reset confirm` | Erase Wi-Fi, server and credentials |

## What the agent does

It connects to `<server>/ws/agent` with a WebSocket (`wss://` for `https://` servers) and speaks
NetIgnite protocol v2 (JSON frames; see `lib/netignite_core/src/netignite/Protocol.h`):

- `WAKE`: sends the magic packet 3 times to the subnet broadcast address and `255.255.255.255`.
- `STATUS`: pings the device (IPv4, IPv6, or the IPv6 link-local address derived from its MAC
address) and/or tries a TCP port. A refused TCP connection also counts as online. Host names
resolve to IPv4 and IPv6 addresses.
- `PING`: keep-alive. Without any message from the server for 100 s, the agent reconnects.
- `OTA_BEGIN`: downloads a firmware image from its server (with its own credential), checks its
size and SHA-256 and, if the firmware has a public key compiled in, its signature, writes it to
the other app slot and restarts. The new firmware is on probation until it reaches the server;
if it crashes or doesn't get there within 5 minutes, the bootloader starts the previous firmware
again, and the agent reports the failed version in its HELLO.
- `GET_LOGS`, `LOG_SUBSCRIBE`: the last 16 KB of log lines (also ESP-IDF's own messages), and new
lines as `LOG` events, for the dashboard's console.
- `CONFIG_SET`: server address, host name, log level and IP settings. A new server address or IP
setting is tried first and only saved once the agent reaches the server with it; otherwise the
agent restarts with the previous settings after 2 minutes.
- `SSH_CHECK`, `SSH_EXEC`: logs in to a device in its network over SSH (libssh) with the login the
server stores for that device. A check only authenticates and never opens a session; a command
needs the trusted host key and runs only `[sudo -n ]shutdown -h|-r now` or
`shutdown /s|/r /t 0`. The password or key is wiped from memory after each login.
- `GET_INFO`, `REBOOT`, `FACTORY_RESET`.

HTTPS servers need a certificate from a public CA. For a private CA, put its certificate (`.pem`
or `.der`) into `data/cert/custom/` before building.

## Build and flash

Requires [PlatformIO](https://platformio.org) Core **6.2 or newer** (`pio upgrade`). The build
uses [pioarduino](https://github.com/pioarduino/platform-espressif32) (Arduino-ESP32 3.3,
ESP-IDF 5.5), pinned in `platformio.ini`.

```bash
pio run -e esp32dev # build
pio run -e esp32dev -t upload # flash over USB
pio device monitor # serial console
```

The partition table (`partitions.csv`) has two 1.875 MB app slots for over-the-air updates. If the
board ran v1 firmware, flash v2 once over USB.

## Tests

```bash
pio test -e native # parsers and encoders, on your computer (needs gcc)
python scripts/check_size.py # the firmware must stay below 85 % of an OTA slot
```

`test/emulator/` boots the firmware in Espressif's QEMU and tests Improv and the console with the
same client ESP Web Tools uses, and OTA updates (signature checks, probation, rollback) with images
sent over the serial port. QEMU has no Wi-Fi, so networking needs a real board.

## Releases

Pushing a tag `v<version>` runs `.github/workflows/firmware.yml`: unit tests, build, size check and
emulator test, then a GitHub release with `bootloader.bin`, `partitions.bin`, `boot_app0.bin`,
`firmware.bin`, a merged `firmware-factory.bin` (flash at `0x0`) and `release.json`, which
describes the parts for the NetIgnite server's web installer.

Releases are signed when the repository secret `FIRMWARE_SIGNING_KEY` is set; create the key pair
with `sh scripts/generate_signing_key.sh` and commit only the public key
(`keys/firmware-signing.pub.pem`). Once that file exists, `scripts/signing_key.py` compiles it into
the firmware, and the agent only installs OTA updates with a valid signature. Set the same public
key as `NETIGNITE_FIRMWARE_PUBLIC_KEY` on the server, so it only offers signed releases.

## License

[AGPL-3.0](LICENSE)
8 changes: 6 additions & 2 deletions data/cert/run_gen_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
import os
import shutil
import subprocess
import sys
import tempfile

Import("env") # noqa: F821 - provided by PlatformIO (SCons)

PYTHON = env.subst("$PYTHONEXE") # noqa: F821
# The interpreter running this script: the cryptography check below and the generator must use the
# same one (pioarduino runs the build in its own environment, separate from $PYTHONEXE).
PYTHON = sys.executable
CERT_DIR = os.path.join(env.subst("$PROJECT_DIR"), "data", "cert") # noqa: F821
CUSTOM_DIR = os.path.join(CERT_DIR, "custom")
BUNDLE_PATH = os.path.join(CERT_DIR, "x509_crt_bundle.bin")
Expand Down Expand Up @@ -49,7 +52,8 @@ def generate_bundle():
subprocess.check_call(
[PYTHON, os.path.join(CERT_DIR, "gen_crt_bundle.py"), "--quiet", "--input", *certificate_inputs()],
cwd=work_dir,
env=dict(os.environ, PYTHONWARNINGS="ignore"),
# The same import path as this process (pioarduino adds its own packages to it).
env=dict(os.environ, PYTHONWARNINGS="ignore", PYTHONPATH=os.pathsep.join(p for p in sys.path if p)),
)
with open(os.path.join(work_dir, "x509_crt_bundle"), "rb") as f:
bundle = f.read()
Expand Down
Loading
Loading