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/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b94131f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,57 @@ +## The Prosperity Public License 3.0.0 + +Contributor: OpenIPC (c) 2026 + +Source Code: https://github.com/OpenIPC/cerberus + +### Purpose + +This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days. + +### Agreement + +In order to receive this license, you have to agree to its rules. Those rules are both obligations under that agreement and conditions to your license. Don't do anything with this software that triggers a rule you can't or won't follow. + +### Notices + +Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above. + +### Commercial Trial + +Limit your use of this software for commercial purposes to a thirty-day trial period. If you use this software for work, your company gets one trial period for all personnel, not one trial per person. + +### Contributions Back + +Developing feedback, changes, or additions that you contribute back to the contributor on the terms of a standardized public software license such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html) doesn't count as use for a commercial purpose. + +### Personal Uses + +Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn't count as use for a commercial purpose. + +### Noncommercial Organizations + +Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution doesn't count as use for a commercial purpose regardless of the source of funding or obligations resulting from the funding. + +### Defense + +Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent. + +### Copyright + +The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it. + +### Patent + +The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license. + +### Reliability + +The contributor can't revoke this license. + +### Excuse + +You're excused for unknowingly breaking Notices if you take all practical steps to comply within thirty days of learning you broke the rule. + +### No Liability + +_**As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim.**_ 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..e3ea1d6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,311 @@ # 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. + +## License + +[The Prosperity Public License 3.0.0](LICENSE.md), © OpenIPC. Free for noncommercial use — +personal, hobby, research, and noncommercial organizations — and a thirty-day trial for commercial +use; commercial use beyond the trial is not covered by this license. Every copy, binaries included, +carries the license text with it, which is why the release tarballs ship `LICENSE.md`. diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..511e9d3 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,308 @@ +# 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` прямо с камеры по +туннелю. + +## Лицензия + +[The Prosperity Public License 3.0.0](LICENSE.md), © OpenIPC. Бесплатно для некоммерческого +использования — личного, любительского, исследовательского и некоммерческими организациями — и +тридцать дней пробы для коммерческого; коммерческое использование сверх пробы эта лицензия не покрывает. Текст лицензии +обязан идти с каждой копией, бинарной в том числе, поэтому `LICENSE.md` лежит и в тарболлах релиза. 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..9763d2a --- /dev/null +++ b/ci/build.sh @@ -0,0 +1,221 @@ +#!/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" + # The licence travels with every copy, binaries included: its Notices rule asks for exactly that. + cp LICENSE.md "$stage/LICENSE.md" + + 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