From 2ab38d96a1d1fefacd88417361e9f74b082eaf9b Mon Sep 17 00:00:00 2001 From: wudgexyz Date: Tue, 13 Oct 2026 22:45:53 +0300 Subject: [PATCH 1/2] Import cerberus, the on-camera agent, with its own CI and releases The agent moves out of the cloud monorepo into a repository of its own: the C source, its Makefile, the BusyBox init script and the two installers that ship to cameras. Builds: ci/build.sh packages musl-armv7 (static, for OpenIPC), armhf, arm64 and amd64 in containers, with -Werror and an ELF-architecture assertion per target. The version compiled into the binary now comes from the v* tag (make VERSION=...), so a tarball and the agent inside it never disagree. Tests: tests/run.sh runs the agent against a fake coordinator over real TLS - identity, TLS verification, enrolment, the heartbeat back-channel, start-up backoff, revocation - and then brings up a real kernel WireGuard tunnel to a hub in its own netns, including a stolen route the in-tunnel probe has to notice and repair. It runs on Alpine (musl, mbedTLS 3.x) and Debian bookworm (glibc, mbedTLS 2.28), the two generations found in firmware. CI: shellcheck, both test legs and the build matrix on every push and pull request; a v* tag publishes the tested tarballs as a GitHub release. S99cerberus now quotes the values it reads from the U-Boot environment: an unquoted export word-splits in BusyBox ash, so a hostname with a space broke the start script. --- .editorconfig | 9 + .gitattributes | 1 + .github/workflows/ci.yml | 113 +++ .gitignore | 4 + Makefile | 62 ++ README.md | 304 +++++++- README.ru.md | 301 ++++++++ S99cerberus | 54 ++ bootstrap.sh | 80 ++ ci/build.sh | 216 ++++++ main.c | 1469 +++++++++++++++++++++++++++++++++++++ tests/fake_coordinator.py | 176 +++++ tests/run.sh | 27 + tests/suite.sh | 365 +++++++++ uninstall.sh | 32 + 15 files changed, 3212 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.ru.md create mode 100755 S99cerberus create mode 100755 bootstrap.sh create mode 100755 ci/build.sh create mode 100644 main.c create mode 100755 tests/fake_coordinator.py create mode 100755 tests/run.sh create mode 100755 tests/suite.sh create mode 100755 uninstall.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d9bc4cc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +insert_final_newline = true +[*.{yml,yaml,json}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9cdb249 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,113 @@ +name: ci + +on: + push: + branches: [master] + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + name: shellcheck + runs-on: ubuntu-latest + # The init script and the installers run under BusyBox ash on the camera, where nobody watches + # them fail. SC2155 is off: `export X="$(fw_printenv ...)"` masking the exit status is the point — + # a key that is not set is simply empty. + steps: + - uses: actions/checkout@v4 + - run: shellcheck -S warning -e SC2155 S99cerberus bootstrap.sh uninstall.sh ci/build.sh tests/run.sh tests/suite.sh + + test: + name: tests (${{ matrix.name }}) + runs-on: ubuntu-latest + # Both mbedTLS generations are in the field: 3.x on current distros and in Alpine (musl, which is + # what OpenIPC is), 2.28 LTS in Buildroot-era firmware — and Debian bookworm carries exactly that. + # The suite builds with -Werror, enrols against a fake coordinator over real TLS, and brings up a + # real WireGuard tunnel to a hub in its own netns; REQUIRE_WG turns a runner without kernel + # WireGuard into a failure instead of a silently skipped section. ubuntu-latest has it. + strategy: + fail-fast: false + matrix: + include: + - name: musl, mbedTLS 3.x + image: alpine:3.20 + - name: glibc, mbedTLS 2.28 + image: debian:bookworm-slim + steps: + - uses: actions/checkout@v4 + + - name: Test + run: tests/run.sh + env: + CERBERUS_TEST_IMAGE: ${{ matrix.image }} + REQUIRE_WG: '1' + + build: + name: build (${{ matrix.arch }}) + runs-on: ubuntu-latest + # The real target is arm; a compile-only x64 job would keep reporting green while the binary the + # firmware needs does not exist. musl-armv7 is the artefact that actually goes into OpenIPC + # firmware and builds in an emulated armv7 Alpine, so it is the one target here that needs binfmt. + # The ELF-architecture assertion lives in the script, next to the build that produced the binary. + strategy: + fail-fast: false + matrix: + arch: [musl-armv7, armhf, arm64, amd64] + steps: + - uses: actions/checkout@v4 + with: + # git describe needs the tags: the version compiled into the binary comes from them. + fetch-depth: 0 + + - name: Enable arm emulation + if: matrix.arch == 'musl-armv7' + uses: docker/setup-qemu-action@v3 + with: + platforms: arm + + - name: Build and package + run: ci/build.sh ${{ matrix.arch }} + + - uses: actions/upload-artifact@v4 + with: + name: cerberus-${{ matrix.arch }} + path: dist/*.tar.gz* + if-no-files-found: error + + release: + name: release + # A v* tag publishes the tarballs that were just built and tested — the same artefacts, not a + # rebuild — as a GitHub release. + if: startsWith(github.ref, 'refs/tags/v') + needs: [lint, test, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: cerberus-* + path: dist + merge-multiple: true + + - name: Every tarball carries the tag's version + run: | + want="${GITHUB_REF_NAME#v}" + ls dist + for f in dist/*.tar.gz; do + case "$(basename "$f")" in + "cerberus-$want-linux-"*) ;; + *) echo "$f was not built as $want"; exit 1 ;; + esac + done + + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" dist/* \ + --repo "$GITHUB_REPOSITORY" --title "cerberus ${GITHUB_REF_NAME#v}" --generate-notes --verify-tag diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..456377a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# build output +/cerberus +/dist/ +*.o diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2883e61 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +# cerberus — on-camera agent. mbedTLS is the only dependency; everything else is libc. +# +# Native build: make +# Static binary: make STATIC=1 +# Cross (OpenIPC): make CC=arm-openipc-linux-musleabi-gcc MBEDTLS=/path/to/staging STATIC=1 +# Release version: make VERSION=1.2.0 (otherwise the version in main.c) +# Tests: make test (needs Docker; see tests/run.sh) +# +# MBEDTLS points at a prefix holding include/mbedtls and lib/libmbed*. Leave it empty when the +# toolchain already sees them (a distro's mbedtls-dev, or a Buildroot sysroot). + +CC ?= cc +STRIP ?= strip +DESTDIR ?= +PREFIX ?= /usr +MBEDTLS ?= +VERSION ?= + +CFLAGS ?= -Os -std=c99 -Wall -Wextra -ffunction-sections -fdata-sections +LDFLAGS ?= -Wl,--gc-sections +LIBS = -lmbedtls -lmbedx509 -lmbedcrypto + +# CI adds -Werror here without having to restate the defaults above. +CFLAGS += $(EXTRA_CFLAGS) + +ifneq ($(VERSION),) +CFLAGS += -DCERBERUS_VERSION=\"$(VERSION)\" +endif + +ifneq ($(MBEDTLS),) +CFLAGS += -I$(MBEDTLS)/include +LDFLAGS += -L$(MBEDTLS)/lib +endif + +ifeq ($(STATIC),1) +LDFLAGS += -static +endif + +BIN = cerberus + +all: $(BIN) + +$(BIN): main.c + $(CC) $(CFLAGS) -o $@ main.c $(LDFLAGS) $(LIBS) + $(STRIP) $@ + @echo "==> $@: $$(wc -c <$@) bytes" + +size: $(BIN) + @size $(BIN) || true + @ls -l $(BIN) + +install: $(BIN) + install -D -m 0755 $(BIN) $(DESTDIR)$(PREFIX)/sbin/$(BIN) + install -D -m 0755 S99cerberus $(DESTDIR)/etc/init.d/S99cerberus + +test: + tests/run.sh + +clean: + rm -f $(BIN) + +.PHONY: all size install test clean diff --git a/README.md b/README.md index 8fd71ce..84bb5a3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,304 @@ # cerberus -Cerberus - cloud agent for OpenIPC + +[Русская версия](README.ru.md) + +The cloud agent for OpenIPC cameras: a small C daemon that lives **inside the camera firmware** and +puts the camera on a private WireGuard overlay, so it can be reached from anywhere — video, ONVIF, +settings and SSH — with nothing between it and the cloud but the tunnel. + +It does five things and nothing else: + +1. generates the device's X25519 keypair and a claim code — once, on first start; +2. enrols at the coordinator over TLS (`POST /enroll`) and takes the join material; +3. keeps key, claim code and join material in the **U-Boot environment** via `fw_setenv` (a file is + the fallback) — the one place on a camera that survives a firmware reflash; +4. brings up `wg0` from it and supervises the tunnel from both ends: the handshake age from `wg` + **and** an ICMP probe of the hub *inside* the tunnel — a fresh handshake over a dead path looks + alive; +5. reports liveness with `POST /nodes/{id}/heartbeat` and applies what comes back in the answer: + changed join material and one-shot commands. + +**mbedTLS** (TLS, X25519, SHA-256, base64, CTR-DRBG) is the only dependency; everything else is libc. +It drives the kernel WireGuard the firmware already ships, so it is tens of kilobytes rather than a +userspace WireGuard implementation. + +## Installing on a camera + +The usual way is one line over SSH, with an enrolment token from the cloud panel: + +```sh +curl -fsSL https://cloud.openipc.ru/install.sh | sh -s -- tok_... +``` + +The installer (`bootstrap.sh` here) asks the camera for its processor (`fw_printenv -n soc`), fetches +the matching build, installs `/usr/sbin/cerberus` and `/etc/init.d/S99cerberus`, writes the address +and the token into the U-Boot environment and starts the agent. On failure it prints a short code — +`cerberus: E13` — and the page that decodes it: +. `uninstall.sh` takes all of it off again. + +By hand, from a release tarball: + +```sh +tar xzf cerberus--linux-musl-armv7.tar.gz && cd cerberus--linux-musl-armv7 +./install.sh # /usr/sbin/cerberus + /etc/init.d/S99cerberus +fw_setenv streq_api https://cloud-api.openipc.ru/v1 +fw_setenv streq_enrollment_token tok_... +reboot # or: /etc/init.d/S99cerberus restart +``` + +The init script is BusyBox, not systemd: `S99cerberus` reads its config out of the U-Boot env and +starts `cerberus run` with `start-stop-daemon`. Updating is replacing the binary and restarting the +service; key, node id and claim code live in the U-Boot env and survive it. + +A token minted in the panel for one camera binds the camera to whoever minted it. With a batch token +the agent prints a claim code (also shown by `cerberus status`) that is entered in the panel to +claim the camera. Until it is claimed the camera is on the overlay, but nobody can watch or command +it. + +### What the camera needs + +| needed | for | without it | +|---|---|---| +| kernel WireGuard + `wg` | the whole data plane | the agent is useless | +| `ip` | address and link on `wg0` | same | +| `fw_setenv` / `fw_printenv` | storage that survives a reflash | falls back to a file (which does not) | +| shared mbedTLS | TLS, X25519, SHA-256 | use a static build | +| `ping` | the in-tunnel probe | the probe is switched off with a warning; the handshake watch stays | +| `reboot` | the `Reboot` command | the command is logged as impossible | + +## Building + +```sh +make # native, against the system mbedTLS +make STATIC=1 # static +make VERSION=1.2.0 # the version the agent reports (default: the one in main.c) +make install DESTDIR=/tmp/rootfs # /usr/sbin/cerberus + /etc/init.d/S99cerberus +``` + +It builds against both mbedTLS generations in the field: 3.x and 2.28 LTS — the latter is what +Buildroot, and so most OpenIPC-era firmware, carries. + +Release tarballs are built in containers; the host needs only Docker: + +```sh +ci/build.sh musl-armv7 # OpenIPC on ARMv7: static musl, no dependencies at all +ci/build.sh armhf arm64 amd64 # generic SBCs (glibc, dynamic) + # → dist/cerberus--linux-.tar.gz (+ .sha256) +``` + +**A glibc build does not start on OpenIPC at all.** The firmware is musl: a glibc binary asks for a +loader (`/lib/ld-linux-armhf.so.3`) that is not there, and the shell answers `not found` about a file +that is right in front of you. Hence the separate `musl-armv7` build. It runs inside an emulated +armv7 Alpine (binfmt/QEMU — Docker Desktop has it; on plain Linux run +`docker run --privileged --rm tonistiigi/binfmt --install arm` once), so no cross toolchain is +needed: the libc is the one the firmware runs. It is static, with mbedTLS inside, so it does not +matter which mbedTLS a given firmware carries, or whether it carries one at all. + +Check the camera before picking a build: + +```sh +uname -m # armv7l → musl-armv7 fits; mips/mipsel → it does not +grep -i features /proc/cpuinfo | head -1 # needs vfp: ARMv5 without it cannot run armv7 code +ls /lib/ld-musl-* 2>/dev/null # confirms musl firmware +ls /usr/lib/libmbedtls* 2>/dev/null # firmware has mbedTLS → can link dynamically +``` + +Ingenic T20/T31 are MIPS, and ARMv5 SoCs have no VFP; both need the firmware's own Buildroot +toolchain: + +```sh +make CC=mipsel-openipc-linux-musl-gcc MBEDTLS= STATIC=1 +# or, when the firmware has a matching mbedTLS, without STATIC=1 — tens of KB instead of hundreds +``` + +`STATIC=1` is only sane with musl: static glibc still needs the shared NSS libraries at runtime for +`getaddrinfo` — exactly the call that resolves the hub. + +### Size + +`-Os`, `--gc-sections`, stripped; measured from the `ci/build.sh` tarballs: + +| target | bytes | +|---|---| +| armv7, musl, static — OpenIPC | 538 080 | +| armv7, glibc, dynamic (armhf) | 26 568 | +| arm64, glibc, dynamic | 68 568 | +| x86-64, glibc, dynamic | 44 120 | + +The agent itself is the dynamic armv7 number. Nearly all of the static build is mbedTLS as Alpine +builds it — server, DTLS, RSA and every cipher suite. Firmware builds mbedTLS with its own config +(TLS client, ECDHE + AEAD, X.509), and a static agent against that is a fraction of the size. + +At runtime it is one process, one thread, about 1.4 MB RSS — mostly mbedTLS buffers during the TLS +handshake. Each heartbeat is a new TLS connection, ~4 KB on the wire, so the default 20 seconds is +~17 MB a day; `STREQ_HEARTBEAT=60` divides that by three on a metered link, at the price of commands +and rotations arriving that much later. + +## Tests + +```sh +make test # Alpine: musl + mbedTLS 3.x +CERBERUS_TEST_IMAGE=debian:bookworm-slim tests/run.sh # glibc + mbedTLS 2.28 +``` + +The suite needs only Docker. It builds the agent with `-Werror` and runs it against a fake +coordinator (`tests/fake_coordinator.py`) over real TLS with its own CA: + +- **identity** — the X25519 key is the one `wg pubkey` derives, it lives in the store and nowhere + else, the store is 0600, a legacy key file is migrated in unchanged; +- **TLS** — an unrelated CA and a certificate for another name are both refused before a byte of + the request leaves; +- **enrolment** — the request carries the public key and `sha256(claim code)`, never the code + itself; the join material is persisted; a second enrol spends nothing; +- **the back-channel** — `Restart` re-execs the agent, `Reboot` reboots, unknown commands are + ignored, a moved or re-keyed hub is picked up and the interface reprogrammed, a coordinator outage + is one warning and is survived, a revoked node discards its enrolment and stops; +- **start-up** — an unreachable coordinator is waited out with backoff, and SIGTERM during the + pause is obeyed at once; +- **the data plane** — a real kernel WireGuard tunnel to a hub in its own network namespace: + handshake, ping through the tunnel, and a stolen route noticed by the in-tunnel probe and repaired + by rebuilding the interface; +- **the shipped scripts** parse, and the installers carry no comments. + +The data-plane part needs kernel WireGuard on the host (any current Linux, Docker Desktop's VM) and +is skipped without it; `REQUIRE_WG=1` makes that a failure instead, which is how CI runs it. + +## CI and releases + +`.github/workflows/ci.yml` runs shellcheck, the suite on both mbedTLS generations, and builds the +four tarballs on every push and pull request. Pushing a `v*` tag publishes those same tarballs, with +their `.sha256` files, as a GitHub release; the version compiled into the binary comes from the tag. + +```sh +git tag v0.2.0 && git push origin v0.2.0 +``` + +## Running + +Configuration is `STREQ_*` environment variables; `S99cerberus` fills them from the U-Boot env. + +| variable | default | meaning | +|---|---|---| +| `STREQ_API` | `https://api.localhost/v1` | coordinator base URL, including `/v1` | +| `STREQ_ENROLLMENT_TOKEN` | — | one-shot token from the owner | +| `STREQ_KIND` | `Camera` | node kind | +| `STREQ_HOSTNAME` | — | the name the camera shows up under in the panel | +| `STREQ_IFACE` | `wg0` | WireGuard interface | +| `STREQ_SUBNET` | `10.80.0.0/16` | fallback: the network's subnet and the hub's address arrive with the join material | +| `STREQ_CA` | `/etc/ssl/certs/ca-certificates.crt` | CA file or directory for TLS | +| `STREQ_INSECURE` | `0` | skip TLS verification (debugging only) | +| `STREQ_STORE` | `auto` | `auto` \| `uboot` \| `file:/path` — the device key lives here too | +| `STREQ_KEYFILE` | `/etc/streq/wg.key` | legacy key location; migrated into the store on sight | +| `STREQ_KEY_TMP` | `/tmp/.streq-wg.key` | where the key is materialised for one `wg set` call | +| `STREQ_HEARTBEAT` | `20` | seconds between heartbeats; `0` disables | +| `STREQ_PING_TARGET` | the hub | in-tunnel probe target | +| `STREQ_PING_INTERVAL` | `60` | seconds between probes; `0` disables | +| `STREQ_PING_TIMEOUT` | `3` | seconds one probe waits | +| `STREQ_PING_FAILS` | `3` | consecutive failed probes before the interface is rebuilt | +| `STREQ_STALE_AFTER` | `180` | seconds without a handshake before the peer is re-poked | +| `STREQ_RETRY_MIN` / `STREQ_RETRY_MAX` | `5` / `300` | enrolment retry backoff, seconds | +| `STREQ_DRY_RUN` | `0` | print the `ip`/`wg` commands instead of running them | + +The U-Boot keys `S99cerberus` reads: `streq_api`, `streq_enrollment_token`, `streq_hostname`, +`streq_ca`, `streq_subnet`, `streq_ping_target`, `streq_ping_interval`, `streq_iface` (all but the +first two optional; empty means default). `streq_ca` matters more than it looks: a firmware may +carry no CA bundle at all, and then enrolment fails on the coordinator's certificate with no way to +fix it short of a reflash — point it at the root certificate (ISRG Root X1 for Let's Encrypt). + +``` +cerberus run # enroll + up + watch (the default; what S99cerberus starts) +cerberus enroll # enroll only, idempotent +cerberus up # bring the interface up from the stored material +cerberus status # what is stored, handshake age, whether the tunnel answers +cerberus keygen # print the keypair (stdout is data, diagnostics go to stderr) +cerberus clean # remove the agent: interface, init script, stored material, the binary +``` + +## What lives where + +`streq_device_key`, `streq_node_id`, `streq_overlay_ip`, `streq_hub_endpoint`, `streq_hub_pubkey`, +`streq_allowed_ips`, `streq_network_subnet`, `streq_hub_ip`, `streq_claim_code` — all in the U-Boot +environment. + +**The key is there too, deliberately.** A separate 0600 file would be the obvious place, but on a +camera `/etc` is squashfs or an overlay on a partition a reflash overwrites, while `streq_node_id` +next to it in the U-Boot env survives. The node would come back with an identity the hub has a peer +for and no key to prove it — and enrolment is skipped whenever a node id is stored. That is not +"re-enrols", it is *stuck for good*. Key and node id have to live and die together, so they share a +store. `wg set private-key` takes a path, so for exactly one call the key is materialised in `/tmp` +(0600) and deleted straight after; it is never written to the rootfs. + +`streq_network_subnet` and `streq_hub_ip` arrive with enrolment: every network on a multi-tenant +instance has its own range, and the node takes its address mask and a separate route to the hub +from them. The route matters because WireGuard's `allowed-ips` is cryptokey routing, not the +kernel's routing table: without it the handshake succeeds and the packets go around the tunnel. + +## The control plane: REST, not the hub + +Full nodes hold a SignalR connection to the coordinator and get the network map pushed to them. A C +client for that is not this kind of program, so thin nodes take the degraded path: + +| hub | what cerberus does | +|---|---| +| `Heartbeat(nodeId)` | `POST /nodes/{id}/heartbeat` every `STREQ_HEARTBEAT` seconds | +| `DirectiveIssued` (push) | the answer to that same heartbeat — the same `NodeDirective` | +| `JoinNetwork(nodeId)` → map | `GET /nodes/{id}/map` exists, but the agent does not poll it | +| `NetworkMapUpdated` / `PathRevoked` | no pushes | + +A camera does not need the map: it has exactly one peer, the hub, and that arrives with the join +material. A `404` on the heartbeat means the coordinator no longer knows the node (revoked or +rebuilt): the agent drops the enrolment from the store and stops, so the next start enrols afresh +instead of knocking with a dead node id forever. + +### The back-channel: the heartbeat answer + +```jsonc +{ + "command": "None", // None | Restart | Reboot — one-shot, delivered exactly once + "join": { // the current join material, always whole, never a delta + "kind": "Wireguard", "overlayIp": "10.80.0.7", + "hubEndpoint": "hub.example:51820", "hubPublicKey": "base64…", "allowedIps": "10.80.0.7/32" + }, + "mapVersion": 42 +} +``` + +The agent compares `join` with what it has stored and applies **only the difference**: when the hub +moved (a new VPS, a new DNS name) or changed its key, the camera rewrites its store and reprograms +the interface by itself. `Restart` re-execs the agent (there may be no supervisor on the camera); +`Reboot` is `sync` and `reboot`. + +### Supervising the tunnel + +The handshake and the probe answer different questions, and neither follows from the other: +WireGuard re-handshakes about every two minutes and shows a perfectly fresh one while nothing at all +crosses the tunnel — a route someone took, an interface somebody else reused, a hub that completes +handshakes and forwards nothing. The handshake says the peer is there; only a packet that comes back +says the tunnel works. + +The recovery ladder goes up in cost, and the camera has to climb it alone: + +| what is seen | what is done | +|---|---| +| handshake older than `STREQ_STALE_AFTER` | the peer is re-poked (which also re-resolves the hub's name) | +| `STREQ_PING_FAILS` probes in a row unanswered | the interface is torn down and rebuilt | +| twice that — the rebuild did not help | the agent re-execs itself | + +A camera that reboots over one lost packet is worse than one that just stays down, so thresholds +count runs, not events. + +Start-up is separate. `cerberus run` **does not die of an unreachable coordinator**: enrolment is +retried with a doubling pause from `STREQ_RETRY_MIN` to `STREQ_RETRY_MAX`, as long as it takes. Init +on a camera starts the agent once and never looks at it again, so exiting there would mean a camera +lost until the next power cycle — over a network, DNS or NTP that came up a few seconds late. +Configuration errors still exit at once: retrying does not invent a token nobody wrote down. + +## What it does not do + +No pushes and no peer-to-peer. `PathRevoked` only travels over the hub; a thin node picks a +revocation up from the next heartbeat. Hole-punching goes through the hub (`ReportCandidates` → +`PunchNow` → `ReportPathState`) and needs a simultaneity polling cannot give, so this agent stays on +the hub path — which is exactly the path that always works. + +Video does not pass through the agent at all: the relay pulls `rtsp://{overlayIp}:554/stream0` +straight from the camera through the tunnel. diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..592edd6 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,301 @@ +# cerberus + +[English version](README.md) + +Облачный агент для камер OpenIPC: маленький демон на C, который живёт **внутри прошивки камеры** и +включает камеру в приватную WireGuard-сеть — так, что видео, ONVIF, настройки и SSH доступны откуда +угодно, а между камерой и облаком нет ничего, кроме туннеля. + +Делает ровно пять вещей: + +1. заводит ключевую пару X25519 и claim-код — один раз, на первом старте; +2. энроллится в координатор по TLS (`POST /enroll`) и забирает join-материал; +3. кладёт ключ, claim-код и join-материал в **U-Boot environment** через `fw_setenv` (файл — + фолбэк) — единственное место на камере, которое переживает перепрошивку; +4. поднимает из него `wg0` и следит за туннелем с двух сторон: возраст хендшейка из `wg` **и** + ICMP-проба хаба *внутри* туннеля — свежий хендшейк на мёртвом пути выглядит как живой; +5. шлёт живость через `POST /nodes/{id}/heartbeat` и применяет то, что приезжает в ответе: + сменившийся join-материал и одноразовые команды. + +Единственная зависимость — **mbedTLS** (TLS, X25519, SHA-256, base64, CTR-DRBG), всё остальное — libc. +Агент рулит kernel WireGuard, который в прошивке уже есть, поэтому весит десятки килобайт, а не +целую userspace-реализацию WireGuard. + +## Установка на камеру + +Обычный путь — одна строка по SSH с токеном энролла из панели облака: + +```sh +curl -fsSL https://cloud.openipc.ru/install.sh | sh -s -- tok_... +``` + +Установщик (здесь это `bootstrap.sh`) спрашивает у камеры её процессор (`fw_printenv -n soc`), +скачивает сборку под него, ставит `/usr/sbin/cerberus` и `/etc/init.d/S99cerberus`, пишет адрес и +токен в U-Boot env и запускает агента. Если что-то не так, он печатает короткий код — +`cerberus: E13` — и страницу, где код расшифрован: +. `uninstall.sh` снимает всё обратно. + +Руками, из тарболла релиза: + +```sh +tar xzf cerberus--linux-musl-armv7.tar.gz && cd cerberus--linux-musl-armv7 +./install.sh # /usr/sbin/cerberus + /etc/init.d/S99cerberus +fw_setenv streq_api https://cloud-api.openipc.ru/v1 +fw_setenv streq_enrollment_token tok_... +reboot # или: /etc/init.d/S99cerberus restart +``` + +Init — BusyBox, не systemd: `S99cerberus` читает конфиг из U-Boot env и запускает `cerberus run` +через `start-stop-daemon`. Обновление — заменить бинарь и перезапустить службу; ключ, node id и +claim-код лежат в U-Boot env и обновление переживают. + +Токен, выписанный в панели на одну камеру, привязывает камеру к тому, кто его выписал. С токеном +партии агент печатает claim-код (его же показывает `cerberus status`), который вводят в панели, +чтобы забрать камеру себе. До этого камера в сети, но смотреть её и командовать ей некому. + +### Что должно быть на камере + +| нужно | зачем | если нет | +|---|---|---| +| kernel WireGuard + `wg` | весь data plane | агент бесполезен | +| `ip` | адрес и линк на `wg0` | то же | +| `fw_setenv` / `fw_printenv` | хранилище, переживающее перепрошивку | фолбэк в файл (и он не переживёт) | +| разделяемый mbedTLS | TLS, X25519, SHA-256 | статическая сборка | +| `ping` | проба внутри туннеля | проба выключается с предупреждением, присмотр за хендшейком остаётся | +| `reboot` | команда `Reboot` | команда логируется как невыполнимая | + +## Сборка + +```sh +make # нативно, против системного mbedTLS +make STATIC=1 # статически +make VERSION=1.2.0 # версия, которую агент сообщает (по умолчанию — из main.c) +make install DESTDIR=/tmp/rootfs # /usr/sbin/cerberus + /etc/init.d/S99cerberus +``` + +Собирается против обоих поколений mbedTLS, что встречаются в поле: 3.x и 2.28 LTS — последний лежит +в Buildroot и, значит, в большинстве прошивок эпохи OpenIPC. + +Тарболлы релиза собираются в контейнерах, хосту нужен только Docker: + +```sh +ci/build.sh musl-armv7 # OpenIPC на ARMv7: статический musl, без зависимостей +ci/build.sh armhf arm64 amd64 # обычные SBC (glibc, динамически) + # → dist/cerberus--linux-.tar.gz (+ .sha256) +``` + +**glibc-сборка на OpenIPC не запускается вообще.** Прошивка на musl: glibc-бинарь просит загрузчик +`/lib/ld-linux-armhf.so.3`, которого нет, и `sh` отвечает `not found` про файл, лежащий перед +глазами. Отсюда отдельная сборка `musl-armv7`. Она идёт внутри эмулированного armv7 Alpine +(binfmt/QEMU — в Docker Desktop есть; на голом Linux один раз +`docker run --privileged --rm tonistiigi/binfmt --install arm`), поэтому кросс-тулчейн не нужен: +libc ровно та, что в прошивке. Сборка статическая, mbedTLS внутри — неважно, какой mbedTLS несёт +прошивка и несёт ли вообще. + +Посмотрите на камеру, прежде чем выбирать сборку: + +```sh +uname -m # armv7l → musl-armv7 годится; mips/mipsel → нет +grep -i features /proc/cpuinfo | head -1 # нужен vfp: ARMv5 без него armv7-код не запустит +ls /lib/ld-musl-* 2>/dev/null # подтверждает, что прошивка на musl +ls /usr/lib/libmbedtls* 2>/dev/null # есть свой mbedTLS → можно линковать динамически +``` + +Ingenic T20/T31 — это MIPS, а у ARMv5 нет VFP; обоим нужен тулчейн самой прошивки (Buildroot): + +```sh +make CC=mipsel-openipc-linux-musl-gcc MBEDTLS= STATIC=1 +# или, если mbedTLS в прошивке есть и версия совпадает, без STATIC=1 — десятки КБ вместо сотен +``` + +`STATIC=1` разумен только с musl: статический glibc всё равно тянет в рантайме разделяемые +NSS-библиотеки для `getaddrinfo` — ровно того вызова, которым резолвится хаб. + +### Сколько весит + +`-Os`, `--gc-sections`, strip; мерено по тарболлам из `ci/build.sh`: + +| цель | байт | +|---|---| +| armv7, musl, статически — OpenIPC | 538 080 | +| armv7, glibc, динамически (armhf) | 26 568 | +| arm64, glibc, динамически | 68 568 | +| x86-64, glibc, динамически | 44 120 | + +Сам агент — это динамическая armv7-цифра. Почти всё в статической сборке — mbedTLS в том виде, в +каком его собирает Alpine: сервер, DTLS, RSA, все наборы шифров. Прошивка собирает mbedTLS со своим +конфигом (TLS-клиент, ECDHE + AEAD, X.509), и статический агент против него в разы меньше. + +В работе это один процесс, один поток, около 1,4 МБ RSS — в основном буферы mbedTLS на время +TLS-хендшейка. Каждый heartbeat — новое TLS-соединение, ~4 КБ по сети, так что при 20 секундах по +умолчанию это ~17 МБ в сутки; на мобильном тарифе `STREQ_HEARTBEAT=60` делит счёт на три — ценой того, +что команды и ротация доедут на столько же позже. + +## Тесты + +```sh +make test # Alpine: musl + mbedTLS 3.x +CERBERUS_TEST_IMAGE=debian:bookworm-slim tests/run.sh # glibc + mbedTLS 2.28 +``` + +Нужен только Docker. Набор собирает агента с `-Werror` и гоняет его против поддельного координатора +(`tests/fake_coordinator.py`) по настоящему TLS со своим CA: + +- **личность** — ключ X25519 тот же, что выводит `wg pubkey`, лежит в сторе и больше нигде, стор + 0600, ключ из старого файла переносится без изменений; +- **TLS** — чужой CA и сертификат на другое имя отвергаются раньше, чем уйдёт хоть байт запроса; +- **энролл** — в запросе публичный ключ и `sha256(claim-код)`, но никогда не сам код; join-материал + сохраняется; повторный энролл ничего не тратит; +- **обратный канал** — `Restart` перезапускает агента, `Reboot` перезагружает, неизвестные команды + игнорируются, переехавший или сменивший ключ хаб подхватывается с перепрограммированием + интерфейса, недоступность координатора — одно предупреждение и пережита, отозванный узел + выбрасывает энролл и останавливается; +- **старт** — недоступный координатор пережидается с отступом, SIGTERM в паузе выполняется сразу; +- **data plane** — настоящий kernel-WireGuard туннель до хаба в отдельном network namespace: + хендшейк, ping по туннелю, и украденный маршрут, который видит проба внутри туннеля и чинит + пересборка интерфейса; +- **поставляемые скрипты** разбираются шеллом, а в установщиках нет комментариев. + +Для data plane на хосте нужен kernel WireGuard (любой свежий Linux, VM Docker Desktop); без него +секция пропускается, а `REQUIRE_WG=1` превращает пропуск в провал — так запускает CI. + +## CI и релизы + +`.github/workflows/ci.yml` на каждый push и pull request гоняет shellcheck, тесты на обоих поколениях +mbedTLS и собирает четыре тарболла. Тег `v*` публикует эти же тарболлы с `.sha256` как GitHub +release; версия, вкомпилированная в бинарь, берётся из тега. + +```sh +git tag v0.2.0 && git push origin v0.2.0 +``` + +## Запуск + +Конфиг — переменные окружения `STREQ_*`; `S99cerberus` заполняет их из U-Boot env. + +| переменная | по умолчанию | смысл | +|---|---|---| +| `STREQ_API` | `https://api.localhost/v1` | база координатора, включая `/v1` | +| `STREQ_ENROLLMENT_TOKEN` | — | одноразовый токен от владельца | +| `STREQ_KIND` | `Camera` | тип узла | +| `STREQ_HOSTNAME` | — | имя, под которым камера появится в панели | +| `STREQ_IFACE` | `wg0` | интерфейс WireGuard | +| `STREQ_SUBNET` | `10.80.0.0/16` | фолбэк: подсеть сети и адрес хаба приезжают в join-материале | +| `STREQ_CA` | `/etc/ssl/certs/ca-certificates.crt` | файл или каталог доверенных CA | +| `STREQ_INSECURE` | `0` | отключить проверку TLS (только для отладки) | +| `STREQ_STORE` | `auto` | `auto` \| `uboot` \| `file:/path` — здесь же лежит и ключ | +| `STREQ_KEYFILE` | `/etc/streq/wg.key` | старое место ключа; при первом же виде переносится в стор | +| `STREQ_KEY_TMP` | `/tmp/.streq-wg.key` | куда ключ кладётся на время одного `wg set` | +| `STREQ_HEARTBEAT` | `20` | секунд между heartbeat; `0` — выключить | +| `STREQ_PING_TARGET` | хаб | кого пинговать внутри туннеля | +| `STREQ_PING_INTERVAL` | `60` | секунд между пробами; `0` — выключить | +| `STREQ_PING_TIMEOUT` | `3` | сколько секунд ждать ответа на одну пробу | +| `STREQ_PING_FAILS` | `3` | неотвеченных проб подряд до пересборки интерфейса | +| `STREQ_STALE_AFTER` | `180` | секунд без хендшейка до перетыкания пира | +| `STREQ_RETRY_MIN` / `STREQ_RETRY_MAX` | `5` / `300` | отступ повторов энролла, секунды | +| `STREQ_DRY_RUN` | `0` | печатать команды `ip`/`wg`, а не выполнять их | + +Ключи U-Boot env, которые читает `S99cerberus`: `streq_api`, `streq_enrollment_token`, +`streq_hostname`, `streq_ca`, `streq_subnet`, `streq_ping_target`, `streq_ping_interval`, +`streq_iface` (все, кроме первых двух, необязательные; пусто — по умолчанию). `streq_ca` важнее, чем +кажется: в прошивке может не быть бандла CA вовсе, и тогда энролл падает на сертификате координатора, +а поправить это без перепрошивки нечем — укажите путь к корневому сертификату (ISRG Root X1 для +Let's Encrypt). + +``` +cerberus run # enroll + up + watch (по умолчанию; это запускает S99cerberus) +cerberus enroll # только энролл, идемпотентно +cerberus up # поднять интерфейс из сохранённого +cerberus status # что сохранено, возраст хендшейка, отвечает ли туннель +cerberus keygen # напечатать пару ключей (stdout — данные, диагностика в stderr) +cerberus clean # убрать агента: интерфейс, автозапуск, сохранённое и сам бинарь +``` + +## Что где лежит + +`streq_device_key`, `streq_node_id`, `streq_overlay_ip`, `streq_hub_endpoint`, `streq_hub_pubkey`, +`streq_allowed_ips`, `streq_network_subnet`, `streq_hub_ip`, `streq_claim_code` — всё в U-Boot env. + +**Ключ там же, и это осознанно.** Прямолинейнее держать его отдельным файлом 0600, но на камере +`/etc` — это squashfs или оверлей на разделе, который перепрошивка перезаписывает, а `streq_node_id` +рядом в U-Boot env её переживает. Узел вернулся бы с личностью, на которую у хаба есть пир, и без +ключа, которым её доказывают, — а энролл пропускается всегда, когда node id сохранён. Это не +«перерегистрируется», это **насмерть**. Ключ и node id обязаны жить и умирать вместе, поэтому лежат +в одном сторе. `wg set private-key` хочет путь, поэтому на время ровно одного вызова ключ +материализуется в `/tmp` (0600) и сразу удаляется; на rootfs он не пишется вообще. + +`streq_network_subnet` и `streq_hub_ip` приходят с энроллом: на инстансе с арендаторами у каждой +сети свой диапазон, и узел берёт из них маску своего адреса и отдельный маршрут до хаба. Маршрут +нужен потому, что `allowed-ips` у WireGuard — это криптомаршрутизация, а не таблица ядра: без него +хендшейк проходит, а пакеты уходят мимо туннеля. + +## Control plane: REST, а не хаб + +Полноценные узлы держат SignalR-соединение с координатором и получают пуши карты сети. Клиент такого +на C — не тот жанр, поэтому у тонких узлов деградированный путь: + +| хаб | что делает cerberus | +|---|---| +| `Heartbeat(nodeId)` | `POST /nodes/{id}/heartbeat` раз в `STREQ_HEARTBEAT` секунд | +| `DirectiveIssued` (пуш) | ответ на тот же heartbeat — та же `NodeDirective` | +| `JoinNetwork(nodeId)` → карта | `GET /nodes/{id}/map` есть, но агент его не опрашивает | +| `NetworkMapUpdated` / `PathRevoked` | пушей нет | + +Карта камере не нужна: пир у неё ровно один — хаб, и он приезжает в join-материале. `404` на +heartbeat значит, что координатор узел больше не знает (отозван или пересобран): агент выбрасывает +энролл из стора и останавливается, чтобы следующий старт зашёл заново, а не долбился мёртвым node id +до конца времён. + +### Обратный канал: ответ на heartbeat + +```jsonc +{ + "command": "None", // None | Restart | Reboot — одноразовая, выдаётся ровно один раз + "join": { // текущий join-материал, всегда целиком, а не дельта + "kind": "Wireguard", "overlayIp": "10.80.0.7", + "hubEndpoint": "hub.example:51820", "hubPublicKey": "base64…", "allowedIps": "10.80.0.7/32" + }, + "mapVersion": 42 +} +``` + +`join` агент сверяет с сохранённым и применяет **только расхождение**: хаб переехал (новый VPS, +новый DNS) или сменил ключ — камера перезаписывает стор и перепрограммирует интерфейс сама. +`Restart` — это re-exec самого агента (супервизора на камере может и не быть), `Reboot` — `sync` и +`reboot`. + +### Присмотр за туннелем + +Хендшейк и проба отвечают на разные вопросы, и ни один не следует из другого: WireGuard +переустанавливает хендшейк примерно раз в две минуты и покажет совершенно свежий, пока по туннелю не +ходит ничего — забранный маршрут, интерфейс, который занял кто-то другой, хаб, который отвечает на +хендшейк и не форвардит. Хендшейк говорит «пир на месте», и только вернувшийся пакет говорит +«туннель работает». + +Лестница восстановления — по возрастанию цены, и камера обязана пройти её сама: + +| что видно | что делается | +|---|---| +| хендшейку больше `STREQ_STALE_AFTER` | перетыкается пир (это же и перерезолвит имя хаба) | +| `STREQ_PING_FAILS` проб подряд без ответа | интерфейс сносится и собирается заново | +| вдвое больше — пересборка не помогла | агент перезапускает сам себя | + +Камера, которая перезагружается от одного потерянного пакета, хуже камеры, которая просто лежит, — +поэтому пороги считают серии, а не события. + +Отдельно — старт. `cerberus run` **не умирает от недоступного координатора**: энролл повторяется с +удваивающейся паузой от `STREQ_RETRY_MIN` до `STREQ_RETRY_MAX`, сколько понадобится. Init на камере +запускает агента ровно один раз и больше на него не смотрит, так что выход здесь означал бы камеру, +потерянную до следующего выключения питания — из-за сети, DNS или NTP, опоздавших на несколько +секунд. Ошибки конфигурации по-прежнему завершают сразу: повторы не выдумают токен, которого не +прописали. + +## Чего он не делает + +Пушей и P2P. `PathRevoked` ходит только через хаб — тонкий узел узнает об отзыве со следующим +heartbeat. Hole-punch идёт через хаб (`ReportCandidates` → `PunchNow` → `ReportPathState`) и требует +одновременности, которой опрос не даёт, поэтому этот агент остаётся на пути через хаб — ровно на том +пути, который работает всегда. + +Видео через агента не идёт вовсе: релей тянет `rtsp://{overlayIp}:554/stream0` прямо с камеры по +туннелю. diff --git a/S99cerberus b/S99cerberus new file mode 100755 index 0000000..6ca4e9a --- /dev/null +++ b/S99cerberus @@ -0,0 +1,54 @@ +#!/bin/sh +# OpenIPC uses BusyBox init, not systemd. Config lives in the U-Boot environment, which is also +# where cerberus persists the join material it gets back from the coordinator. + +DAEMON=/usr/sbin/cerberus +PIDFILE=/var/run/cerberus.pid + +# Read the deployment config out of the U-Boot env (streq_api, streq_enrollment_token, ...) so a +# camera is provisioned with `fw_setenv` and a reboot, without touching the read-only rootfs. +export STREQ_API="$(fw_printenv -n streq_api 2>/dev/null)" +export STREQ_ENROLLMENT_TOKEN="$(fw_printenv -n streq_enrollment_token 2>/dev/null)" +export STREQ_SUBNET="$(fw_printenv -n streq_subnet 2>/dev/null)" + +# Пустая переменная для агента означает "как по умолчанию" (env_or), поэтому отсутствующий ключ в +# u-boot env безвреден и все, что ниже, необязательно. +# +# streq_ca — не роскошь: прошивка может не нести бандл доверенных CA вовсе, и тогда энролл падает на +# проверке сертификата координатора, а поправить это, не перепрошивая камеру, больше нечем. Класть +# туда путь к корневому сертификату (ISRG Root X1 для Let's Encrypt, или свой CA). +export STREQ_CA="$(fw_printenv -n streq_ca 2>/dev/null)" +# Имя, под которым камера появится в панели; иначе там будет один id. +export STREQ_HOSTNAME="$(fw_printenv -n streq_hostname 2>/dev/null)" +# Адрес для пробы внутри туннеля (по умолчанию выводится из streq_subnet) и её период. +export STREQ_PING_TARGET="$(fw_printenv -n streq_ping_target 2>/dev/null)" +export STREQ_PING_INTERVAL="$(fw_printenv -n streq_ping_interval 2>/dev/null)" +STREQ_IFACE=$(fw_printenv -n streq_iface 2>/dev/null) +export STREQ_IFACE=${STREQ_IFACE:-wg0} + +case "$1" in + start) + [ -n "$STREQ_API" ] || { echo "streq: not provisioned (fw_setenv streq_api ...)"; exit 0; } + echo "Starting cerberus..." + start-stop-daemon -S -q -m -b -p $PIDFILE -x $DAEMON -- run + ;; + stop) + echo "Stopping cerberus..." + start-stop-daemon -K -q -p $PIDFILE + # Интерфейса может не быть — агент мог не дойти до его подъёма. Это не повод завершиться с + # ошибкой: для init "stop" неподнятого сервиса обязан быть успехом. + ip link del dev "$STREQ_IFACE" 2>/dev/null || true + ;; + restart|reload) + "$0" stop + sleep 1 + "$0" start + ;; + status) + $DAEMON status + ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..29dcb1f --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,80 @@ +#!/bin/sh +set -eu + +DIST="${STREQ_DIST:-https://cloud.openipc.ru/cerberus}" +API="${STREQ_API:-https://cloud-api.openipc.ru/v1}" +DOCS="${STREQ_DOCS:-https://cloud.openipc.ru/docs/openipc-camera/#codes}" +TOKEN="${1:-}" + +say() { echo "cerberus: $*"; } +die() { echo "cerberus: E$1" >&2; echo "cerberus: $DOCS" >&2; exit "$1"; } + +[ "$(id -u)" = 0 ] || die 10 + +command -v fw_printenv >/dev/null 2>&1 || die 11 +chip=$(fw_printenv -n soc 2>/dev/null | tr 'A-Z' 'a-z' | tr -d ' \t\r') || die 12 +[ -n "$chip" ] || die 12 +say "chip $chip" + +tmp="/tmp/cerberus.$$" +rc="/tmp/S99cerberus.$$" +trap 'rm -f "$tmp" "$tmp.sha" "$rc"' EXIT INT TERM + +if ! curl -fsL -o "$tmp" "$DIST/cerberus_$chip"; then + curl -fsL -m 5 -o /dev/null -X POST -H 'Content-Type: application/json' -d "{\"chip\":\"$chip\"}" "$API/builds/missing" 2>/dev/null || true + die 13 +fi +curl -fsL -o "$rc" "$DIST/S99cerberus" || die 14 + +if curl -fsL -o "$tmp.sha" "$DIST/cerberus_$chip.sha256" 2>/dev/null; then + want=$(cut -d' ' -f1 <"$tmp.sha") + have=$(sha256sum "$tmp" | cut -d' ' -f1) + [ "$want" = "$have" ] || die 15 + say "sha256 $have" +fi + +if [ -x /etc/init.d/S99cerberus ]; then + /etc/init.d/S99cerberus stop 2>/dev/null || true +fi + +chmod 0755 "$tmp" "$rc" || die 16 +mv "$tmp" /usr/sbin/cerberus || die 16 +mv "$rc" /etc/init.d/S99cerberus || die 16 +mkdir -p /etc/streq || die 16 +chmod 0700 /etc/streq || die 16 +say "installed /usr/sbin/cerberus" + +if [ -z "$TOKEN" ]; then + say "E20" + say "$DOCS" + echo " fw_setenv streq_api '$API'" + echo " fw_setenv streq_enrollment_token 'tok_...'" + echo " /etc/init.d/S99cerberus restart" + exit 0 +fi + +fw_setenv streq_api "$API" || die 17 +fw_setenv streq_enrollment_token "$TOKEN" || die 17 + +/etc/init.d/S99cerberus restart >/dev/null 2>&1 || die 18 +say "started" + +claim="" +i=0 +while [ "$i" -lt 40 ]; do + claim=$(/usr/sbin/cerberus status 2>/dev/null | awk '$1 == "claim" && $2 == "code" { print $3 }') + if [ -n "$claim" ] && [ "$claim" != "-" ]; then + break + fi + claim="" + i=$((i + 1)) + sleep 1 +done + +if [ -n "$claim" ]; then + say "claim $claim" +else + say "E21" + say "$DOCS" + echo " cerberus status" +fi diff --git a/ci/build.sh b/ci/build.sh new file mode 100755 index 0000000..a307afe --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Package the on-camera agent for real hardware. +# +# The camera is not an edge box: no room for a runtime, and the rootfs is a read-only squashfs. So the +# agent is one C binary, mbedTLS its only dependency, using the kernel WireGuard the firmware ships. +# +# Cross-compiles inside a Debian container with multiarch, so the only thing a build host needs is +# Docker — no toolchain, no emulation, no arm hardware. +# +# Usage: ci/build.sh # arm64 +# ci/build.sh arm64 armhf amd64 # explicit set (Debian arch names) +# ci/build.sh musl-armv7 # OpenIPC (ARM): static musl, no dependencies +# VERSION=1.2.0 OUT=dist ci/build.sh +# +# VERSION defaults to the nearest v* tag, without the v. It is compiled into the binary — it is what +# the agent reports to the coordinator — so a tarball and the agent inside it never disagree. With no +# tag in reach the binary keeps the version written in main.c and the tarball is named -dev. +# +# NOTE ON LIBC. arm64/armhf/amd64 here are glibc, which is what a generic arm SBC runs, and they are +# dynamically linked on purpose: statically linked glibc still needs matching shared NSS libraries at +# runtime for getaddrinfo — exactly the call that resolves the hub endpoint. They are also the +# mbedTLS 2.28 builds (Debian bookworm), the generation Buildroot-era firmware carries. +# +# OpenIPC is musl, so a glibc binary does not run there at all: the loader it names does not exist and +# the kernel answers with a bare "not found". That is what `musl-armv7` is for. It builds inside an +# armv7 Alpine container (binfmt/QEMU — Docker Desktop has it; on a plain Linux host run +# `docker run --privileged --rm tonistiigi/binfmt --install arm` once) and links statically, because +# static is the only thing that makes a binary independent of which mbedTLS a given firmware carries. +# musl static has none of glibc's NSS problem. +# +# It does not cover every OpenIPC device: Ingenic T20/T31 are MIPS, and ARMv5 SoCs have no VFP, so +# neither runs an ARMv7 build. Those need the firmware's own Buildroot toolchain: +# +# make CC=arm-openipc-linux-musleabi-gcc MBEDTLS= STATIC=1 +set -euo pipefail + +cd "$(dirname "$0")/.." + +ARCHES=("$@") +[ ${#ARCHES[@]} -gt 0 ] || ARCHES=(arm64) +OUT=${OUT:-dist} +IMAGE=${CERBERUS_BUILD_IMAGE:-debian:bookworm-slim} +# --platform picks the architecture; the single-architecture repository keeps that choice from leaking. +# `--platform linux/arm/v7 alpine:3.20` overwrites the local alpine:3.20 tag with an armv7 image, and +# every later `docker run alpine:3.20` on that machine then runs emulated — tests/run.sh included, +# where kernel WireGuard fails and it reads as a bug in the agent. +CERBERUS_ARM_IMAGE=${CERBERUS_ARM_IMAGE:-arm32v7/alpine:3.20} + +VERSION=${VERSION:-$(git describe --tags --match 'v[0-9]*' --dirty 2>/dev/null || true)} +VERSION=${VERSION#v} +SRC_VERSION=$(sed -n 's/^#define CERBERUS_VERSION "\(.*\)"$/\1/p' main.c) +NAME_VERSION=${VERSION:-$SRC_VERSION-dev} + +mkdir -p "$OUT" + +# Docker on Windows wants a native path for -v, and MSYS would rewrite the container-side one. +host_path() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) (cd "$1" && pwd -W) ;; + *) (cd "$1" && pwd) ;; + esac +} +SRC=$(host_path .) + +for arch in "${ARCHES[@]}"; do + case "$arch" in + arm64) triple=aarch64-linux-gnu; want='ARM aarch64' ;; + armhf) triple=arm-linux-gnueabihf; want='ARM, EABI' ;; + amd64) triple=x86_64-linux-gnu; want='x86-64' ;; + musl-armv7) triple=armv7-alpine-linux-musleabihf; want='ARM, EABI' ;; + *) echo "unknown arch '$arch' (want arm64 | armhf | amd64 | musl-armv7)"; exit 2 ;; + esac + + echo "==> building linux-$arch ($triple), version ${VERSION:-$SRC_VERSION (from main.c)}" + stage="$OUT/cerberus-$NAME_VERSION-linux-$arch" + rm -rf "$stage" + mkdir -p "$stage" + + if [ "$arch" = musl-armv7 ]; then + # Native build inside an emulated armv7 Alpine: no cross toolchain to find, and the libc is + # the one OpenIPC actually runs. Slow (QEMU) but this is one C file. + MSYS_NO_PATHCONV=1 docker run -i --rm \ + --platform linux/arm/v7 \ + -v "$SRC:/src:ro" \ + -v "$(host_path "$stage"):/out" \ + -e WANT="$want" -e VERSION="$VERSION" \ + "$CERBERUS_ARM_IMAGE" sh -s <<'MUSLBUILD' || { +set -e +apk add --no-cache gcc musl-dev make mbedtls-dev mbedtls-static file >/dev/null +mkdir /build && cp /src/main.c /src/Makefile /build/ && cd /build +make STATIC=1 VERSION="$VERSION" EXTRA_CFLAGS=-Werror + +file cerberus +file cerberus | grep -q "$WANT" +# Static is the point: a firmware that carries no mbedTLS, or a different one, must not matter. +file cerberus | grep -q "statically linked" + +cp cerberus /out/ +MUSLBUILD + echo "musl-armv7 build failed. If docker could not run linux/arm/v7, install binfmt:" + echo " docker run --privileged --rm tonistiigi/binfmt --install arm" + exit 1 + } + else + + # -i matters: without it the container gets no stdin, `sh -s` reads EOF, and the build silently + # does nothing while exiting 0. + MSYS_NO_PATHCONV=1 docker run -i --rm \ + -v "$SRC:/src:ro" \ + -v "$(host_path "$stage"):/out" \ + -e ARCH="$arch" -e TRIPLE="$triple" -e WANT="$want" -e VERSION="$VERSION" \ + "$IMAGE" sh -s <<'BUILD' +set -e +export DEBIAN_FRONTEND=noninteractive + +# amd64 is the container's own architecture: no multiarch, no cross toolchain, plain gcc. +if [ "$ARCH" = amd64 ]; then + apt-get update -qq >/dev/null + apt-get install -y -qq gcc libc6-dev libmbedtls-dev make file >/dev/null + CC=gcc + STRIP=strip +else + dpkg --add-architecture "$ARCH" + apt-get update -qq >/dev/null + apt-get install -y -qq "crossbuild-essential-$ARCH" "libmbedtls-dev:$ARCH" make file >/dev/null + CC="$TRIPLE-gcc" + STRIP="$TRIPLE-strip" +fi + +mkdir /build && cp /src/main.c /src/Makefile /build/ && cd /build +make CC="$CC" STRIP="$STRIP" VERSION="$VERSION" EXTRA_CFLAGS=-Werror + +# A binary built for the wrong architecture is the failure mode worth catching here, and it is +# invisible in a build log. +file cerberus +file cerberus | grep -q "$WANT" + +# Its one shared dependency has to actually be mbedTLS and nothing more exotic. +"${TRIPLE}-objdump" -p cerberus 2>/dev/null | grep NEEDED || objdump -p cerberus | grep NEEDED + +cp cerberus /out/ +BUILD + fi + + cp S99cerberus "$stage/S99cerberus" + chmod 755 "$stage/S99cerberus" + + cat >"$stage/install.sh" <<'EOF' +#!/bin/sh +set -e +[ "$(id -u)" = 0 ] || { echo "cerberus: E10" >&2; exit 10; } + +cp cerberus /usr/sbin/cerberus +cp S99cerberus /etc/init.d/S99cerberus +chmod 0755 /usr/sbin/cerberus /etc/init.d/S99cerberus +mkdir -p /etc/streq && chmod 0700 /etc/streq + +echo "done. Provisioning is two fw_setenv calls and a reboot:" +echo " fw_setenv streq_api https://api.example.com/v1" +echo " fw_setenv streq_enrollment_token tok_..." +echo " reboot" +echo +echo "After start the claim code is shown by: cerberus status" +EOF + chmod 755 "$stage/install.sh" + + if [ "$arch" = musl-armv7 ]; then + LIBC_NOTE="**Static musl build for ARMv7 — for OpenIPC firmware.** No dependencies at all: +mbedTLS is inside the binary, so whichever mbedTLS the firmware carries does not matter. It does not +fit MIPS devices (Ingenic T20/T31) or ARMv5 without VFP — those need the firmware's own toolchain." + else + LIBC_NOTE='**This is a glibc build for a generic arm SBC.** It will not start on OpenIPC +firmware at all — that is musl: a camera needs the `musl-armv7` build.' + fi + + cat >"$stage/README.md" <"cerberus-$NAME_VERSION-linux-$arch.tar.gz.sha256") + echo " $OUT/cerberus-$NAME_VERSION-linux-$arch.tar.gz ($(du -h "$OUT/cerberus-$NAME_VERSION-linux-$arch.tar.gz" | cut -f1))" +done diff --git a/main.c b/main.c new file mode 100644 index 0000000..964bfb4 --- /dev/null +++ b/main.c @@ -0,0 +1,1469 @@ +/* + * cerberus — узловой агент для камеры (C-вариант C1, для прошивки OpenIPC). + * + * On-camera control agent: the smallest thing that can put a camera into the Streq overlay when the + * firmware already ships kernel WireGuard and `wg`. It does five things and nothing else: + * + * 1. generates a device X25519 keypair (mbedTLS) and a claim code, once; + * 2. POSTs /enroll to the coordinator over TLS (mbedTLS) and takes the join material; + * 3. persists key, claim code and join material into the U-Boot environment via fw_setenv (file + * store as fallback) — the one place on the camera a firmware update does not wipe; + * 4. programs wg0 from it and supervises the tunnel: the handshake age from `wg`, and an ICMP probe + * of the hub *inside* the tunnel — a fresh handshake over a dead path is a live-looking corpse; + * 5. posts liveness to /nodes/{id}/heartbeat — the REST half of the control plane, for nodes that + * cannot hold a SignalR connection (the coordinator's "thin node" REST twins) — and applies the + * NodeDirective that comes back: rotated join material (the hub moved or re-keyed) and one-shot + * commands (Restart / Reboot). That answer is the only back-channel a thin node has. + * + * It is deliberately NOT the .NET agent: no SignalR, no map polling, no go2rtc republishing. A camera + * that is itself on the overlay needs none of that — the relay pulls rtsp://{overlayIp}:554/stream0 + * straight off it, and its only peer, the hub, arrives with the join material. + * + * Everything here talks the coordinator's frozen REST contract (OpenAPI 3.1, served under /v1). + */ + +#define _POSIX_C_SOURCE 200809L +/* sync(3) — XSI, and OpenIPC's musl gates it the same way glibc does. */ +#define _DEFAULT_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(MBEDTLS_PSA_CRYPTO_C) +#include +#endif + +/* Both mbedTLS generations are in the field: 3.x on current distros, 2.28 LTS in Buildroot (and so + * in most OpenIPC-era firmware). The only call that differs is the one-shot SHA-256. */ +#if MBEDTLS_VERSION_MAJOR < 3 +#define streq_sha256(in, len, out) mbedtls_sha256_ret((in), (len), (out), 0) +#else +#define streq_sha256(in, len, out) mbedtls_sha256((in), (len), (out), 0) +#endif + +/* Release builds pass the tag in (`make VERSION=...`); a plain `make` reports the source version. */ +#ifndef CERBERUS_VERSION +#define CERBERUS_VERSION "0.1.0" +#endif + +/* Where the errors are decoded. Printed after every fatal one: the reader is on a camera + * console, with no repository, no log anyone else can see and often no idea what this binary + * is. A wrong chip, a spent token and a firewall all look alike from there — the page is what + * tells them apart, and the installer prints the same address for the same reason. */ +#define CERBERUS_DOCS "https://cloud.openipc.ru/docs/openipc-camera/#codes" + +/* Куда установщик кладёт автозапуск и сам агент: то же самое убирает `clean`. */ +#define INIT_SCRIPT "/etc/init.d/S99cerberus" +#define DEFAULT_SELF_PATH "/usr/sbin/cerberus" + +/* ---------------------------------------------------------------- logging */ + +static int g_quiet; + +/* Diagnostics go to stderr, always: stdout belongs to whatever the subcommand prints as data + * (`keygen` hands its keypair straight to `wg`, `status` gets scraped by init scripts). */ +static void note(const char *fmt, ...) +{ + va_list ap; + if (g_quiet) return; + va_start(ap, fmt); + fputs("cerberus: ", stderr); + vfprintf(stderr, fmt, ap); + fputc('\n', stderr); + fflush(stderr); + va_end(ap); +} + +static void warn_(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + fputs("cerberus: warning: ", stderr); + vfprintf(stderr, fmt, ap); + fputc('\n', stderr); + va_end(ap); +} + +static void die(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + fputs("cerberus: error: ", stderr); + vfprintf(stderr, fmt, ap); + fputc('\n', stderr); + va_end(ap); + fprintf(stderr, "cerberus: %s\n", CERBERUS_DOCS); + exit(1); +} + +/* ----------------------------------------------------------------- config */ + +/* Bound from STREQ_* env, same convention as the .NET agent (AgentOptions). */ +struct cfg { + const char *api; /* coordinator base URL incl. /v1 */ + const char *token; /* one-shot enrollment token */ + const char *kind; /* NodeKind — Camera for an on-cam agent */ + const char *hostname; + const char *iface; /* wg interface to program */ + const char *subnet; /* overlay CIDR routed to the hub */ + const char *ca; /* CA bundle/dir for TLS verification */ + const char *store; /* "auto" | "uboot" | "file:/path" */ + const char *keyfile; /* legacy plain-file device key: migrated into the store on sight */ + const char *ping_target; /* адрес внутри туннеля для ICMP-пробы; пусто → вывести из subnet */ + int insecure; /* skip TLS verification (dev only) */ + int dry_run; /* print the ip/wg commands instead of running them */ + int watch_interval; /* seconds between handshake checks */ + int stale_after; /* seconds without a handshake before re-poking the peer */ + int heartbeat; /* seconds between liveness posts; 0 disables */ + int ping_interval; /* seconds between in-tunnel ICMP probes; 0 disables */ + int ping_timeout; /* seconds one probe waits for its reply */ + int ping_fails; /* consecutive failed probes before the interface is rebuilt */ + int retry_min; /* first pause before a failed enrolment is retried */ + int retry_max; /* ceiling that pause backs off to */ +}; + +static struct cfg g_cfg; + +static const char *env_or(const char *name, const char *fallback) +{ + const char *v = getenv(name); + return (v && *v) ? v : fallback; +} + +static void cfg_load(void) +{ + g_cfg.api = env_or("STREQ_API", "https://api.localhost/v1"); + g_cfg.token = env_or("STREQ_ENROLLMENT_TOKEN", ""); + g_cfg.kind = env_or("STREQ_KIND", "Camera"); + g_cfg.hostname = env_or("STREQ_HOSTNAME", NULL); + g_cfg.iface = env_or("STREQ_IFACE", "wg0"); + g_cfg.subnet = env_or("STREQ_SUBNET", "10.80.0.0/16"); + g_cfg.ca = env_or("STREQ_CA", "/etc/ssl/certs/ca-certificates.crt"); + g_cfg.store = env_or("STREQ_STORE", "auto"); + g_cfg.keyfile = env_or("STREQ_KEYFILE", "/etc/streq/wg.key"); + g_cfg.insecure = atoi(env_or("STREQ_INSECURE", "0")); + g_quiet = atoi(env_or("STREQ_QUIET", "0")); + g_cfg.dry_run = atoi(env_or("STREQ_DRY_RUN", "0")); + g_cfg.watch_interval = atoi(env_or("STREQ_WATCH_INTERVAL", "30")); + g_cfg.stale_after = atoi(env_or("STREQ_STALE_AFTER", "180")); + + /* The coordinator's contract: 20s by default, never faster than 5. */ + g_cfg.heartbeat = atoi(env_or("STREQ_HEARTBEAT", "20")); + if (g_cfg.heartbeat > 0 && g_cfg.heartbeat < 5) g_cfg.heartbeat = 5; + if (g_cfg.watch_interval < 1) g_cfg.watch_interval = 1; + + /* The in-tunnel probe. Slower than the handshake check on purpose: it costs a packet round trip + * over the overlay, and what it is looking for (a path that is up on paper and dead in fact) does + * not appear between two ticks. */ + g_cfg.ping_target = env_or("STREQ_PING_TARGET", ""); + g_cfg.ping_interval = atoi(env_or("STREQ_PING_INTERVAL", "60")); + g_cfg.ping_timeout = atoi(env_or("STREQ_PING_TIMEOUT", "3")); + g_cfg.ping_fails = atoi(env_or("STREQ_PING_FAILS", "3")); + if (g_cfg.ping_interval > 0 && g_cfg.ping_interval < 10) g_cfg.ping_interval = 10; + if (g_cfg.ping_timeout < 1) g_cfg.ping_timeout = 1; + if (g_cfg.ping_fails < 1) g_cfg.ping_fails = 1; + + /* Ждать координатор, а не умирать. См. enroll_with_retry. */ + g_cfg.retry_min = atoi(env_or("STREQ_RETRY_MIN", "5")); + g_cfg.retry_max = atoi(env_or("STREQ_RETRY_MAX", "300")); + if (g_cfg.retry_min < 1) g_cfg.retry_min = 1; + if (g_cfg.retry_max < g_cfg.retry_min) g_cfg.retry_max = g_cfg.retry_min; +} + +/* ------------------------------------------------------------------ crypto */ + +static mbedtls_entropy_context g_entropy; +static mbedtls_ctr_drbg_context g_drbg; + +static void crypto_init(void) +{ + const char *pers = "cerberus"; +#if defined(MBEDTLS_PSA_CRYPTO_C) + psa_crypto_init(); +#endif + mbedtls_entropy_init(&g_entropy); + mbedtls_ctr_drbg_init(&g_drbg); + if (mbedtls_ctr_drbg_seed(&g_drbg, mbedtls_entropy_func, &g_entropy, + (const unsigned char *)pers, strlen(pers)) != 0) + die("no entropy source"); +} + +static void b64(const unsigned char *in, size_t n, char *out, size_t out_sz) +{ + size_t olen = 0; + if (mbedtls_base64_encode((unsigned char *)out, out_sz, &olen, in, n) != 0) + die("base64 buffer too small"); + out[olen] = 0; +} + +static int b64_decode(const char *in, unsigned char *out, size_t out_sz, size_t *olen) +{ + return mbedtls_base64_decode(out, out_sz, olen, (const unsigned char *)in, strlen(in)); +} + +static void sha256_hex(const char *in, char out[65]) +{ + unsigned char d[32]; + int i; + if (streq_sha256((const unsigned char *)in, strlen(in), d) != 0) + die("sha256 failed"); + for (i = 0; i < 32; i++) + sprintf(out + i * 2, "%02x", d[i]); + out[64] = 0; +} + +/* + * X25519 public key from a private key, the WireGuard way: clamp, then scalar-multiply the Curve25519 + * base point. mbedTLS carries the curve already (it is a TLS key exchange), so this costs no extra + * dependency — the alternative would be shelling out to `wg pubkey`. + */ +static int x25519_pub(const unsigned char priv[32], unsigned char pub[32]) +{ + mbedtls_ecp_group grp; + mbedtls_ecp_point Q; + mbedtls_mpi d; + size_t olen = 0; + int rc; + + mbedtls_ecp_group_init(&grp); + mbedtls_ecp_point_init(&Q); + mbedtls_mpi_init(&d); + + rc = mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_CURVE25519); + if (rc == 0) rc = mbedtls_mpi_read_binary_le(&d, priv, 32); + if (rc == 0) rc = mbedtls_ecp_mul(&grp, &Q, &d, &grp.G, mbedtls_ctr_drbg_random, &g_drbg); + /* For a Montgomery curve this writes X little-endian in 32 bytes — exactly WireGuard's + * encoding. Going through the public API keeps us off mbedTLS 3.x private struct fields. */ + if (rc == 0) rc = mbedtls_ecp_point_write_binary(&grp, &Q, MBEDTLS_ECP_PF_UNCOMPRESSED, + &olen, pub, 32); + if (rc == 0 && olen != 32) rc = -1; + + mbedtls_mpi_free(&d); + mbedtls_ecp_point_free(&Q); + mbedtls_ecp_group_free(&grp); + return rc; +} + +static void wg_clamp(unsigned char k[32]) +{ + k[0] &= 248; + k[31] &= 127; + k[31] |= 64; +} + +/* Crockford-ish base32 without vowels or look-alikes — this gets read off a screen and typed by + * hand. Must stay byte-identical to FileClaimCodeStore in the .NET agent. */ +static void claim_code_new(char out[13]) +{ + static const char alphabet[] = "23456789ABCDEFGHJKMNPQRSTVWXYZ"; + unsigned char r[12]; + int i; + if (mbedtls_ctr_drbg_random(&g_drbg, r, sizeof r) != 0) + die("rng failed"); + for (i = 0; i < 12; i++) + out[i] = alphabet[r[i] % (sizeof alphabet - 1)]; + out[12] = 0; +} + +/* ------------------------------------------------------------ subprocesses */ + +/* Runs argv, optionally capturing stdout. Returns the exit status, or -1 if it could not start. */ +static int run(char *const argv[], char *out, size_t out_sz) +{ + int fds[2] = { -1, -1 }; + pid_t pid; + int status = -1; + + if (out) { + out[0] = 0; + if (pipe(fds) != 0) return -1; + } + + pid = fork(); + if (pid < 0) { + if (out) { close(fds[0]); close(fds[1]); } + return -1; + } + if (pid == 0) { + int devnull = open("/dev/null", O_WRONLY); + if (out) { + dup2(fds[1], STDOUT_FILENO); + close(fds[0]); + close(fds[1]); + } else if (devnull >= 0) { + dup2(devnull, STDOUT_FILENO); + } + if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); } + execvp(argv[0], argv); + _exit(127); + } + + if (out) { + size_t n = 0; + ssize_t r; + close(fds[1]); + while (n + 1 < out_sz && (r = read(fds[0], out + n, out_sz - n - 1)) > 0) + n += (size_t)r; + out[n] = 0; + close(fds[0]); + } + waitpid(pid, &status, 0); + return WIFEXITED(status) ? WEXITSTATUS(status) : -1; +} + +static int have(const char *tool) +{ + char *argv[] = { "sh", "-c", NULL, NULL }; + char cmd[128]; + snprintf(cmd, sizeof cmd, "command -v %s >/dev/null 2>&1", tool); + argv[2] = cmd; + return run(argv, NULL, 0) == 0; +} + +/* Builds an argv from a printf-style line and runs it. Words are split on spaces — every argument + * here is a key, an address or an interface name, none of which contain one. */ +static int runf(const char *fmt, ...) +{ + char line[1024]; + char *argv[32]; + int argc = 0; + char *p, *save; + va_list ap; + + va_start(ap, fmt); + vsnprintf(line, sizeof line, fmt, ap); + va_end(ap); + + if (g_cfg.dry_run) { + note("dry-run: %s", line); + return 0; + } + + for (p = strtok_r(line, " ", &save); p && argc < 31; p = strtok_r(NULL, " ", &save)) + argv[argc++] = p; + argv[argc] = NULL; + if (argc == 0) return -1; + return run(argv, NULL, 0); +} + +/* ---------------------------------------------------------------- the store */ + +/* + * Where the join material lives across reboots. On an OpenIPC camera the rootfs is squashfs and the + * one thing guaranteed writable is the U-Boot environment, so fw_setenv is the primary backend. A + * plain file is used when fw_setenv is absent (dev boxes, containers, anything with a real rootfs). + */ +static int store_is_uboot(void) +{ + if (!strcmp(g_cfg.store, "uboot")) return 1; + if (!strncmp(g_cfg.store, "file:", 5)) return 0; + return have("fw_setenv"); /* auto */ +} + +static const char *store_file(void) +{ + if (!strncmp(g_cfg.store, "file:", 5)) return g_cfg.store + 5; + return "/etc/streq/agent.env"; +} + +static void mkparent(const char *path) +{ + char buf[512]; + char *slash; + snprintf(buf, sizeof buf, "%s", path); + slash = strrchr(buf, '/'); + if (!slash || slash == buf) return; + *slash = 0; + if (mkdir(buf, 0700) != 0 && errno != EEXIST) + warn_("could not create %s: %s", buf, strerror(errno)); +} + +static int store_get(const char *key, char *out, size_t out_sz) +{ + FILE *f; + char line[1024]; + size_t klen = strlen(key); + + out[0] = 0; + + if (store_is_uboot()) { + char *argv[] = { "fw_printenv", "-n", (char *)key, NULL }; + char buf[512]; + if (run(argv, buf, sizeof buf) != 0) return 0; + buf[strcspn(buf, "\r\n")] = 0; + if (!buf[0]) return 0; + snprintf(out, out_sz, "%s", buf); + return 1; + } + + f = fopen(store_file(), "r"); + if (!f) return 0; + while (fgets(line, sizeof line, f)) { + line[strcspn(line, "\r\n")] = 0; + if (!strncmp(line, key, klen) && line[klen] == '=') { + snprintf(out, out_sz, "%s", line + klen + 1); + fclose(f); + return out[0] != 0; + } + } + fclose(f); + return 0; +} + +static void store_set(const char *key, const char *value) +{ + const char *path; + char tmp[512], line[1024]; + size_t klen = strlen(key); + FILE *in, *out; + + if (store_is_uboot()) { + char *argv[] = { "fw_setenv", (char *)key, (char *)value, NULL }; + if (run(argv, NULL, 0) != 0) + die("fw_setenv %s failed — is /etc/fw_env.config right?", key); + return; + } + + /* Rewrite the file with this key replaced. Small and rare enough that a full rewrite beats + * carrying an in-memory model of it. */ + path = store_file(); + mkparent(path); + snprintf(tmp, sizeof tmp, "%s.tmp", path); + out = fopen(tmp, "w"); + if (!out) die("cannot write %s: %s", tmp, strerror(errno)); + in = fopen(path, "r"); + if (in) { + while (fgets(line, sizeof line, in)) { + if (!strncmp(line, key, klen) && line[klen] == '=') continue; + fputs(line, out); + } + fclose(in); + } + fprintf(out, "%s=%s\n", key, value); + fclose(out); + if (chmod(tmp, 0600) != 0) warn_("chmod %s: %s", tmp, strerror(errno)); + if (rename(tmp, path) != 0) die("cannot replace %s: %s", path, strerror(errno)); +} + +/* + * Forgets the enrolment, keeping the device's own key and claim code. Called when the coordinator + * says it has never heard of this node: it was revoked, or rebuilt from under us. Enrolment is + * skipped whenever a node id is stored, so leaving it in place would turn every later boot into the + * same dead end — the same reasoning as AgentHost.TryDeleteCache in the .NET agent. + */ +static void store_forget_enrollment(void) +{ + static const char *keys[] = { + "streq_node_id", "streq_overlay_ip", "streq_hub_endpoint", + "streq_hub_pubkey", "streq_allowed_ips", "streq_network_subnet", + "streq_hub_ip", NULL + }; + int i; + + for (i = 0; keys[i]; i++) { + if (store_is_uboot()) { + char *argv[] = { "fw_setenv", (char *)keys[i], NULL }; /* no value = unset */ + run(argv, NULL, 0); + } else { + store_set(keys[i], ""); + } + } + warn_("enrolment discarded; restart with a fresh STREQ_ENROLLMENT_TOKEN to join again"); +} + +/* ------------------------------------------------------------------- HTTPS */ + +struct conn { + mbedtls_net_context net; + mbedtls_ssl_context ssl; + mbedtls_ssl_config conf; + mbedtls_x509_crt ca; + int tls; +}; + +static int conn_send(struct conn *c, const char *buf, size_t len) +{ + size_t sent = 0; + while (sent < len) { + int n = c->tls + ? mbedtls_ssl_write(&c->ssl, (const unsigned char *)buf + sent, len - sent) + : mbedtls_net_send(&c->net, (const unsigned char *)buf + sent, len - sent); + if (n == MBEDTLS_ERR_SSL_WANT_READ || n == MBEDTLS_ERR_SSL_WANT_WRITE) continue; + if (n <= 0) return -1; + sent += (size_t)n; + } + return 0; +} + +static int conn_recv(struct conn *c, unsigned char *buf, size_t len) +{ + int n; + do { + n = c->tls + ? mbedtls_ssl_read(&c->ssl, buf, len) + : mbedtls_net_recv(&c->net, buf, len); + } while (n == MBEDTLS_ERR_SSL_WANT_READ || n == MBEDTLS_ERR_SSL_WANT_WRITE); + return n; +} + +static void conn_close(struct conn *c) +{ + if (c->tls) { + mbedtls_ssl_close_notify(&c->ssl); + mbedtls_ssl_free(&c->ssl); + mbedtls_ssl_config_free(&c->conf); + mbedtls_x509_crt_free(&c->ca); + } + mbedtls_net_free(&c->net); +} + +/* https://host[:port]/path — the only URL shape the coordinator is ever configured with. */ +static int url_split(const char *url, int *tls, char *host, size_t host_sz, + char *port, size_t port_sz, char *path, size_t path_sz) +{ + const char *p, *slash; + char *colon; + + if (!strncmp(url, "https://", 8)) { *tls = 1; p = url + 8; snprintf(port, port_sz, "443"); } + else if (!strncmp(url, "http://", 7)) { *tls = 0; p = url + 7; snprintf(port, port_sz, "80"); } + else return -1; + + slash = strchr(p, '/'); + snprintf(path, path_sz, "%s", slash ? slash : "/"); + snprintf(host, host_sz, "%.*s", slash ? (int)(slash - p) : (int)strlen(p), p); + + colon = strrchr(host, ':'); + if (colon && !strchr(host, ']')) { /* not a bracketed v6 literal */ + snprintf(port, port_sz, "%s", colon + 1); + *colon = 0; + } + return 0; +} + +/* + * One request, one connection, `Connection: close` — so the body is simply "everything until EOF" + * and neither Content-Length nor chunked framing has to be parsed. The agent makes at most one HTTP + * call per boot; keep-alive would buy nothing and cost a de-chunker. + */ +static int http_post_json(const char *url, const char *body, char *resp, size_t resp_sz, int *status) +{ + struct conn c; + char host[256], port[16], path[512], head[1024]; + unsigned char buf[4096]; + char *hdr_end; + size_t total = 0; + int rc, n; + + memset(&c, 0, sizeof c); + if (url_split(url, &c.tls, host, sizeof host, port, sizeof port, path, sizeof path) != 0) + die("unsupported URL: %s", url); + + mbedtls_net_init(&c.net); + if ((rc = mbedtls_net_connect(&c.net, host, port, MBEDTLS_NET_PROTO_TCP)) != 0) { + warn_("connect %s:%s failed (-0x%04x)", host, port, (unsigned)-rc); + return -1; + } + + if (c.tls) { + mbedtls_ssl_init(&c.ssl); + mbedtls_ssl_config_init(&c.conf); + mbedtls_x509_crt_init(&c.ca); + + if (mbedtls_ssl_config_defaults(&c.conf, MBEDTLS_SSL_IS_CLIENT, + MBEDTLS_SSL_TRANSPORT_STREAM, + MBEDTLS_SSL_PRESET_DEFAULT) != 0) + die("ssl config failed"); + + if (g_cfg.insecure) { + /* Dev only. Loud, because an enrollment token in the clear is a device someone else + * can enroll. */ + warn_("TLS verification disabled (STREQ_INSECURE=1)"); + mbedtls_ssl_conf_authmode(&c.conf, MBEDTLS_SSL_VERIFY_NONE); + } else { + struct stat st; + int loaded = -1; + if (stat(g_cfg.ca, &st) == 0) + loaded = S_ISDIR(st.st_mode) + ? mbedtls_x509_crt_parse_path(&c.ca, g_cfg.ca) + : mbedtls_x509_crt_parse_file(&c.ca, g_cfg.ca); + if (loaded < 0) + die("no usable CA trust store at %s (set STREQ_CA)", g_cfg.ca); + mbedtls_ssl_conf_authmode(&c.conf, MBEDTLS_SSL_VERIFY_REQUIRED); + mbedtls_ssl_conf_ca_chain(&c.conf, &c.ca, NULL); + } + + mbedtls_ssl_conf_rng(&c.conf, mbedtls_ctr_drbg_random, &g_drbg); + if (mbedtls_ssl_setup(&c.ssl, &c.conf) != 0) die("ssl setup failed"); + if (mbedtls_ssl_set_hostname(&c.ssl, host) != 0) die("ssl SNI failed"); + mbedtls_ssl_set_bio(&c.ssl, &c.net, mbedtls_net_send, mbedtls_net_recv, NULL); + + while ((rc = mbedtls_ssl_handshake(&c.ssl)) != 0) { + if (rc == MBEDTLS_ERR_SSL_WANT_READ || rc == MBEDTLS_ERR_SSL_WANT_WRITE) continue; + warn_("TLS handshake with %s failed (-0x%04x); verify flags 0x%08x", + host, (unsigned)-rc, mbedtls_ssl_get_verify_result(&c.ssl)); + conn_close(&c); + return -1; + } + note("TLS ok: %s, %s", mbedtls_ssl_get_version(&c.ssl), mbedtls_ssl_get_ciphersuite(&c.ssl)); + } + + snprintf(head, sizeof head, + "POST %s HTTP/1.1\r\n" + "Host: %s\r\n" + "User-Agent: cerberus/" CERBERUS_VERSION "\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %u\r\n" + "Connection: close\r\n\r\n", + path, host, (unsigned)strlen(body)); + + if (conn_send(&c, head, strlen(head)) != 0 || conn_send(&c, body, strlen(body)) != 0) { + warn_("sending the request failed"); + conn_close(&c); + return -1; + } + + while (total + 1 < resp_sz) { + n = conn_recv(&c, buf, sizeof buf); + if (n == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY || n == 0) break; + if (n < 0) break; + if (total + (size_t)n + 1 > resp_sz) n = (int)(resp_sz - total - 1); + memcpy(resp + total, buf, (size_t)n); + total += (size_t)n; + } + resp[total] = 0; + conn_close(&c); + + if (total == 0 || strncmp(resp, "HTTP/1.", 7) != 0) { + warn_("no HTTP response"); + return -1; + } + *status = atoi(resp + 9); + + hdr_end = strstr(resp, "\r\n\r\n"); + if (hdr_end) memmove(resp, hdr_end + 4, strlen(hdr_end + 4) + 1); + return 0; +} + +/* --------------------------------------------------------------- tiny JSON */ + +/* + * Pulls one flat string field out of an object. The enroll response has six of them, all ids, + * addresses or base64 — no escapes, no nesting to walk, no duplicate keys. A real parser would be + * several times the size of everything else here. + */ +static int json_str(const char *json, const char *key, char *out, size_t out_sz) +{ + char pat[64]; + const char *p, *q; + size_t n; + + out[0] = 0; + snprintf(pat, sizeof pat, "\"%s\"", key); + p = strstr(json, pat); + if (!p) return 0; + p = strchr(p + strlen(pat), ':'); + if (!p) return 0; + while (*++p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') { } + if (*p != '"') return 0; + q = strchr(++p, '"'); + if (!q) return 0; + n = (size_t)(q - p); + if (n + 1 > out_sz) n = out_sz - 1; + memcpy(out, p, n); + out[n] = 0; + return 1; +} + +/* ------------------------------------------------------------------ device */ + +/* + * The device private key, from the same store the join material lives in. + * + * Not from a file on the rootfs, and that is the point. On an OpenIPC camera /etc is squashfs (or an + * overlay on a partition a firmware update replaces), so a key kept there dies with the next reflash + * — while `streq_node_id` next to it in the U-Boot environment survives. The node would come back + * with an identity the hub has a peer for and no key to prove it, and enrolment is skipped whenever a + * node id is stored: not "re-enrollable", *stuck*. Key and node id have to share a lifetime, so they + * share a store. + * + * An older key sitting in STREQ_KEYFILE is migrated in on sight, once. It is left on disk rather than + * deleted: the reflash that motivates all of this will take it anyway, and a delete racing a failed + * fw_setenv would be the one unrecoverable outcome here. + */ +static void device_key(char priv_b64[64], char pub_b64[64]) +{ + unsigned char priv[32], pub[32]; + char stored[128], line[128]; + size_t olen = 0; + FILE *f; + + if (store_get("streq_device_key", stored, sizeof stored) && stored[0]) { + if (b64_decode(stored, priv, sizeof priv, &olen) != 0 || olen != 32) + die("streq_device_key is not a base64 WireGuard key"); + b64(priv, sizeof priv, priv_b64, 64); /* re-encode: normalises whatever was stored */ + } else if ((f = fopen(g_cfg.keyfile, "r")) != NULL) { + int ok = fgets(line, sizeof line, f) != NULL; + fclose(f); + line[strcspn(line, "\r\n")] = 0; + if (!ok || b64_decode(line, priv, sizeof priv, &olen) != 0 || olen != 32) + die("%s is not a base64 WireGuard key", g_cfg.keyfile); + b64(priv, sizeof priv, priv_b64, 64); + store_set("streq_device_key", priv_b64); + note("migrated the device key from %s into the store — it now survives a firmware update", + g_cfg.keyfile); + } else { + if (mbedtls_ctr_drbg_random(&g_drbg, priv, sizeof priv) != 0) die("rng failed"); + wg_clamp(priv); + b64(priv, sizeof priv, priv_b64, 64); + store_set("streq_device_key", priv_b64); + note("generated a device key into the %s store", + store_is_uboot() ? "u-boot env" : store_file()); + } + + if (x25519_pub(priv, pub) != 0) die("X25519 public key derivation failed"); + b64(pub, sizeof pub, pub_b64, 64); +} + +/* + * `wg set private-key` takes a path, not a key, so the key is materialised for exactly one call: + * on tmpfs, 0600, unlinked straight after. The copy that persists is the one in the store — which on + * a camera is the U-Boot environment, and the rootfs is not written at all. + */ +static int key_materialise(char *path, size_t path_sz) +{ + char priv_b64[64], pub_b64[64]; + int fd; + + device_key(priv_b64, pub_b64); + snprintf(path, path_sz, "%s", env_or("STREQ_KEY_TMP", "/tmp/.streq-wg.key")); + + fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) { + warn_("cannot write %s: %s", path, strerror(errno)); + return -1; + } + if (write(fd, priv_b64, strlen(priv_b64)) < 0 || write(fd, "\n", 1) < 0) { + warn_("cannot write %s: %s", path, strerror(errno)); + close(fd); + unlink(path); + return -1; + } + close(fd); + return 0; +} + +static void claim_code_get(char *code, size_t code_sz) +{ + char fresh[13]; + if (store_get("streq_claim_code", code, code_sz) && code[0]) return; + claim_code_new(fresh); + store_set("streq_claim_code", fresh); + snprintf(code, code_sz, "%s", fresh); +} + +/* ----------------------------------------------------------------- enroll */ + +/* POST /enroll. Idempotent by construction: a stored nodeId means this + * camera is already a peer on the hub, and the one-shot token is long gone. */ +static int enroll(void) +{ + char priv_b64[64], pub_b64[64], code[16], hash[65]; + char url[512], body[1024], resp[8192]; + char node[128], ip[64], ep[256], hubkey[64], allowed[80], subnet[64], hubip[64]; + int status = 0; + + if (store_get("streq_node_id", node, sizeof node)) { + store_get("streq_overlay_ip", ip, sizeof ip); + note("already enrolled as %s (overlay %s)", node, ip[0] ? ip : "?"); + return 0; + } + + if (!*g_cfg.token) + die("not enrolled and no STREQ_ENROLLMENT_TOKEN set"); + + device_key(priv_b64, pub_b64); + claim_code_get(code, sizeof code); + sha256_hex(code, hash); + + snprintf(url, sizeof url, "%s/enroll", g_cfg.api); + /* agentVersion — U0. Обновлять себя эта камера не умеет (rootfs только для чтения, путь + * обновления — прошивка), поэтому облако ей не апдейтер, а термометр: пусть владелец видит, что + * на камере агент позапрошлого года, раньше, чем это выяснится другим способом. */ + if (g_cfg.hostname) + snprintf(body, sizeof body, + "{\"enrollmentToken\":\"%s\",\"devicePublicKey\":\"%s\",\"kind\":\"%s\"," + "\"hostname\":\"%s\",\"claimCodeHash\":\"%s\",\"agentVersion\":\"%s\"}", + g_cfg.token, pub_b64, g_cfg.kind, g_cfg.hostname, hash, CERBERUS_VERSION); + else + snprintf(body, sizeof body, + "{\"enrollmentToken\":\"%s\",\"devicePublicKey\":\"%s\",\"kind\":\"%s\"," + "\"claimCodeHash\":\"%s\",\"agentVersion\":\"%s\"}", + g_cfg.token, pub_b64, g_cfg.kind, hash, CERBERUS_VERSION); + + note("enrolling at %s as %s", url, pub_b64); + if (http_post_json(url, body, resp, sizeof resp, &status) != 0) + return 1; + if (status != 200) { + warn_("enroll rejected: HTTP %d %s", status, resp); + return 1; + } + + if (!json_str(resp, "nodeId", node, sizeof node) || + !json_str(resp, "overlayIp", ip, sizeof ip) || + !json_str(resp, "hubEndpoint", ep, sizeof ep) || + !json_str(resp, "hubPublicKey", hubkey, sizeof hubkey)) { + warn_("enroll response missing join material: %s", resp); + return 1; + } + if (!json_str(resp, "allowedIps", allowed, sizeof allowed)) + snprintf(allowed, sizeof allowed, "%s/32", ip); + + /* Подсеть своей сети и адрес хаба. Их может не быть — координатор старше арендаторов; тогда + * работает STREQ_SUBNET, как и раньше. */ + if (!json_str(resp, "networkSubnet", subnet, sizeof subnet)) subnet[0] = 0; + if (!json_str(resp, "hubOverlayIp", hubip, sizeof hubip)) hubip[0] = 0; + + /* Persist before touching the interface: a camera that got a peer on the hub and then lost the + * material would re-enroll on the next boot with a token already spent. */ + store_set("streq_node_id", node); + store_set("streq_overlay_ip", ip); + store_set("streq_hub_endpoint", ep); + store_set("streq_hub_pubkey", hubkey); + store_set("streq_allowed_ips", allowed); + if (subnet[0]) store_set("streq_network_subnet", subnet); + if (hubip[0]) store_set("streq_hub_ip", hubip); + + note("enrolled as %s, overlay %s/%s via hub %s", node, ip, subnet[0] ? subnet : "?", ep); + note("claim code: %.4s-%.4s-%.4s — enter it in the panel to bind this camera to an account", + code, code + 4, code + 8); + return 0; +} + +/* -------------------------------------------------------------- interface */ + +static int prefix_of(const char *cidr) +{ + const char *slash = strchr(cidr, '/'); + return slash ? atoi(slash + 1) : 16; +} + +/* + * Programs the interface from the stored material. Kernel WireGuard, `wg` and `ip` — all three are + * already in an OpenIPC image, which is the whole reason this can be a small binary instead of a + * userspace WireGuard implementation. + * + * `flush` is for the rotation path: a new overlay address must replace the old one rather than join + * it, or the camera keeps answering on an address the coordinator has already handed to somebody else. + */ +/* + * Подсеть, в которой стоит этот узел: из стора, если координатор её назвал, иначе STREQ_SUBNET. + * + * With one network per instance these are the same string. With tenants they are not: the node's + * own /24 is what it may claim on the interface, and STREQ_SUBNET is a guess made by whoever wrote + * the u-boot environment — usually the instance-wide pool, which belongs to everybody. + */ +static const char *node_subnet(void) +{ + static char subnet[64]; + if (store_get("streq_network_subnet", subnet, sizeof subnet) && subnet[0]) + return subnet; + return g_cfg.subnet; +} + +/* Overlay-адрес хаба, если координатор его назвал. Пусто — хаб внутри нашей же подсети. */ +static const char *hub_overlay_ip(void) +{ + static char hubip[64]; + if (store_get("streq_hub_ip", hubip, sizeof hubip) && hubip[0]) + return hubip; + return ""; +} + +static int link_apply(int flush) +{ + char ip[64], ep[256], hubkey[64], keypath[256]; + const char *subnet, *hubip; + int rc; + + if (!store_get("streq_overlay_ip", ip, sizeof ip) || + !store_get("streq_hub_endpoint", ep, sizeof ep) || + !store_get("streq_hub_pubkey", hubkey, sizeof hubkey)) { + warn_("no stored join material — run `cerberus enroll` first"); + return 1; + } + + if (key_materialise(keypath, sizeof keypath) != 0) + return 1; + + subnet = node_subnet(); + hubip = hub_overlay_ip(); + + runf("ip link add dev %s type wireguard", g_cfg.iface); /* already there → non-zero, fine */ + + /* Хаб живёт вне подсети арендатора, поэтому он вторым allowed-ip — и ниже вторым маршрутом. + * allowed-ips это криптомаршрутизация WireGuard, таблицы ядра она не трогает вовсе. */ + rc = *hubip + ? runf("wg set %s private-key %s peer %s endpoint %s allowed-ips %s,%s/32 persistent-keepalive 25", + g_cfg.iface, keypath, hubkey, ep, subnet, hubip) + : runf("wg set %s private-key %s peer %s endpoint %s allowed-ips %s persistent-keepalive 25", + g_cfg.iface, keypath, hubkey, ep, subnet); + unlink(keypath); + if (rc != 0) { + warn_("wg set failed on %s", g_cfg.iface); + return 1; + } + + if (flush) + runf("ip address flush dev %s", g_cfg.iface); + if (runf("ip address replace %s/%d dev %s", ip, prefix_of(subnet), g_cfg.iface) != 0 || + runf("ip link set up dev %s", g_cfg.iface) != 0) { + warn_("bringing %s up failed", g_cfg.iface); + return 1; + } + /* Без этого маршрута пакет к хабу уходит через обычный шлюз: туннель поднят, а связи нет. */ + if (*hubip) + runf("ip route replace %s/32 dev %s", hubip, g_cfg.iface); + + note("overlay up: %s in %s on %s via hub %s", ip, subnet, g_cfg.iface, ep); + return 0; +} + +static int link_up(void) +{ + return link_apply(0); +} + +/* + * Tears the interface down and rebuilds it. The heavy hammer, for when the tunnel is up on paper and + * dead in fact: `wg set` cannot fix an interface whose address or route was taken over by something + * else, and nobody is going to walk up to the camera and find out which it was. + */ +static int link_reset(void) +{ + warn_("rebuilding %s from scratch", g_cfg.iface); + runf("ip link del dev %s", g_cfg.iface); + return link_apply(1); +} + +/* Seconds since the last completed handshake with the hub, or -1 when there has never been one. */ +static long handshake_age(void) +{ + char *argv[] = { "wg", "show", NULL, "latest-handshakes", NULL }; + char out[1024]; + const char *tab; + long ts; + + argv[2] = (char *)g_cfg.iface; + if (run(argv, out, sizeof out) != 0) return -1; + + tab = strchr(out, '\t'); + if (!tab) return -1; + ts = strtol(tab + 1, NULL, 10); + if (ts <= 0) return -1; + return (long)time(NULL) - ts; +} + +/* ---------------------------------------------------------- in-tunnel probe */ + +/* + * Кого пинговать внутри туннеля. Хаб: единственный пир камеры и первый адрес подсети (10.80.0.0/16 → + * 10.80.0.1, ровно так его поднимает хаб). Материал вступления его не называет — + * узлу он больше ни для чего не нужен — поэтому адрес выводится из подсети, а STREQ_PING_TARGET + * перекрывает вывод там, где хаб стоит не на первом адресе. + */ +static const char *ping_target(void) +{ + static char target[64]; + unsigned a = 0, b = 0, c = 0, d = 0; + int prefix = 0; + unsigned long net; + + if (*g_cfg.ping_target) return g_cfg.ping_target; + if (target[0]) return target; + + /* Координатор назвал адрес хаба — пингуем его, а не вывод из подсети: у арендатора хаб вообще + * не в его диапазоне. */ + if (*hub_overlay_ip()) { + snprintf(target, sizeof target, "%s", hub_overlay_ip()); + return target; + } + + if (sscanf(g_cfg.subnet, "%u.%u.%u.%u/%d", &a, &b, &c, &d, &prefix) < 4) + return ""; + if (prefix <= 0 || prefix > 30) prefix = 16; + + net = ((unsigned long)a << 24) | ((unsigned long)b << 16) | ((unsigned long)c << 8) | d; + net &= (0xFFFFFFFFUL << (32 - prefix)) & 0xFFFFFFFFUL; + net += 1; + snprintf(target, sizeof target, "%lu.%lu.%lu.%lu", + (net >> 24) & 0xFF, (net >> 16) & 0xFF, (net >> 8) & 0xFF, net & 0xFF); + return target; +} + +/* + * Одна ICMP-проба через туннель: 1 — дошло, 0 — нет, -1 — пробовать нечем. + * + * Why this sits next to the handshake check rather than replacing it: they fail for different reasons + * and neither implies the other. WireGuard re-handshakes about every two minutes and will show a + * perfectly fresh one while nothing at all traverses the tunnel — a route that got taken, an + * interface someone else reused, a hub that completes handshakes and forwards nothing. The handshake + * says the peer is there; only a packet that comes back says the tunnel works. + * + * Shelling out to busybox ping instead of opening a raw socket: the firmware already ships one, a raw + * socket is another privileged path to get wrong, and this runs once a minute. + */ +static int ping_ok(void) +{ + char *argv[] = { "ping", "-c", "1", "-W", NULL, NULL, NULL }; + char timeout[16]; + const char *target = ping_target(); + int rc; + + if (!*target) return -1; + + snprintf(timeout, sizeof timeout, "%d", g_cfg.ping_timeout); + argv[4] = timeout; + argv[5] = (char *)target; + + rc = run(argv, NULL, 0); + if (rc == 127 || rc < 0) return -1; /* no ping binary — the probe is simply unavailable */ + return rc == 0; +} + +/* ------------------------------------------------------------- directives */ + +static char **g_argv; + +/* Перезапуск самого агента: re-exec, потому что супервизора на камере может и не оказаться. */ +static void self_restart(void) +{ + note("restarting"); + fflush(NULL); + execv("/proc/self/exe", g_argv); + execvp(g_argv[0], g_argv); + /* Both failed: exit non-zero and let whatever started us decide. */ + die("restart failed: %s", strerror(errno)); +} + +static void do_reboot(void) +{ + char *argv[] = { "reboot", NULL }; + char *fallback[] = { "/sbin/reboot", NULL }; + + warn_("rebooting on the coordinator's command"); + sync(); + if (run(argv, NULL, 0) != 0 && run(fallback, NULL, 0) != 0) + warn_("reboot failed — no usable reboot binary"); +} + +/* + * Применяет NodeDirective из ответа на heartbeat. + * + * The join material arrives whole on every beat, never as a delta, so this compares it with what is + * stored and acts only on a difference — that is what makes a hub that moved or re-keyed survivable + * without visiting the camera. Persist first, reprogram second: a camera that reprogrammed and then + * lost the material would come back on the next boot pointing at a hub that no longer exists. + */ +static void apply_directive(const char *json) +{ + char ep[256], hubkey[64], ip[64], allowed[80], fresh[64]; + char cur[256], command[32]; + int rotated = 0; + + if (json_str(json, "hubEndpoint", ep, sizeof ep) && + (!store_get("streq_hub_endpoint", cur, sizeof cur) || strcmp(cur, ep) != 0)) { + note("hub endpoint rotated: %s -> %s", cur[0] ? cur : "(none)", ep); + store_set("streq_hub_endpoint", ep); + rotated = 1; + } + if (json_str(json, "hubPublicKey", hubkey, sizeof hubkey) && + (!store_get("streq_hub_pubkey", cur, sizeof cur) || strcmp(cur, hubkey) != 0)) { + note("hub public key rotated"); + store_set("streq_hub_pubkey", hubkey); + rotated = 1; + } + if (json_str(json, "overlayIp", ip, sizeof ip) && + (!store_get("streq_overlay_ip", cur, sizeof cur) || strcmp(cur, ip) != 0)) { + note("overlay address rotated: %s -> %s", cur[0] ? cur : "(none)", ip); + store_set("streq_overlay_ip", ip); + rotated = 1; + } + if (json_str(json, "allowedIps", allowed, sizeof allowed)) + store_set("streq_allowed_ips", allowed); + if (json_str(json, "networkSubnet", fresh, sizeof fresh) && fresh[0] && + (!store_get("streq_network_subnet", cur, sizeof cur) || strcmp(cur, fresh) != 0)) { + note("network subnet rotated: %s -> %s", cur[0] ? cur : "(none)", fresh); + store_set("streq_network_subnet", fresh); + rotated = 1; + } + if (json_str(json, "hubOverlayIp", fresh, sizeof fresh) && fresh[0] && + (!store_get("streq_hub_ip", cur, sizeof cur) || strcmp(cur, fresh) != 0)) { + note("hub overlay address rotated: %s -> %s", cur[0] ? cur : "(none)", fresh); + store_set("streq_hub_ip", fresh); + rotated = 1; + } + + if (rotated && link_apply(1) != 0) + warn_("could not apply the rotated join material; the stale-handshake path will retry"); + + if (!json_str(json, "command", command, sizeof command)) + return; + if (!strcmp(command, "Reboot")) + do_reboot(); + else if (!strcmp(command, "Restart")) + self_restart(); + else if (strcmp(command, "None") != 0) + warn_("unknown command '%s' from the coordinator — ignoring", command); +} + +/* ------------------------------------------------------------------ watch */ + +static volatile sig_atomic_t g_stop; +static void on_signal(int sig) { (void)sig; g_stop = 1; } + +/* + * Ставятся до энролла, а не в watch(): между стартом и watch() может пройти сколько угодно времени, + * если координатор недоступен, и всё это время камера обязана слышать SIGTERM — иначе перезагрузка + * ждёт полный таймаут убийцы процессов. + */ +static void catch_signals(void) +{ + signal(SIGINT, on_signal); + signal(SIGTERM, on_signal); +} + +/* + * The whole "monitor that the tunnel is alive" job, on three timers. + * + * WireGuard is stateless enough that there is no connection to repair — but the hub's public address + * can move (DHCP, a redeployed VPS), and `wg` resolved that name exactly once, at set time. Re-setting + * the peer re-resolves it, which is the cheapest recovery a camera can perform on its own. + * + * Past that the escalation is laddered on purpose, because each rung costs more than the one below: + * re-poke the peer (nothing is interrupted) → rebuild the interface (a second of downtime) → restart + * the agent (everything re-derived from the store). A camera nobody can reach has to be able to work + * through all three by itself; a camera that reboots itself over one lost packet is worse than one + * that stays down. + */ +static int watch(void) +{ + char ep[256], hubkey[64], node[128], beat_url[512], resp[4096]; + int misses = 0, beat_fails = 0, ping_misses = 0, ping_available = 1, status; + time_t next_check = 0, next_beat = 0, next_ping = 0, now; + int tick; + + catch_signals(); /* повторно — безвредно; watch() бывает и точкой входа отладки */ + + if (!store_get("streq_hub_endpoint", ep, sizeof ep) || + !store_get("streq_hub_pubkey", hubkey, sizeof hubkey)) + return 1; + store_get("streq_node_id", node, sizeof node); + snprintf(beat_url, sizeof beat_url, "%s/nodes/%s/heartbeat", g_cfg.api, node); + + tick = g_cfg.watch_interval; + if (g_cfg.heartbeat > 0 && g_cfg.heartbeat < tick) tick = g_cfg.heartbeat; + if (g_cfg.ping_interval > 0 && g_cfg.ping_interval < tick) tick = g_cfg.ping_interval; + + note("watching %s (handshake every %ds, stale after %ds, heartbeat %ds, ping %s every %ds)", + g_cfg.iface, g_cfg.watch_interval, g_cfg.stale_after, g_cfg.heartbeat, + g_cfg.ping_interval > 0 ? ping_target() : "off", g_cfg.ping_interval); + + while (!g_stop) { + now = time(NULL); + + if (now >= next_check) { + long age = handshake_age(); + if (age >= 0 && age <= g_cfg.stale_after) { + misses = 0; + } else { + misses++; + warn_("no handshake for %lds (miss %d) — re-resolving %s", age, misses, ep); + /* Re-read: a directive may have rotated the hub since this loop started. */ + store_get("streq_hub_endpoint", ep, sizeof ep); + store_get("streq_hub_pubkey", hubkey, sizeof hubkey); + if (*hub_overlay_ip()) + runf("wg set %s peer %s endpoint %s allowed-ips %s,%s/32 persistent-keepalive 25", + g_cfg.iface, hubkey, ep, node_subnet(), hub_overlay_ip()); + else + runf("wg set %s peer %s endpoint %s allowed-ips %s persistent-keepalive 25", + g_cfg.iface, hubkey, ep, node_subnet()); + } + next_check = now + g_cfg.watch_interval; + } + + /* Does anything actually traverse the tunnel? Escalates only on a run of failures: one lost + * ICMP packet is weather, three in a row is a dead path. */ + if (g_cfg.ping_interval > 0 && ping_available && now >= next_ping) { + int probe = ping_ok(); + if (probe < 0) { + ping_available = 0; + warn_("no usable `ping` — the in-tunnel probe is off, the handshake watch stands"); + } else if (probe) { + if (ping_misses > 0) note("the tunnel answers again (%s)", ping_target()); + ping_misses = 0; + } else { + ping_misses++; + warn_("no reply from %s inside the tunnel (miss %d)", ping_target(), ping_misses); + if (ping_misses >= g_cfg.ping_fails * 2) { + warn_("the tunnel stayed dead through a rebuild — restarting the agent"); + self_restart(); + } else if (ping_misses == g_cfg.ping_fails) { + link_reset(); + } + } + next_ping = time(NULL) + g_cfg.ping_interval; + } + + /* Liveness — and the only back-channel a thin node has, so the answer is read, not discarded. + * The node is perfectly usable without it (relay tokens never consult lastSeen), so a + * coordinator that is unreachable is worth one warning, not a retry storm. */ + if (g_cfg.heartbeat > 0 && node[0] && now >= next_beat) { + status = 0; + if (http_post_json(beat_url, "{\"agentVersion\":\"" CERBERUS_VERSION "\"}", + resp, sizeof resp, &status) != 0 || status >= 500) { + if (beat_fails++ == 0) + warn_("heartbeat to %s failed (HTTP %d) — the overlay is unaffected", beat_url, status); + } else { + if (beat_fails > 0) note("heartbeat recovered after %d failure(s)", beat_fails); + beat_fails = 0; + if (status == 404) { + /* The coordinator does not know this node any more. */ + store_forget_enrollment(); + g_stop = 1; + } else if (status == 200) { + apply_directive(resp); + } + } + next_beat = time(NULL) + g_cfg.heartbeat; + } + + if (!g_stop) sleep((unsigned)tick); + } + note("stopping"); + return 0; +} + +/* + * Энролл с повторами. Агент не имеет права умереть от того, что координатор недоступен в секунду + * его старта. + * + * BusyBox init runs S99cerberus once and then supervises nothing at all, so an exit here is the + * camera gone until somebody power-cycles it — and the most ordinary boot in the world has the + * network, DNS or NTP arriving a few seconds after we do. The .NET agent gets this for free from + * systemd's Restart=always; on a camera there is no systemd to get it from, so the waiting has to + * live here. + * + * Отступает до потолка, а не долбится: недоступный координатор недоступен минутами, а не + * миллисекундами, и камера на мобильном тарифе платит за каждую попытку. Ошибки конфигурации при + * этом по-прежнему убивают сразу — enroll() зовёт для них die(), и никакое количество повторов не + * выдумает токен, которого не прописали. + */ +static int enroll_with_retry(void) +{ + int delay = g_cfg.retry_min; + unsigned slept; + + while (!g_stop) { + if (enroll() == 0) + return 0; + if (g_stop) + break; + + warn_("enrolment did not go through; retrying in %ds", delay); + for (slept = 0; slept < (unsigned)delay && !g_stop; slept++) + sleep(1); + + delay *= 2; + if (delay > g_cfg.retry_max) + delay = g_cfg.retry_max; + } + return 1; +} + +/* ------------------------------------------------------------------- main */ + +static int cmd_status(void) +{ + char v[512]; + long age; + printf("store %s\n", store_is_uboot() ? "u-boot env (fw_setenv)" : store_file()); + printf("node %s\n", store_get("streq_node_id", v, sizeof v) ? v : "(not enrolled)"); + printf("device key %s\n", store_get("streq_device_key", v, sizeof v) ? "in the store" : "-"); + printf("overlay ip %s\n", store_get("streq_overlay_ip", v, sizeof v) ? v : "-"); + printf("hub endpoint %s\n", store_get("streq_hub_endpoint", v, sizeof v) ? v : "-"); + printf("hub public key %s\n", store_get("streq_hub_pubkey", v, sizeof v) ? v : "-"); + printf("network subnet %s%s\n", node_subnet(), + store_get("streq_network_subnet", v, sizeof v) ? "" : " (из STREQ_SUBNET)"); + printf("hub overlay ip %s\n", *hub_overlay_ip() ? hub_overlay_ip() : "- (внутри своей подсети)"); + printf("claim code %s\n", store_get("streq_claim_code", v, sizeof v) ? v : "-"); + age = handshake_age(); + if (age < 0) printf("handshake never\n"); + else printf("handshake %lds ago\n", age); + if (g_cfg.ping_interval > 0) { + int probe = ping_ok(); + printf("tunnel ping %s (%s)\n", + probe < 0 ? "unavailable" : probe ? "ok" : "no reply", ping_target()); + } else { + printf("tunnel ping off\n"); + } + return 0; +} + +static int cmd_keygen(void) +{ + char priv_b64[64], pub_b64[64]; + device_key(priv_b64, pub_b64); + printf("%s\n%s\n", priv_b64, pub_b64); + return 0; +} + +/* + * Убирает агента с камеры целиком: интерфейс, автозапуск, состояние и сам бинарь. + * + * Это не «сбросить подключение» — для этого есть store_forget_enrollment, оставляющий ключ и + * claim-код на месте, — а «камеры в облаке больше нет». Порядок обратный установке: сначала уходит + * то, что держит туннель, затем то, что его помнит, и последним файл, который всё это делал. + * + * Узел остаётся в реестре облака, и это не недоделка: у агента нет прав отзывать самого себя, и + * появиться им незачем. Иначе камера, до которой кто-то дотянулся по SSH, выписывала бы себя из + * чужого аккаунта сама. Запись убирает владелец, в панели. + */ +static int cmd_clean(void) +{ + static const char *keys[] = { + "streq_node_id", "streq_overlay_ip", "streq_hub_endpoint", "streq_hub_pubkey", + "streq_allowed_ips", "streq_network_subnet", "streq_hub_ip", "streq_claim_code", + "streq_device_key", "streq_api", "streq_enrollment_token", "streq_hostname", + "streq_kind", "streq_iface", "streq_subnet", "streq_ca", "streq_heartbeat", + "streq_ping_target", "streq_ping_interval", "streq_ping_timeout", "streq_ping_fails", + NULL + }; + char self[256]; + char cmd[320]; + char *sh[] = { "sh", "-c", NULL, NULL }; + ssize_t n; + int i; + + /* Где мы лежим на самом деле: на камере это /usr/sbin/cerberus, на стенде — что угодно. */ + n = readlink("/proc/self/exe", self, sizeof self - 1); + if (n <= 0) + snprintf(self, sizeof self, "%s", DEFAULT_SELF_PATH); + else + self[n] = 0; + + runf("ip link del dev %s", g_cfg.iface); + if (unlink(INIT_SCRIPT) != 0 && errno != ENOENT) + warn_("cannot remove %s: %s", INIT_SCRIPT, strerror(errno)); + + for (i = 0; keys[i]; i++) { + if (store_is_uboot()) { + char *argv[] = { "fw_setenv", (char *)keys[i], NULL }; /* без значения — сброс */ + run(argv, NULL, 0); + } else { + store_set(keys[i], ""); + } + } + if (!store_is_uboot()) + unlink(store_file()); + + note("cleaned: %s and %s are gone, the stored material with them", INIT_SCRIPT, self); + + /* + * Себя бинарь удаляет чужими руками и не сразу: работающий процесс переживёт unlink на любой + * нормальной ФС, но на камере ФС бывает какая угодно, а половина удалённого агента хуже целого. + * Три секунды — чтобы этот процесс успел закончиться и отдать код возврата тому, кто его звал. + */ + snprintf(cmd, sizeof cmd, "(sleep 3; rm -f %s) >/dev/null 2>&1 &", self); + sh[2] = cmd; + run(sh, NULL, 0); + return 0; +} + +static void usage(void) +{ + fputs( + "cerberus " CERBERUS_VERSION " — on-camera Streq agent\n" + "\n" + "usage: cerberus [enroll|up|run|status|keygen]\n" + " enroll enroll into the coordinator and persist the join material (idempotent)\n" + " up program the WireGuard interface from the stored material\n" + " run enroll + up + watch the tunnel (the default; what the init script calls)\n" + " status print what is stored, how old the handshake is, whether the tunnel answers\n" + " keygen print the device keypair (private, public), creating it if needed\n" + "\n" + "config (env):\n" + " STREQ_API coordinator base URL incl. /v1 [https://api.localhost/v1]\n" + " STREQ_ENROLLMENT_TOKEN one-shot token from the owner\n" + " STREQ_KIND node kind [Camera]\n" + " STREQ_IFACE WireGuard interface [wg0]\n" + " STREQ_SUBNET overlay CIDR routed to the hub [10.80.0.0/16]\n" + " STREQ_CA CA file or dir for TLS [/etc/ssl/certs/ca-certificates.crt]\n" + " STREQ_INSECURE=1 skip TLS verification (dev only)\n" + " STREQ_STORE auto | uboot | file:/path [auto] — holds the device key too\n" + " STREQ_KEYFILE legacy key file, migrated into the store [/etc/streq/wg.key]\n" + " STREQ_HEARTBEAT seconds between heartbeats; 0 disables [20]\n" + " STREQ_PING_TARGET in-tunnel probe target [first address of STREQ_SUBNET]\n" + " STREQ_PING_INTERVAL seconds between probes; 0 disables [60]\n" + " STREQ_PING_TIMEOUT seconds one probe waits [3]\n" + " STREQ_PING_FAILS failed probes before the interface is rebuilt [3]\n" + " STREQ_DRY_RUN=1 print ip/wg commands instead of running them\n", + stderr); +} + +int main(int argc, char **argv) +{ + const char *cmd = argc > 1 ? argv[1] : "run"; + + /* Kept for self_restart(): re-exec has to reproduce the argv we were started with. */ + g_argv = argv; + + if (!strcmp(cmd, "-h") || !strcmp(cmd, "--help")) { usage(); return 0; } + + cfg_load(); + crypto_init(); + + if (!strcmp(cmd, "status")) return cmd_status(); + if (!strcmp(cmd, "keygen")) return cmd_keygen(); + if (!strcmp(cmd, "clean")) return cmd_clean(); + if (!strcmp(cmd, "enroll")) return enroll(); + if (!strcmp(cmd, "up")) return link_up(); + if (!strcmp(cmd, "run")) { + catch_signals(); + /* Ненулевой ответ здесь бывает ровно один: попросили остановиться. Это не отказ. */ + if (enroll_with_retry() != 0) { + note("stopping"); + return 0; + } + /* A failed bring-up is not fatal here, and this is the difference between a camera that can + * be fixed remotely and one that cannot: the stored hub endpoint may no longer resolve (the + * hub moved, its DNS name is gone), and the answer to that is a directive — which only + * arrives if we stay alive to heartbeat for it. The overlay is not the path to the + * coordinator; the internet is. */ + if (link_up() != 0) + warn_("staying up without the overlay — a directive may still fix the material"); + return watch(); + } + + usage(); + return 2; +} diff --git a/tests/fake_coordinator.py b/tests/fake_coordinator.py new file mode 100755 index 0000000..6851532 --- /dev/null +++ b/tests/fake_coordinator.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""A stand-in for the Streq coordinator: the two REST calls cerberus makes, and nothing else. + +It answers exactly the shapes the real one does — `POST /v1/enroll` returns an EnrollResponse with +WireGuard join material, `POST /v1/nodes/{id}/heartbeat` returns a NodeDirective — over real TLS, +so what the agent is tested against is its own HTTP and mbedTLS code, not a mock of it. + +Everything the test needs to steer goes through `/_test/*` on the same port: + + POST /_test/command {"command": "Restart"} one-shot, consumed by the next heartbeat + POST /_test/join {"hubEndpoint": "..."} rewrites fields of the join material + POST /_test/forget {} the node is revoked: heartbeats answer 404 + POST /_test/fail {"status": 503} heartbeats answer this until set to 0 + GET /_test/requests every request so far, as a JSON list + +Stdlib only: the test containers have python3 and nothing more. +""" + +import argparse +import json +import re +import secrets +import ssl +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +LOCK = threading.Lock() +STATE = { + "token": "", + "token_uses": 0, + "token_max_uses": 1, + "nodes": {}, # nodeId -> {"pub": ..., "claimCodeHash": ...} + "join": {}, + "command": None, + "fail_status": 0, + "requests": [], +} + + +def join_material(): + return dict(STATE["join"], kind="Wireguard") + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): # quiet: the suite prints what matters + pass + + def _body(self): + n = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(n) if n else b"" + try: + return raw, (json.loads(raw) if raw else {}) + except ValueError: + return raw, None + + def _send(self, status, obj=None): + data = b"" if obj is None else json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path == "/_test/requests": + with LOCK: + return self._send(200, STATE["requests"]) + self._send(404, {"error": "not_found"}) + + def do_POST(self): + raw, body = self._body() + with LOCK: + if not self.path.startswith("/_test/"): + STATE["requests"].append({ + "path": self.path, + "userAgent": self.headers.get("User-Agent"), + "body": body if body is not None else raw.decode(errors="replace"), + }) + status, answer = self.route(body) + self._send(status, answer) + + def route(self, body): + if body is None: + return 400, {"error": "bad_json"} + + if self.path == "/_test/command": + STATE["command"] = body.get("command") + return 202, {} + if self.path == "/_test/join": + STATE["join"].update(body) + return 200, join_material() + if self.path == "/_test/forget": + STATE["nodes"].clear() + return 200, {} + if self.path == "/_test/fail": + STATE["fail_status"] = int(body.get("status", 0)) + return 200, {} + + if self.path == "/v1/enroll": + return self.enroll(body) + + m = re.fullmatch(r"/v1/nodes/([^/]+)/heartbeat", self.path) + if m: + return self.heartbeat(m.group(1)) + + return 404, {"error": "not_found"} + + def enroll(self, body): + for field in ("enrollmentToken", "devicePublicKey", "kind"): + if not body.get(field): + return 400, {"error": f"missing {field}"} + if body["enrollmentToken"] != STATE["token"] or STATE["token_uses"] >= STATE["token_max_uses"]: + return 401, {"error": "invalid_token"} + + claim = body.get("claimCodeHash", "") + if claim and not re.fullmatch(r"[0-9a-f]{64}", claim): + return 400, {"error": "claimCodeHash is not lowercase hex sha-256"} + + STATE["token_uses"] += 1 + node = "node_" + secrets.token_hex(16) + STATE["nodes"][node] = {"pub": body["devicePublicKey"], "claimCodeHash": claim} + return 200, { + "nodeId": node, + "networkId": "net_test", + "fabric": "Wireguard", + "join": join_material(), + } + + def heartbeat(self, node): + if STATE["fail_status"]: + return STATE["fail_status"], {"error": "injected"} + if node not in STATE["nodes"]: + return 404, {"error": "unknown_node"} + command = STATE["command"] or "None" + STATE["command"] = None + return 200, {"command": command, "join": join_material(), "mapVersion": 1} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=8443) + ap.add_argument("--cert", required=True) + ap.add_argument("--key", required=True) + ap.add_argument("--token", required=True) + ap.add_argument("--token-max-uses", type=int, default=1) + ap.add_argument("--overlay-ip", required=True) + ap.add_argument("--network-subnet", required=True) + ap.add_argument("--hub-overlay-ip", required=True) + ap.add_argument("--hub-endpoint", required=True) + ap.add_argument("--hub-public-key", required=True) + a = ap.parse_args() + + STATE["token"] = a.token + STATE["token_max_uses"] = a.token_max_uses + STATE["join"] = { + "overlayIp": a.overlay_ip, + "hubEndpoint": a.hub_endpoint, + "hubPublicKey": a.hub_public_key, + "allowedIps": a.network_subnet, + "networkSubnet": a.network_subnet, + "hubOverlayIp": a.hub_overlay_ip, + } + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(a.cert, a.key) + srv = ThreadingHTTPServer(("0.0.0.0", a.port), Handler) + srv.socket = ctx.wrap_socket(srv.socket, server_side=True) + print(f"fake coordinator on :{a.port}", flush=True) + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..bde18ed --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Runs tests/suite.sh in a throwaway container: no C toolchain, mbedTLS or WireGuard tools needed on +# the host — only Docker. The container is --privileged because the data-plane section creates a +# WireGuard interface and a network namespace for the hub; the host kernel needs WireGuard for that +# part (any current Linux, and Docker Desktop's VM), and the section is skipped where it is missing. +# +# Usage: tests/run.sh # Alpine: musl + mbedTLS 3.x (what OpenIPC is) +# CERBERUS_TEST_IMAGE=debian:bookworm-slim tests/run.sh # glibc + mbedTLS 2.28 LTS +# REQUIRE_WG=1 tests/run.sh # a missing data plane is a failure (CI) +set -euo pipefail + +cd "$(dirname "$0")/.." +IMAGE=${CERBERUS_TEST_IMAGE:-alpine:3.20} + +host_path() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) (cd "$1" && pwd -W) ;; + *) (cd "$1" && pwd) ;; + esac +} + +echo "==> cerberus tests in $IMAGE" +MSYS_NO_PATHCONV=1 exec docker run --rm --privileged \ + --add-host coordinator.test:127.0.0.1 \ + -v "$(host_path .):/src:ro" \ + -e REQUIRE_WG="${REQUIRE_WG:-}" \ + "$IMAGE" sh /src/tests/suite.sh diff --git a/tests/suite.sh b/tests/suite.sh new file mode 100755 index 0000000..f71dd2d --- /dev/null +++ b/tests/suite.sh @@ -0,0 +1,365 @@ +#!/bin/sh +# The cerberus test suite. Runs inside a throwaway container (tests/run.sh starts it), as root: +# builds the agent, stands up a fake coordinator over real TLS, and walks everything the agent does +# without needing a cloud — identity, TLS verification, enrolment, the heartbeat back-channel, +# supervision — and then, where the kernel allows, a real WireGuard tunnel to a hub in its own netns. +# +# Most of it runs with STREQ_DRY_RUN=1: the agent prints the `ip`/`wg` commands it would run, and the +# suite checks those. The data-plane section at the end runs them for real. +# +# Env: REQUIRE_WG=1 fail (rather than skip) when kernel WireGuard or netns are unavailable — CI. +set -u + +PASS=0 +FAIL=0 +SKIP=0 + +check() { # check