diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cdb249..cba91e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,16 +19,16 @@ jobs: # 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 + - run: shellcheck -S warning -e SC2155 S99cerberus install.sh uninstall.sh ci/build.sh ci/assemble.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. + # Both mbedTLS generations are in the field: 3.x on current distros and in Alpine, 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, walks the streq_* -> cerberus_* migration and the + # installer, 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. strategy: fail-fast: false matrix: @@ -49,61 +49,63 @@ jobs: 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. + # One static musl binary per CPU architecture, which is what a camera's installer downloads. The + # script asserts the ELF machine and static linking, and then runs the binary under QEMU and + # checks its X25519 key against `wg pubkey` — a binary for the right machine can still die on its + # first instruction, and only running it shows that. strategy: fail-fast: false matrix: - arch: [musl-armv7, armhf, arm64, amd64] + arch: [arm, mipsel, aarch64, x86_64] 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 + - name: Toolchain and mbedTLS cache + uses: actions/cache@v4 with: - platforms: arm + path: .cache + key: toolchain-${{ matrix.arch }}-${{ hashFiles('ci/build.sh') }} - - name: Build and package + - name: Build run: ci/build.sh ${{ matrix.arch }} - uses: actions/upload-artifact@v4 with: name: cerberus-${{ matrix.arch }} - path: dist/*.tar.gz* + path: dist/cerberus-${{ matrix.arch }} 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. + # A v* tag publishes the binaries that were just built and tested — the same files, not a rebuild — + # together with the installer, the uninstaller, the init script and SHA256SUMS over all of them. + # Asset names carry no version: cameras fetch releases/latest/download/. if: startsWith(github.ref, 'refs/tags/v') needs: [lint, test, build] runs-on: ubuntu-latest permissions: contents: write steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: pattern: cerberus-* path: dist merge-multiple: true - - name: Every tarball carries the tag's version + - name: The binaries were built as this tag 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 + chmod +x dist/cerberus-* + got=$(dist/cerberus-x86_64 --help 2>&1 | head -1 | cut -d' ' -f2) + echo "binary says $got, tag says ${GITHUB_REF_NAME#v}" + [ "$got" = "${GITHUB_REF_NAME#v}" ] + + - name: Lay out the release + run: ci/assemble.sh - name: Publish env: diff --git a/.gitignore b/.gitignore index 456377a..d3a3557 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /cerberus /dist/ *.o +/.cache/ diff --git a/README.md b/README.md index e3ea1d6..a4e85b3 100644 --- a/README.md +++ b/README.md @@ -1,209 +1,137 @@ -# cerberus +![OpenIPC logo][logo] -[Русская версия](README.ru.md) +# OpenIPC Cerberus -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. +**English** | [Русский](README.ru.md) | [简体中文](README.zh-CN.md) + +[![CI](https://github.com/OpenIPC/cerberus/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenIPC/cerberus/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/OpenIPC/cerberus)](https://github.com/OpenIPC/cerberus/releases/latest) +[![License](https://img.shields.io/badge/license-Prosperity%203.0-blue)](LICENSE.md) +[![Platform](https://img.shields.io/badge/platform-OpenIPC-brightgreen)](https://openipc.org) + +The cloud agent for cameras running [OpenIPC](https://openipc.org) firmware: a small C daemon that +lives **inside the camera** and puts it on a private WireGuard network, so the camera 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. +3. keeps key, claim code and join material in the **U-Boot environment** via `fw_setenv` — the one + place on a camera that survives a firmware reflash; +4. brings up `wg0` 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: changed join + material and one-shot commands. -## 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_... -``` +**mbedTLS** is the only dependency; everything else is libc. It drives the kernel WireGuard the +firmware already ships, so it is a few hundred kilobytes static, not a userspace WireGuard. -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. +## Install -By hand, from a release tarball: +One line over SSH on the camera, with an enrolment token from the cloud panel (the panel gives you +this command ready-filled): ```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 +curl -fsSL https://github.com/OpenIPC/cerberus/releases/latest/download/install.sh | sh -s -- tok_... ``` -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 | +The installer picks the build for the camera's CPU, checks it against `SHA256SUMS` from the same +release, installs `/usr/sbin/cerberus` and `/etc/init.d/S99cerberus`, writes the address and the token +into the U-Boot environment and starts the agent. No reboot is needed. On failure it prints a short +code — `cerberus: E13` — and the page that decodes it: +. -## Building +To take it all off again: ```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 +curl -fsSL https://github.com/OpenIPC/cerberus/releases/latest/download/uninstall.sh | sh ``` -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: +By hand, the same thing: ```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) +# cerberus-arm, cerberus-mipsel, ... — see Releases below +curl -fsSL -o /usr/sbin/cerberus https://github.com/OpenIPC/cerberus/releases/latest/download/cerberus-arm +curl -fsSL -o /etc/init.d/S99cerberus https://github.com/OpenIPC/cerberus/releases/latest/download/S99cerberus +chmod +x /usr/sbin/cerberus /etc/init.d/S99cerberus +fw_setenv cerberus_api https://cloud-api.openipc.ru/v1 +fw_setenv cerberus_enrollment_token tok_... +/etc/init.d/S99cerberus restart ``` -**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. +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. -### Size +## Releases -`-Os`, `--gc-sections`, stripped; measured from the `ci/build.sh` tarballs: +Every release carries one **static** binary per CPU architecture — static with musl and mbedTLS +inside, so it does not matter which libc or which mbedTLS a given firmware has, or whether it has one +at all: -| target | bytes | +| file | runs on | |---|---| -| 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 -``` +| `cerberus-arm` | every ARM camera from ARMv5 to ARMv7 — HiSilicon, Goke, SigmaStar, Fullhan, … (soft-float, so ARMv5 without VFP too) | +| `cerberus-mipsel` | MIPS cameras — Ingenic T10, T20, T21, T31, T40, … | +| `cerberus-aarch64` | 64-bit ARM | +| `cerberus-x86_64` | VMs and test boxes | +| `install.sh`, `uninstall.sh` | the installer and the uninstaller | +| `S99cerberus` | the BusyBox init script | +| `SHA256SUMS` | checksums of all of the above; the installer verifies against it | +| `LICENSE.md` | the license | + +Asset names carry no version on purpose: `releases/latest/download/` always points at the +current release. + +| binary | bytes | +|---|---| +| `cerberus-arm` | 570 564 | +| `cerberus-mipsel` | 660 900 | +| `cerberus-aarch64` | 587 320 | +| `cerberus-x86_64` | 575 144 | -## Running +Almost all of that is mbedTLS built with its default configuration; the agent itself is ~30 KB. -Configuration is `STREQ_*` environment variables; `S99cerberus` fills them from the U-Boot env. +## What the camera needs -| variable | default | meaning | +| needed | for | without it | |---|---|---| -| `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). +| 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) | +| `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 | +| `curl` with TLS | the installer | download the files by hand | + +## Configuration + +`S99cerberus` reads the U-Boot environment and hands it to the agent as `CERBERUS_*` variables: + +| U-Boot key | variable | default | meaning | +|---|---|---|---| +| `cerberus_api` | `CERBERUS_API` | — | coordinator base URL, including `/v1` | +| `cerberus_enrollment_token` | `CERBERUS_ENROLLMENT_TOKEN` | — | one-shot token from the owner | +| `cerberus_hostname` | `CERBERUS_HOSTNAME` | — | the name the camera shows up under in the panel | +| `cerberus_ca` | `CERBERUS_CA` | `/etc/ssl/certs/ca-certificates.crt` | CA file or directory for TLS | +| `cerberus_iface` | `CERBERUS_IFACE` | `wg0` | WireGuard interface | +| `cerberus_subnet` | `CERBERUS_SUBNET` | `10.80.0.0/16` | fallback only: the network's subnet arrives with the join material | +| `cerberus_heartbeat` | `CERBERUS_HEARTBEAT` | `20` | seconds between heartbeats; `0` disables | +| `cerberus_ping_target` | `CERBERUS_PING_TARGET` | the hub | in-tunnel probe target | +| `cerberus_ping_interval` | `CERBERUS_PING_INTERVAL` | `60` | seconds between probes; `0` disables | +| `cerberus_ping_timeout` | `CERBERUS_PING_TIMEOUT` | `3` | seconds one probe waits | +| `cerberus_ping_fails` | `CERBERUS_PING_FAILS` | `3` | failed probes in a row before the interface is rebuilt | + +`cerberus_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). + +Variables only the agent reads: `CERBERUS_STORE` (`auto` \| `uboot` \| `file:/path`), +`CERBERUS_STALE_AFTER` (`180`, seconds without a handshake before the peer is re-poked), +`CERBERUS_RETRY_MIN` / `CERBERUS_RETRY_MAX` (`5` / `300`, enrolment backoff), `CERBERUS_INSECURE=1` +(skip TLS verification, debugging only), `CERBERUS_DRY_RUN=1` (print the `ip`/`wg` commands instead +of running them). ``` cerberus run # enroll + up + watch (the default; what S99cerberus starts) @@ -214,43 +142,40 @@ 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 +### Formerly `streq_*` -`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. +Before this repository the keys were called `streq_*` and the variables `STREQ_*`. Cameras installed +then keep working, with nothing to do: -**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. +- on start the agent copies its own keys — device key, claim code, join material — from `streq_*` + to `cerberus_*`, checks each copy, and from then on writes only the new names; +- the `streq_*` copies are **left in place**, so an older `S99cerberus`, or an older binary someone + rolls back to, still finds a working camera; +- `S99cerberus` reads `cerberus_` and falls back to `streq_`, so settings written by the + old instructions still apply; `STREQ_*` variables are still accepted; +- forgetting an enrolment (a revoked node), `cerberus clean` and `uninstall.sh` remove both names, so + an old copy can never bring a revoked node back; +- once there is nothing left to copy, a start writes nothing to flash. -`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. +## How it works -## The control plane: REST, not the hub +### What lives where -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: +Device key, node id, overlay address, hub endpoint and key, allowed IPs, the network's subnet, the +hub's overlay address and the claim code — all in the U-Boot environment. -| 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 | +**The key is there too, deliberately.** On a camera `/etc` is squashfs or an overlay on a partition a +reflash overwrites, while the node id in the U-Boot env survives it. A camera that kept its node id +and lost its key would come back with an identity the hub knows and no key to prove it — and +enrolment is skipped whenever a node id is stored. Key and node id have to live and die together. +`wg set private-key` takes a path, so for exactly one call the key is written to `/tmp` (0600) and +deleted straight after; it never touches the rootfs. -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 control plane: REST -### The back-channel: the heartbeat answer +Full nodes hold a SignalR connection to the coordinator. A C client for that is not this kind of +program, so the camera uses the REST twins instead: a heartbeat every `CERBERUS_HEARTBEAT` seconds, +and the answer to that heartbeat is its whole back-channel: ```jsonc { @@ -264,48 +189,92 @@ instead of knocking with a dead node id forever. ``` 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`. +moves or changes its key, the camera rewrites its store and reprograms the interface by itself. +`Restart` re-execs the agent; `Reboot` is `sync` and `reboot`. A `404` on the heartbeat means the +coordinator no longer knows the node: the agent drops its enrolment and stops, so the next start +enrols afresh instead of knocking with a dead node id forever. ### 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: +A fresh handshake does not mean a working tunnel: WireGuard re-handshakes every two minutes while +nothing crosses it — a route someone took, an interface somebody else reused, a hub that forwards +nothing. Only a packet that comes back says the tunnel works. The recovery ladder goes up in cost, +and the camera climbs 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 | +| handshake older than `CERBERUS_STALE_AFTER` | the peer is re-poked (which also re-resolves the hub's name) | +| `CERBERUS_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. +`cerberus run` does not die of an unreachable coordinator either: init on a camera starts it once and +never looks again, so enrolment is retried with a doubling pause for as long as it takes. + +Video does not pass through the agent: the relay pulls `rtsp://{overlayIp}:554/stream0` straight from +the camera through the tunnel. There is no peer-to-peer on this agent — the camera stays on the hub +path, which is the path that always works. + +## Building + +```sh +make # native, against the system mbedTLS (3.x or 2.28 LTS) +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 +``` + +The release binaries come from `ci/build.sh`, which needs only Docker: it fetches a +[Bootlin](https://toolchains.bootlin.com) musl toolchain and the mbedTLS source (both pinned by +sha256), builds statically, asserts the ELF machine, and then **runs** each binary under QEMU and +checks its X25519 key against `wg pubkey`: -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. +```sh +ci/build.sh # arm, mipsel, aarch64, x86_64 → dist/cerberus- +ci/build.sh mipsel # one of them +ci/assemble.sh # + installers, init script, SHA256SUMS → a release in dist/ +``` + +Inside a firmware build, with the firmware's own toolchain: + +```sh +make CC=mipsel-openipc-linux-musl-gcc MBEDTLS= +``` -## What it does not do +## Tests -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. +```sh +make test # Alpine: musl + mbedTLS 3.x +CERBERUS_TEST_IMAGE=debian:bookworm-slim tests/run.sh # glibc + mbedTLS 2.28 +``` -Video does not pass through the agent at all: the relay pulls `rtsp://{overlayIp}:554/stream0` -straight from the camera through the tunnel. +The suite needs only Docker. It builds the agent with `-Werror` and runs it against a fake +coordinator over real TLS: identity, TLS verification, enrolment, the back-channel (commands, a moved +or re-keyed hub, an outage, a revoked node), start-up backoff, the `streq_*` migration against a fake +U-Boot environment, the installer end to end from a release laid out on disk — and a real kernel +WireGuard tunnel to a hub in its own network namespace, including a stolen route the in-tunnel probe +has to notice and repair. The data-plane part needs kernel WireGuard on the host; `REQUIRE_WG=1` +makes its absence a failure, which is how CI runs it. + +## CI and releases + +Every push and pull request runs shellcheck, the suite on both mbedTLS generations, and the four +release builds. A `v*` tag publishes those same binaries, with the installers, the init script and +`SHA256SUMS`, as a GitHub release; the version compiled into the binaries comes from the tag. + +```sh +git tag v0.2.0 && git push origin v0.2.0 +``` ## 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`. +[The Prosperity Public License 3.0.0](LICENSE.md): free for noncommercial use, with a 30-day trial +for commercial use. See [LICENSE.md](LICENSE.md) for the full terms. + +## Support + +- Questions and bug reports: [GitHub Issues](https://github.com/OpenIPC/cerberus/issues) +- Documentation: [openipc.org](https://openipc.org) + +[![Telegram](https://openipc.org/images/telegram_button.svg)](https://openipc.org/our-channels) + +[logo]: https://raw.githubusercontent.com/OpenIPC/.github/refs/heads/main/assets/openipc-logo-black.svg diff --git a/README.ru.md b/README.ru.md index 511e9d3..1f950ba 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,206 +1,133 @@ -# cerberus +![OpenIPC logo][logo] -[English version](README.md) +# OpenIPC Cerberus -Облачный агент для камер OpenIPC: маленький демон на C, который живёт **внутри прошивки камеры** и -включает камеру в приватную WireGuard-сеть — так, что видео, ONVIF, настройки и SSH доступны откуда -угодно, а между камерой и облаком нет ничего, кроме туннеля. +[English](README.md) | **Русский** | [简体中文](README.zh-CN.md) + +[![CI](https://github.com/OpenIPC/cerberus/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenIPC/cerberus/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/OpenIPC/cerberus)](https://github.com/OpenIPC/cerberus/releases/latest) +[![License](https://img.shields.io/badge/license-Prosperity%203.0-blue)](LICENSE.md) +[![Platform](https://img.shields.io/badge/platform-OpenIPC-brightgreen)](https://openipc.org) + +Облачный агент для камер с прошивкой [OpenIPC](https://openipc.org): маленький демон на C, который +живёт **внутри камеры** и включает её в приватную WireGuard-сеть — так, что видео, ONVIF, настройки и +SSH доступны откуда угодно, а между камерой и облаком нет ничего, кроме туннеля. Делает ровно пять вещей: 1. заводит ключевую пару X25519 и claim-код — один раз, на первом старте; 2. энроллится в координатор по TLS (`POST /enroll`) и забирает join-материал; -3. кладёт ключ, claim-код и join-материал в **U-Boot environment** через `fw_setenv` (файл — - фолбэк) — единственное место на камере, которое переживает перепрошивку; -4. поднимает из него `wg0` и следит за туннелем с двух сторон: возраст хендшейка из `wg` **и** - ICMP-проба хаба *внутри* туннеля — свежий хендшейк на мёртвом пути выглядит как живой; +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**, всё остальное — libc. Агент рулит kernel WireGuard, который в +прошивке уже есть, поэтому статически весит несколько сотен килобайт, а не целый userspace-WireGuard. -Собирается против обоих поколений mbedTLS, что встречаются в поле: 3.x и 2.28 LTS — последний лежит -в Buildroot и, значит, в большинстве прошивок эпохи OpenIPC. +## Установка -Тарболлы релиза собираются в контейнерах, хосту нужен только Docker: +Одна строка по SSH на камере, с токеном энролла из панели облака (панель выдаёт эту команду уже +заполненной): ```sh -ci/build.sh musl-armv7 # OpenIPC на ARMv7: статический musl, без зависимостей -ci/build.sh armhf arm64 amd64 # обычные SBC (glibc, динамически) - # → dist/cerberus--linux-.tar.gz (+ .sha256) +curl -fsSL https://github.com/OpenIPC/cerberus/releases/latest/download/install.sh | sh -s -- tok_... ``` -**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 несёт -прошивка и несёт ли вообще. +Установщик выбирает сборку под процессор камеры, сверяет её с `SHA256SUMS` из того же релиза, ставит +`/usr/sbin/cerberus` и `/etc/init.d/S99cerberus`, пишет адрес и токен в U-Boot env и запускает агента. +Перезагрузка не нужна. Если что-то не так, он печатает короткий код — `cerberus: E13` — и страницу, где +код расшифрован: . -Посмотрите на камеру, прежде чем выбирать сборку: +Снять всё обратно: ```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 → можно линковать динамически +curl -fsSL https://github.com/OpenIPC/cerberus/releases/latest/download/uninstall.sh | sh ``` -Ingenic T20/T31 — это MIPS, а у ARMv5 нет VFP; обоим нужен тулчейн самой прошивки (Buildroot): +То же самое руками: ```sh -make CC=mipsel-openipc-linux-musl-gcc MBEDTLS= STATIC=1 -# или, если mbedTLS в прошивке есть и версия совпадает, без STATIC=1 — десятки КБ вместо сотен +# cerberus-arm, cerberus-mipsel, ... — см. «Релизы» ниже +curl -fsSL -o /usr/sbin/cerberus https://github.com/OpenIPC/cerberus/releases/latest/download/cerberus-arm +curl -fsSL -o /etc/init.d/S99cerberus https://github.com/OpenIPC/cerberus/releases/latest/download/S99cerberus +chmod +x /usr/sbin/cerberus /etc/init.d/S99cerberus +fw_setenv cerberus_api https://cloud-api.openipc.ru/v1 +fw_setenv cerberus_enrollment_token tok_... +/etc/init.d/S99cerberus restart ``` -`STATIC=1` разумен только с musl: статический glibc всё равно тянет в рантайме разделяемые -NSS-библиотеки для `getaddrinfo` — ровно того вызова, которым резолвится хаб. +Токен, выписанный в панели на одну камеру, привязывает камеру к тому, кто его выписал. С токеном +партии агент печатает claim-код (его же показывает `cerberus status`), который вводят в панели, чтобы +забрать камеру себе. -### Сколько весит +## Релизы -`-Os`, `--gc-sections`, strip; мерено по тарболлам из `ci/build.sh`: +В каждом релизе — по одному **статическому** бинарю на архитектуру процессора: musl и mbedTLS внутри, +поэтому неважно, какая libc и какой mbedTLS в конкретной прошивке и есть ли он там вообще: -| цель | байт | +| файл | на чём работает | |---|---| -| 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 -``` +| `cerberus-arm` | все ARM-камеры от ARMv5 до ARMv7 — HiSilicon, Goke, SigmaStar, Fullhan, … (soft-float, поэтому и ARMv5 без VFP) | +| `cerberus-mipsel` | MIPS-камеры — Ingenic T10, T20, T21, T31, T40, … | +| `cerberus-aarch64` | 64-битный ARM | +| `cerberus-x86_64` | виртуалки и тестовые стенды | +| `install.sh`, `uninstall.sh` | установщик и деинсталлятор | +| `S99cerberus` | init-скрипт BusyBox | +| `SHA256SUMS` | контрольные суммы всего перечисленного; установщик сверяется с ними | +| `LICENSE.md` | лицензия | + +Версии в именах файлов нет специально: `releases/latest/download/<имя>` всегда ведёт на текущий +релиз. + +| бинарь | байт | +|---|---| +| `cerberus-arm` | 570 564 | +| `cerberus-mipsel` | 660 900 | +| `cerberus-aarch64` | 587 320 | +| `cerberus-x86_64` | 575 144 | -## Запуск +Почти всё это — mbedTLS с конфигурацией по умолчанию; сам агент — около 30 КБ. -Конфиг — переменные окружения `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). +| kernel WireGuard + `wg` | весь data plane | агент бесполезен | +| `ip` | адрес и линк на `wg0` | то же | +| `fw_setenv` / `fw_printenv` | хранилище, переживающее перепрошивку | фолбэк в файл (и он не переживёт) | +| `ping` | проба внутри туннеля | проба выключается с предупреждением, присмотр за хендшейком остаётся | +| `reboot` | команда `Reboot` | команда логируется как невыполнимая | +| `curl` с TLS | установщик | скачать файлы руками | + +## Настройка + +`S99cerberus` читает U-Boot env и передаёт агенту переменными `CERBERUS_*`: + +| ключ U-Boot | переменная | по умолчанию | смысл | +|---|---|---|---| +| `cerberus_api` | `CERBERUS_API` | — | база координатора, включая `/v1` | +| `cerberus_enrollment_token` | `CERBERUS_ENROLLMENT_TOKEN` | — | одноразовый токен от владельца | +| `cerberus_hostname` | `CERBERUS_HOSTNAME` | — | имя, под которым камера появится в панели | +| `cerberus_ca` | `CERBERUS_CA` | `/etc/ssl/certs/ca-certificates.crt` | файл или каталог доверенных CA | +| `cerberus_iface` | `CERBERUS_IFACE` | `wg0` | интерфейс WireGuard | +| `cerberus_subnet` | `CERBERUS_SUBNET` | `10.80.0.0/16` | только фолбэк: подсеть сети приезжает в join-материале | +| `cerberus_heartbeat` | `CERBERUS_HEARTBEAT` | `20` | секунд между heartbeat; `0` — выключить | +| `cerberus_ping_target` | `CERBERUS_PING_TARGET` | хаб | кого пинговать внутри туннеля | +| `cerberus_ping_interval` | `CERBERUS_PING_INTERVAL` | `60` | секунд между пробами; `0` — выключить | +| `cerberus_ping_timeout` | `CERBERUS_PING_TIMEOUT` | `3` | сколько секунд ждать ответа на пробу | +| `cerberus_ping_fails` | `CERBERUS_PING_FAILS` | `3` | неотвеченных проб подряд до пересборки интерфейса | + +`cerberus_ca` важнее, чем кажется: в прошивке может не быть бандла CA вовсе, и тогда энролл падает на +сертификате координатора, а поправить это без перепрошивки нечем — укажите путь к корневому +сертификату (ISRG Root X1 для Let's Encrypt). + +Переменные, которые читает только агент: `CERBERUS_STORE` (`auto` \| `uboot` \| `file:/path`), +`CERBERUS_STALE_AFTER` (`180`, секунд без хендшейка до перетыкания пира), `CERBERUS_RETRY_MIN` / +`CERBERUS_RETRY_MAX` (`5` / `300`, отступ повторов энролла), `CERBERUS_INSECURE=1` (без проверки TLS, +только для отладки), `CERBERUS_DRY_RUN=1` (печатать команды `ip`/`wg`, а не выполнять). ``` cerberus run # enroll + up + watch (по умолчанию; это запускает S99cerberus) @@ -211,42 +138,40 @@ cerberus keygen # напечатать пару ключей (stdout — да cerberus clean # убрать агента: интерфейс, автозапуск, сохранённое и сам бинарь ``` -## Что где лежит +### Раньше — `streq_*` -`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. +До этого репозитория ключи назывались `streq_*`, а переменные — `STREQ_*`. Камеры, поставленные +тогда, продолжают работать, делать ничего не нужно: -**Ключ там же, и это осознанно.** Прямолинейнее держать его отдельным файлом 0600, но на камере -`/etc` — это squashfs или оверлей на разделе, который перепрошивка перезаписывает, а `streq_node_id` -рядом в U-Boot env её переживает. Узел вернулся бы с личностью, на которую у хаба есть пир, и без -ключа, которым её доказывают, — а энролл пропускается всегда, когда node id сохранён. Это не -«перерегистрируется», это **насмерть**. Ключ и node id обязаны жить и умирать вместе, поэтому лежат -в одном сторе. `wg set private-key` хочет путь, поэтому на время ровно одного вызова ключ -материализуется в `/tmp` (0600) и сразу удаляется; на rootfs он не пишется вообще. +- на старте агент копирует свои ключи — ключ устройства, claim-код, join-материал — из `streq_*` в + `cerberus_*`, проверяет каждую копию и дальше пишет только новые имена; +- копии `streq_*` **остаются на месте**, поэтому старый `S99cerberus` или старый бинарь, на который + кто-то откатился, по-прежнему находят рабочую камеру; +- `S99cerberus` читает `cerberus_<имя>`, а при его отсутствии — `streq_<имя>`, так что настройки по + старым инструкциям действуют; переменные `STREQ_*` тоже принимаются; +- сброс энролла (отозванный узел), `cerberus clean` и `uninstall.sh` стирают оба имени, поэтому + старая копия не может воскресить отозванный узел; +- когда копировать больше нечего, старт не пишет во флеш ничего. -`streq_network_subnet` и `streq_hub_ip` приходят с энроллом: на инстансе с арендаторами у каждой -сети свой диапазон, и узел берёт из них маску своего адреса и отдельный маршрут до хаба. Маршрут -нужен потому, что `allowed-ips` у WireGuard — это криптомаршрутизация, а не таблица ядра: без него -хендшейк проходит, а пакеты уходят мимо туннеля. +## Как это устроено -## Control plane: REST, а не хаб +### Что где лежит -Полноценные узлы держат SignalR-соединение с координатором и получают пуши карты сети. Клиент такого -на C — не тот жанр, поэтому у тонких узлов деградированный путь: +Ключ устройства, node id, overlay-адрес, адрес и ключ хаба, allowed IPs, подсеть сети, overlay-адрес +хаба и claim-код — всё в U-Boot env. -| хаб | что делает 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 -до конца времён. +**Ключ там же, и это осознанно.** На камере `/etc` — это squashfs или оверлей на разделе, который +перепрошивка перезаписывает, а node id в U-Boot env её переживает. Камера, сохранившая node id и +потерявшая ключ, вернулась бы с личностью, которую хаб знает, и без ключа, которым её доказывают, — а +энролл пропускается всегда, когда node id сохранён. Ключ и node id обязаны жить и умирать вместе. +`wg set private-key` хочет путь, поэтому на время ровно одного вызова ключ пишется в `/tmp` (0600) и +сразу удаляется; на rootfs он не попадает никогда. -### Обратный канал: ответ на heartbeat +### Control plane: REST + +Полноценные узлы держат SignalR-соединение с координатором. Клиент такого на C — не тот жанр, поэтому +камера ходит в REST-двойники: heartbeat раз в `CERBERUS_HEARTBEAT` секунд, и ответ на него — весь её +обратный канал: ```jsonc { @@ -259,50 +184,92 @@ heartbeat значит, что координатор узел больше не } ``` -`join` агент сверяет с сохранённым и применяет **только расхождение**: хаб переехал (новый VPS, -новый DNS) или сменил ключ — камера перезаписывает стор и перепрограммирует интерфейс сама. -`Restart` — это re-exec самого агента (супервизора на камере может и не быть), `Reboot` — `sync` и -`reboot`. +`join` агент сверяет с сохранённым и применяет **только расхождение**: хаб переехал или сменил ключ — +камера перезаписывает стор и перепрограммирует интерфейс сама. `Restart` — это re-exec агента, +`Reboot` — `sync` и `reboot`. `404` на heartbeat значит, что координатор узел больше не знает: агент +выбрасывает энролл и останавливается, чтобы следующий старт зашёл заново, а не долбился мёртвым node id +до конца времён. ### Присмотр за туннелем -Хендшейк и проба отвечают на разные вопросы, и ни один не следует из другого: WireGuard -переустанавливает хендшейк примерно раз в две минуты и покажет совершенно свежий, пока по туннелю не -ходит ничего — забранный маршрут, интерфейс, который занял кто-то другой, хаб, который отвечает на -хендшейк и не форвардит. Хендшейк говорит «пир на месте», и только вернувшийся пакет говорит -«туннель работает». - -Лестница восстановления — по возрастанию цены, и камера обязана пройти её сама: +Свежий хендшейк не значит рабочий туннель: WireGuard переустанавливает его раз в две минуты, даже +когда по туннелю не ходит ничего — забранный маршрут, интерфейс, который занял кто-то другой, хаб, +который ничего не форвардит. Только вернувшийся пакет говорит «туннель работает». Лестница +восстановления — по возрастанию цены, и камера проходит её сама: | что видно | что делается | |---|---| -| хендшейку больше `STREQ_STALE_AFTER` | перетыкается пир (это же и перерезолвит имя хаба) | -| `STREQ_PING_FAILS` проб подряд без ответа | интерфейс сносится и собирается заново | +| хендшейку больше `CERBERUS_STALE_AFTER` | перетыкается пир (это же и перерезолвит имя хаба) | +| `CERBERUS_PING_FAILS` проб подряд без ответа | интерфейс сносится и собирается заново | | вдвое больше — пересборка не помогла | агент перезапускает сам себя | -Камера, которая перезагружается от одного потерянного пакета, хуже камеры, которая просто лежит, — -поэтому пороги считают серии, а не события. +От недоступного координатора `cerberus run` тоже не умирает: init на камере запускает его один раз и +больше не смотрит, поэтому энролл повторяется с удваивающейся паузой сколько понадобится. + +Видео через агента не идёт: релей тянет `rtsp://{overlayIp}:554/stream0` прямо с камеры по туннелю. +P2P этот агент не делает — камера остаётся на пути через хаб, который работает всегда. + +## Сборка + +```sh +make # нативно, против системного mbedTLS (3.x или 2.28 LTS) +make STATIC=1 # статически +make VERSION=1.2.0 # версия, которую агент сообщает (по умолчанию — из main.c) +make install DESTDIR=/tmp/rootfs # /usr/sbin/cerberus + /etc/init.d/S99cerberus +``` + +Бинари релиза собирает `ci/build.sh`, которому нужен только Docker: он скачивает musl-тулчейн +[Bootlin](https://toolchains.bootlin.com) и исходники mbedTLS (оба сверяются по sha256), собирает +статически, проверяет архитектуру ELF, а затем **запускает** каждый бинарь под QEMU и сверяет его +ключ X25519 с `wg pubkey`: + +```sh +ci/build.sh # arm, mipsel, aarch64, x86_64 → dist/cerberus- +ci/build.sh mipsel # одну из них +ci/assemble.sh # + установщики, init-скрипт, SHA256SUMS → релиз в dist/ +``` + +Внутри сборки прошивки, её собственным тулчейном: + +```sh +make CC=mipsel-openipc-linux-musl-gcc MBEDTLS= +``` + +## Тесты + +```sh +make test # Alpine: musl + mbedTLS 3.x +CERBERUS_TEST_IMAGE=debian:bookworm-slim tests/run.sh # glibc + mbedTLS 2.28 +``` -Отдельно — старт. `cerberus run` **не умирает от недоступного координатора**: энролл повторяется с -удваивающейся паузой от `STREQ_RETRY_MIN` до `STREQ_RETRY_MAX`, сколько понадобится. Init на камере -запускает агента ровно один раз и больше на него не смотрит, так что выход здесь означал бы камеру, -потерянную до следующего выключения питания — из-за сети, DNS или NTP, опоздавших на несколько -секунд. Ошибки конфигурации по-прежнему завершают сразу: повторы не выдумают токен, которого не -прописали. +Нужен только Docker. Набор собирает агента с `-Werror` и гоняет его против поддельного координатора +по настоящему TLS: личность, проверка TLS, энролл, обратный канал (команды, переехавший или сменивший +ключ хаб, недоступность координатора, отозванный узел), отступ на старте, миграция `streq_*` на +поддельном U-Boot env, установщик целиком из релиза, разложенного на диске, — и настоящий +kernel-WireGuard туннель до хаба в отдельном network namespace, включая украденный маршрут, который +проба внутри туннеля должна заметить и починить. Для data plane на хосте нужен kernel WireGuard; +`REQUIRE_WG=1` делает его отсутствие провалом — так запускает CI. -## Чего он не делает +## CI и релизы -Пушей и P2P. `PathRevoked` ходит только через хаб — тонкий узел узнает об отзыве со следующим -heartbeat. Hole-punch идёт через хаб (`ReportCandidates` → `PunchNow` → `ReportPathState`) и требует -одновременности, которой опрос не даёт, поэтому этот агент остаётся на пути через хаб — ровно на том -пути, который работает всегда. +На каждый push и pull request — shellcheck, тесты на обоих поколениях mbedTLS и четыре сборки релиза. +Тег `v*` публикует эти же бинари вместе с установщиками, init-скриптом и `SHA256SUMS` как GitHub +release; версия, вкомпилированная в бинари, берётся из тега. -Видео через агента не идёт вовсе: релей тянет `rtsp://{overlayIp}:554/stream0` прямо с камеры по -туннелю. +```sh +git tag v0.2.0 && git push origin v0.2.0 +``` ## Лицензия -[The Prosperity Public License 3.0.0](LICENSE.md), © OpenIPC. Бесплатно для некоммерческого -использования — личного, любительского, исследовательского и некоммерческими организациями — и -тридцать дней пробы для коммерческого; коммерческое использование сверх пробы эта лицензия не покрывает. Текст лицензии -обязан идти с каждой копией, бинарной в том числе, поэтому `LICENSE.md` лежит и в тарболлах релиза. +[The Prosperity Public License 3.0.0](LICENSE.md): бесплатно для некоммерческого использования, +30 дней пробы для коммерческого. Полные условия — в [LICENSE.md](LICENSE.md). + +## Поддержка + +- Вопросы и баг-репорты: [GitHub Issues](https://github.com/OpenIPC/cerberus/issues) +- Документация: [openipc.org](https://openipc.org) + +[![Telegram](https://openipc.org/images/telegram_button.svg)](https://openipc.org/our-channels) + +[logo]: https://raw.githubusercontent.com/OpenIPC/.github/refs/heads/main/assets/openipc-logo-black.svg diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..9f83c76 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,256 @@ +![OpenIPC logo][logo] + +# OpenIPC Cerberus + +[English](README.md) | [Русский](README.ru.md) | **简体中文** + +[![CI](https://github.com/OpenIPC/cerberus/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenIPC/cerberus/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/OpenIPC/cerberus)](https://github.com/OpenIPC/cerberus/releases/latest) +[![License](https://img.shields.io/badge/license-Prosperity%203.0-blue)](LICENSE.md) +[![Platform](https://img.shields.io/badge/platform-OpenIPC-brightgreen)](https://openipc.org) + +适用于运行 [OpenIPC](https://openipc.org) 固件摄像机的云代理:一个用 C 编写的小型守护进程,**运行在摄像机内部**, +将摄像机接入私有 WireGuard 网络。这样无论身在何处,都能访问摄像机的视频、ONVIF、设置和 SSH, +摄像机与云之间除了隧道别无他物。 + +它只做五件事: + +1. 首次启动时生成设备的 X25519 密钥对和认领码(claim code),仅此一次; +2. 通过 TLS 向协调器注册(`POST /enroll`),获取入网材料(join material); +3. 通过 `fw_setenv` 将密钥、认领码和入网材料保存在 **U-Boot 环境变量**中——这是摄像机上唯一能在刷写固件后保留下来的地方; +4. 启动 `wg0` 并从两方面监控隧道:来自 `wg` 的握手时间,**以及**在隧道*内部*对 hub 的 ICMP 探测—— + 死链路上的新鲜握手看起来也像是活的; +5. 通过 `POST /nodes/{id}/heartbeat` 上报存活状态,并执行响应中返回的内容:变更后的入网材料和一次性命令。 + +**mbedTLS** 是唯一的依赖,其余全部是 libc。它驱动固件自带的内核 WireGuard,因此静态编译后只有几百 KB, +而不是一个用户态 WireGuard 实现。 + +## 安装 + +在摄像机上通过 SSH 执行一行命令,并附上云控制面板中的注册令牌(面板会直接给出已填好的命令): + +```sh +curl -fsSL https://github.com/OpenIPC/cerberus/releases/latest/download/install.sh | sh -s -- tok_... +``` + +安装脚本会根据摄像机的 CPU 选择对应的构建,使用同一发布中的 `SHA256SUMS` 进行校验,安装 +`/usr/sbin/cerberus` 和 `/etc/init.d/S99cerberus`,将地址和令牌写入 U-Boot 环境变量并启动代理,无需重启。 +出错时会打印一个简短的代码,例如 `cerberus: E13`,以及解释该代码的页面: +。 + +完全卸载: + +```sh +curl -fsSL https://github.com/OpenIPC/cerberus/releases/latest/download/uninstall.sh | sh +``` + +手动安装,效果相同: + +```sh +# cerberus-arm、cerberus-mipsel 等——见下文“发布” +curl -fsSL -o /usr/sbin/cerberus https://github.com/OpenIPC/cerberus/releases/latest/download/cerberus-arm +curl -fsSL -o /etc/init.d/S99cerberus https://github.com/OpenIPC/cerberus/releases/latest/download/S99cerberus +chmod +x /usr/sbin/cerberus /etc/init.d/S99cerberus +fw_setenv cerberus_api https://cloud-api.openipc.ru/v1 +fw_setenv cerberus_enrollment_token tok_... +/etc/init.d/S99cerberus restart +``` + +在面板中为单台摄像机签发的令牌会把摄像机绑定到签发者名下。使用批量令牌时,代理会打印认领码 +(`cerberus status` 也会显示),在面板中输入该认领码即可认领摄像机。 + +## 发布 + +每个发布都为每种 CPU 架构提供一个**静态**二进制文件:musl 和 mbedTLS 已编译在内,因此无论固件使用哪种 libc、 +带有哪个版本的 mbedTLS,甚至没有 mbedTLS,都没有关系: + +| 文件 | 适用于 | +|---|---| +| `cerberus-arm` | 从 ARMv5 到 ARMv7 的所有 ARM 摄像机——海思、国科、星宸、富瀚……(软浮点,因此也支持没有 VFP 的 ARMv5) | +| `cerberus-mipsel` | MIPS 摄像机——君正 T10、T20、T21、T31、T40…… | +| `cerberus-aarch64` | 64 位 ARM | +| `cerberus-x86_64` | 虚拟机和测试环境 | +| `install.sh`、`uninstall.sh` | 安装和卸载脚本 | +| `S99cerberus` | BusyBox 启动脚本 | +| `SHA256SUMS` | 以上所有文件的校验和,安装脚本据此校验 | +| `LICENSE.md` | 许可证 | + +文件名中故意不带版本号:`releases/latest/download/<文件名>` 始终指向最新发布。 + +| 二进制文件 | 字节 | +|---|---| +| `cerberus-arm` | 570 564 | +| `cerberus-mipsel` | 660 900 | +| `cerberus-aarch64` | 587 320 | +| `cerberus-x86_64` | 575 144 | + +其中绝大部分是默认配置的 mbedTLS,代理本身只有约 30 KB。 + +## 摄像机需要具备 + +| 需要 | 用途 | 缺失时 | +|---|---|---| +| 内核 WireGuard + `wg` | 整个数据平面 | 代理无法工作 | +| `ip` | `wg0` 上的地址和链路 | 同上 | +| `fw_setenv` / `fw_printenv` | 刷写固件后仍保留的存储 | 退回到文件存储(刷写后不会保留) | +| `ping` | 隧道内探测 | 探测关闭并给出警告,握手监控仍然有效 | +| `reboot` | `Reboot` 命令 | 命令记录为无法执行 | +| 支持 TLS 的 `curl` | 安装脚本 | 手动下载文件 | + +## 配置 + +`S99cerberus` 读取 U-Boot 环境变量,并以 `CERBERUS_*` 变量的形式传给代理: + +| U-Boot 键 | 变量 | 默认值 | 含义 | +|---|---|---|---| +| `cerberus_api` | `CERBERUS_API` | — | 协调器地址,包含 `/v1` | +| `cerberus_enrollment_token` | `CERBERUS_ENROLLMENT_TOKEN` | — | 所有者签发的一次性令牌 | +| `cerberus_hostname` | `CERBERUS_HOSTNAME` | — | 摄像机在面板中显示的名称 | +| `cerberus_ca` | `CERBERUS_CA` | `/etc/ssl/certs/ca-certificates.crt` | TLS 使用的 CA 文件或目录 | +| `cerberus_iface` | `CERBERUS_IFACE` | `wg0` | WireGuard 接口 | +| `cerberus_subnet` | `CERBERUS_SUBNET` | `10.80.0.0/16` | 仅作后备:网络子网随入网材料下发 | +| `cerberus_heartbeat` | `CERBERUS_HEARTBEAT` | `20` | 心跳间隔(秒),`0` 表示关闭 | +| `cerberus_ping_target` | `CERBERUS_PING_TARGET` | hub | 隧道内探测的目标 | +| `cerberus_ping_interval` | `CERBERUS_PING_INTERVAL` | `60` | 探测间隔(秒),`0` 表示关闭 | +| `cerberus_ping_timeout` | `CERBERUS_PING_TIMEOUT` | `3` | 单次探测等待的秒数 | +| `cerberus_ping_fails` | `CERBERUS_PING_FAILS` | `3` | 连续多少次探测无响应后重建接口 | + +`cerberus_ca` 比看上去更重要:固件可能根本不带 CA 证书包,此时注册会因协调器证书校验失败而中断, +除了重新刷写固件之外无法修复——请将其指向根证书(Let's Encrypt 使用 ISRG Root X1)。 + +仅由代理读取的变量:`CERBERUS_STORE`(`auto` \| `uboot` \| `file:/path`)、`CERBERUS_STALE_AFTER` +(`180`,多少秒没有握手后重新设置对端)、`CERBERUS_RETRY_MIN` / `CERBERUS_RETRY_MAX`(`5` / `300`, +注册重试的退避时间)、`CERBERUS_INSECURE=1`(跳过 TLS 校验,仅用于调试)、`CERBERUS_DRY_RUN=1` +(只打印 `ip`/`wg` 命令而不执行)。 + +``` +cerberus run # enroll + up + watch(默认;S99cerberus 启动的就是它) +cerberus enroll # 仅注册,幂等 +cerberus up # 用已保存的材料启动接口 +cerberus status # 已保存的内容、握手时间、隧道是否有响应 +cerberus keygen # 打印密钥对(stdout 为数据,诊断信息输出到 stderr) +cerberus clean # 移除代理:接口、启动脚本、已保存的材料以及二进制文件本身 +``` + +### 以前的 `streq_*` + +在本仓库之前,这些键名为 `streq_*`,变量名为 `STREQ_*`。当时安装的摄像机无需任何操作即可继续工作: + +- 启动时,代理会把自己的键——设备密钥、认领码、入网材料——从 `streq_*` 复制到 `cerberus_*`, + 逐一校验,此后只写入新名称; +- `streq_*` 副本**保留不动**,因此旧版 `S99cerberus` 或回退到的旧版二进制文件仍能找到一台可用的摄像机; +- `S99cerberus` 读取 `cerberus_<名称>`,不存在时回退到 `streq_<名称>`,因此按旧说明写入的设置依然有效; + `STREQ_*` 变量同样被接受; +- 注销(节点被撤销)、`cerberus clean` 和 `uninstall.sh` 会同时删除两种名称,因此旧副本永远无法让已撤销的节点复活; +- 当没有需要复制的内容时,启动过程不会向闪存写入任何东西。 + +## 工作原理 + +### 数据存放位置 + +设备密钥、节点 ID、overlay 地址、hub 地址和密钥、allowed IPs、网络子网、hub 的 overlay 地址以及认领码—— +全部保存在 U-Boot 环境变量中。 + +**密钥也放在那里,这是有意为之。** 在摄像机上,`/etc` 是 squashfs 或刷写固件时会被覆盖的分区上的 overlay, +而 U-Boot 环境变量中的节点 ID 能在刷写后保留。如果摄像机保留了节点 ID 却丢失了密钥,它会带着一个 hub +认识的身份回来,却没有能证明这一身份的密钥——而只要保存了节点 ID,就会跳过注册。密钥和节点 ID 必须同生共死。 +`wg set private-key` 需要的是文件路径,因此密钥只在一次调用期间写入 `/tmp`(0600),随即删除,从不写入 rootfs。 + +### 控制平面:REST + +完整节点与协调器之间保持 SignalR 连接。用 C 实现这样的客户端并不适合这个程序,因此摄像机改用对应的 REST +接口:每隔 `CERBERUS_HEARTBEAT` 秒发送一次心跳,而心跳的响应就是它全部的反向通道: + +```jsonc +{ + "command": "None", // None | Restart | Reboot —— 一次性命令,只下发一次 + "join": { // 当前的入网材料,总是完整的,而不是增量 + "kind": "Wireguard", "overlayIp": "10.80.0.7", + "hubEndpoint": "hub.example:51820", "hubPublicKey": "base64…", "allowedIps": "10.80.0.7/32" + }, + "mapVersion": 42 +} +``` + +代理将 `join` 与已保存的内容比较,**只应用差异**:当 hub 迁移或更换密钥时,摄像机会自行更新存储并重新配置接口。 +`Restart` 会重新执行代理本身;`Reboot` 即 `sync` 加 `reboot`。心跳返回 `404` 表示协调器已不再认识该节点: +代理会丢弃注册信息并停止,以便下次启动时重新注册,而不是永远用一个失效的节点 ID 反复请求。 + +### 隧道监控 + +握手新鲜并不代表隧道可用:即使隧道中没有任何流量,WireGuard 也会每两分钟重新握手一次——路由被抢占、 +接口被他人占用、hub 不转发任何数据。只有返回的数据包才能说明隧道可用。恢复措施按代价从低到高排列, +摄像机会自行逐级执行: + +| 观察到的情况 | 采取的措施 | +|---|---| +| 握手时间超过 `CERBERUS_STALE_AFTER` | 重新设置对端(同时重新解析 hub 的域名) | +| 连续 `CERBERUS_PING_FAILS` 次探测无响应 | 拆除并重建接口 | +| 达到两倍次数——重建无效 | 代理重新执行自身 | + +协调器不可达时 `cerberus run` 也不会退出:摄像机上的 init 只启动它一次,此后不再关注,因此注册会以倍增的间隔 +不断重试,直到成功。 + +视频不经过代理:中继服务器通过隧道直接从摄像机拉取 `rtsp://{overlayIp}:554/stream0`。该代理不做点对点连接—— +摄像机始终走经由 hub 的路径,而这条路径总是可用的。 + +## 构建 + +```sh +make # 本机构建,链接系统的 mbedTLS(3.x 或 2.28 LTS) +make STATIC=1 # 静态构建 +make VERSION=1.2.0 # 代理上报的版本(默认取自 main.c) +make install DESTDIR=/tmp/rootfs # /usr/sbin/cerberus + /etc/init.d/S99cerberus +``` + +发布用的二进制文件由 `ci/build.sh` 构建,只需要 Docker:它会下载 [Bootlin](https://toolchains.bootlin.com) +的 musl 工具链和 mbedTLS 源码(均按 sha256 校验),进行静态构建,检查 ELF 架构,然后在 QEMU 下**实际运行** +每个二进制文件,并将其 X25519 密钥与 `wg pubkey` 的结果进行比对: + +```sh +ci/build.sh # arm、mipsel、aarch64、x86_64 → dist/cerberus- +ci/build.sh mipsel # 其中之一 +ci/assemble.sh # + 安装脚本、启动脚本、SHA256SUMS → dist/ 中的完整发布 +``` + +在固件构建环境中,使用固件自带的工具链: + +```sh +make CC=mipsel-openipc-linux-musl-gcc MBEDTLS= +``` + +## 测试 + +```sh +make test # Alpine:musl + mbedTLS 3.x +CERBERUS_TEST_IMAGE=debian:bookworm-slim tests/run.sh # glibc + mbedTLS 2.28 +``` + +测试只需要 Docker。测试套件使用 `-Werror` 构建代理,并通过真实的 TLS 让它与一个模拟协调器交互:身份、TLS 校验、 +注册、反向通道(命令、迁移或更换密钥的 hub、协调器不可用、节点被撤销)、启动时的退避、在模拟 U-Boot 环境上的 +`streq_*` 迁移、从磁盘上的发布目录端到端运行安装脚本——以及一条连接到独立网络命名空间中 hub 的真实内核 WireGuard +隧道,包括隧道内探测必须发现并修复的被抢占路由。数据平面部分需要宿主机支持内核 WireGuard;`REQUIRE_WG=1` +会将其缺失视为失败,CI 就是这样运行的。 + +## CI 与发布 + +每次 push 和 pull request 都会运行 shellcheck、两代 mbedTLS 上的测试套件以及四个发布构建。推送 `v*` 标签后, +这些二进制文件会连同安装脚本、启动脚本和 `SHA256SUMS` 一起作为 GitHub release 发布;编译进二进制文件的版本号取自标签。 + +```sh +git tag v0.2.0 && git push origin v0.2.0 +``` + +## 许可证 + +[The Prosperity Public License 3.0.0](LICENSE.md):非商业用途免费,商业用途可试用 30 天。完整条款见 +[LICENSE.md](LICENSE.md)。 + +## 支持 + +- 问题与缺陷反馈:[GitHub Issues](https://github.com/OpenIPC/cerberus/issues) +- 文档:[openipc.org](https://openipc.org) + +[![Telegram](https://openipc.org/images/telegram_button.svg)](https://openipc.org/our-channels) + +[logo]: https://raw.githubusercontent.com/OpenIPC/.github/refs/heads/main/assets/openipc-logo-black.svg diff --git a/S99cerberus b/S99cerberus index 6ca4e9a..5fe9fa1 100755 --- a/S99cerberus +++ b/S99cerberus @@ -1,43 +1,45 @@ #!/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. +# where cerberus keeps the join material it gets back from the coordinator, so a camera is +# provisioned with `fw_setenv` and a restart, without touching the read-only rootfs. 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)" +# cerberus_, or streq_ — what the keys were called before the agent was renamed. A +# missing key is harmless: an empty variable means "the default" to the agent. +uenv() { + fw_printenv -n "cerberus_$1" 2>/dev/null || fw_printenv -n "streq_$1" 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} +export CERBERUS_API="$(uenv api)" +export CERBERUS_ENROLLMENT_TOKEN="$(uenv enrollment_token)" +export CERBERUS_HOSTNAME="$(uenv hostname)" +# A firmware may carry no CA bundle at all; then enrolment fails on the coordinator's certificate +# and nothing short of a reflash fixes it. Point cerberus_ca at the root certificate instead. +export CERBERUS_CA="$(uenv ca)" +export CERBERUS_KIND="$(uenv kind)" +export CERBERUS_SUBNET="$(uenv subnet)" +export CERBERUS_HEARTBEAT="$(uenv heartbeat)" +export CERBERUS_PING_TARGET="$(uenv ping_target)" +export CERBERUS_PING_INTERVAL="$(uenv ping_interval)" +export CERBERUS_PING_TIMEOUT="$(uenv ping_timeout)" +export CERBERUS_PING_FAILS="$(uenv ping_fails)" +CERBERUS_IFACE=$(uenv iface) +export CERBERUS_IFACE=${CERBERUS_IFACE:-wg0} case "$1" in start) - [ -n "$STREQ_API" ] || { echo "streq: not provisioned (fw_setenv streq_api ...)"; exit 0; } + [ -n "$CERBERUS_API" ] || { echo "cerberus: not provisioned (fw_setenv cerberus_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 + # The agent may never have got as far as the interface; for init, stopping a service that + # is not up is still a success. + ip link del dev "$CERBERUS_IFACE" 2>/dev/null || true ;; restart|reload) "$0" stop diff --git a/bootstrap.sh b/bootstrap.sh deleted file mode 100755 index 29dcb1f..0000000 --- a/bootstrap.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/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/assemble.sh b/ci/assemble.sh new file mode 100755 index 0000000..d22b717 --- /dev/null +++ b/ci/assemble.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Lays a release out next to the binaries ci/build.sh left in $OUT: the installer and the uninstaller, +# the init script, the license, and SHA256SUMS over everything a camera downloads. The installer reads +# the sums from the same release, so a binary and the sum it is checked against always travel together. +# +# Usage: ci/assemble.sh (OUT=dist by default) +set -euo pipefail + +cd "$(dirname "$0")/.." +OUT=${OUT:-dist} + +ls "$OUT"/cerberus-* >/dev/null 2>&1 || { echo "no $OUT/cerberus-* — run ci/build.sh first"; exit 1; } + +cp install.sh uninstall.sh S99cerberus LICENSE.md "$OUT/" +chmod 0755 "$OUT"/cerberus-* "$OUT/install.sh" "$OUT/uninstall.sh" "$OUT/S99cerberus" + +( + cd "$OUT" + # Plain names, no "*" binary marker: the installer matches on the bare name. + for f in cerberus-* install.sh uninstall.sh S99cerberus; do + printf '%s %s\n' "$(sha256sum "$f" | cut -d' ' -f1)" "$f" + done >SHA256SUMS + cat SHA256SUMS +) diff --git a/ci/build.sh b/ci/build.sh index 9763d2a..2028b20 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -1,57 +1,50 @@ #!/usr/bin/env bash -# Package the on-camera agent for real hardware. +# Builds the release binaries: one static musl binary per CPU architecture. # -# 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. +# Static musl is the whole point. A camera's firmware may be musl or uClibc or glibc, may carry +# mbedTLS 2.28, 3.x or none at all — a static binary does not care, so one build per architecture +# runs on every camera of that architecture, whatever SoC and firmware it has: # -# 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. +# arm ARMv5TE, soft-float EABI — runs on every ARM camera from ARMv5 (no VFP) to ARMv7 +# (HiSilicon, Goke, SigmaStar, Fullhan, ...). Floating point never matters here: the +# crypto is integer, and soft-float code runs on a CPU with an FPU just as well. +# mipsel MIPS32 little-endian — Ingenic T10/T20/T21/T31/T40 and other MIPS cameras. +# aarch64 64-bit ARM. +# x86_64 VMs and test boxes. # -# 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 +# Everything happens in a Debian container, so the host needs only Docker: the Bootlin musl +# toolchain and the mbedTLS source are downloaded once into .cache/ and checked against pinned +# sha256 sums. Each binary is then *run* under QEMU in the same container — `--help`, and a keypair +# compared with what `wg pubkey` derives — because a binary for the right ELF machine can still die +# on the first instruction its CPU does not have, and that is invisible to `file`. +# +# Usage: ci/build.sh # all four +# ci/build.sh arm mipsel # a subset # 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: +# the agent reports to the coordinator. With no tag in reach the binary keeps the version in main.c. # -# make CC=arm-openipc-linux-musleabi-gcc MBEDTLS= STATIC=1 +# Output: $OUT/cerberus-. Names carry no version on purpose: the installer fetches +# releases/latest/download/cerberus-, and that address must not change between releases. set -euo pipefail cd "$(dirname "$0")/.." ARCHES=("$@") -[ ${#ARCHES[@]} -gt 0 ] || ARCHES=(arm64) +[ ${#ARCHES[@]} -gt 0 ] || ARCHES=(arm mipsel aarch64 x86_64) OUT=${OUT:-dist} +CACHE=${CACHE:-.cache} 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} + +TOOLCHAIN_RELEASE=stable-2025.08-1 +MBEDTLS_VERSION=3.6.7 +MBEDTLS_SHA256=a7e8bcbec0e6f761b4af24f25677626b35f762f68eef79c08677a363212d11f6 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" +mkdir -p "$OUT" "$CACHE" # Docker on Windows wants a native path for -v, and MSYS would rewrite the container-side one. host_path() { @@ -60,162 +53,87 @@ host_path() { *) (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 ;; + arm) + tc=armv5-eabi; tc_sha=8cdb4ad70c6b5a66427fa3315fe3ddde1c19c90232674dec59045e04d2a36cf1 + qemu="qemu-arm-static"; want='ARM, EABI5' ;; + mipsel) + tc=mips32el; tc_sha=12a5e2f50b94b5c619730914db380ce9679798fb3c7460a40d480f77bb50628f + qemu="qemu-mipsel-static"; want='LSB executable, MIPS, MIPS32' ;; + aarch64) + tc=aarch64; tc_sha=defba831ffa1175236f137069333e21ed46d4d19feb5080a90cf248b6fc2cb08 + qemu="qemu-aarch64-static"; want='ARM aarch64' ;; + x86_64) + tc=x86-64; tc_sha=09fca3aa89540f1b01b5f4210d488cbeb00f522044c53e9989b1dd8a38076912 + qemu=; want='x86-64' ;; + *) echo "unknown arch '$arch' (want arm | mipsel | aarch64 | x86_64)"; 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 + echo "==> cerberus-$arch (Bootlin $tc musl $TOOLCHAIN_RELEASE, mbedTLS $MBEDTLS_VERSION, version ${VERSION:-from main.c})" # -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 + -v "$(host_path .):/src:ro" \ + -v "$(host_path "$CACHE"):/cache" \ + -v "$(host_path "$OUT"):/out" \ + -e ARCH="$arch" -e TC="$tc" -e TC_SHA="$tc_sha" -e TC_RELEASE="$TOOLCHAIN_RELEASE" \ + -e MBEDTLS_VERSION="$MBEDTLS_VERSION" -e MBEDTLS_SHA256="$MBEDTLS_SHA256" \ + -e QEMU="$qemu" -e WANT="$want" -e VERSION="$VERSION" \ + "$IMAGE" bash -s <<'BUILD' +set -euo pipefail 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 +apt-get update -qq >/dev/null +apt-get install -y -qq --no-install-recommends ca-certificates curl xz-utils bzip2 make file \ + qemu-user-static wireguard-tools >/dev/null + +fetch() { # fetch + if [ ! -f "/cache/$3" ] || ! echo "$2 /cache/$3" | sha256sum -c --quiet - 2>/dev/null; then + curl -fsSL --retry 3 -o "/cache/$3.part" "$1" + echo "$2 /cache/$3.part" | sha256sum -c --quiet - + mv "/cache/$3.part" "/cache/$3" 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 +tarball="$TC--musl--$TC_RELEASE.tar.xz" +fetch "https://toolchains.bootlin.com/downloads/releases/toolchains/$TC/tarballs/$tarball" "$TC_SHA" "$tarball" +fetch "https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-$MBEDTLS_VERSION/mbedtls-$MBEDTLS_VERSION.tar.bz2" \ + "$MBEDTLS_SHA256" "mbedtls-$MBEDTLS_VERSION.tar.bz2" - cat >"$stage/README.md" </dev/null +cp /build/mbedtls/library/*.a /build/prefix/lib/ +cp -r /build/mbedtls/include /build/prefix/ -\`\`\`sh -./install.sh -fw_setenv streq_api https://api.example.com/v1 -fw_setenv streq_enrollment_token tok_... -reboot -\`\`\` +cp /src/main.c /src/Makefile /build/ +cd /build +make CC="$CC" STRIP="${CROSS}strip" MBEDTLS=/build/prefix STATIC=1 VERSION="$VERSION" EXTRA_CFLAGS=-Werror -$LIBC_NOTE +# The wrong ELF machine, or a dynamic binary, is the failure worth catching here — and it is +# invisible in a build log. +file cerberus +file cerberus | grep -q "$WANT" +file cerberus | grep -q "statically linked" -Full guide, variables and troubleshooting: https://cloud.openipc.ru/docs/openipc-camera/ -Source: https://github.com/OpenIPC/cerberus +# ...and so is a binary that is the right machine and still cannot run. Run it. +run() { if [ -n "$QEMU" ]; then "$QEMU" ./cerberus "$@"; else ./cerberus "$@"; fi; } +run --help 2>&1 | head -1 +CERBERUS_STORE=file:/tmp/smoke.env run keygen >/tmp/keys 2>/dev/null +[ "$(sed -n 1p /tmp/keys | wg pubkey)" = "$(sed -n 2p /tmp/keys)" ] \ + || { echo "the X25519 key this binary derives is not the one WireGuard derives"; exit 1; } +echo "keygen on $ARCH matches wg pubkey" -Licensed under the Prosperity Public License 3.0.0 — see LICENSE.md: free for noncommercial use, -a thirty-day trial for commercial use. -EOF +cp cerberus "/out/cerberus-$ARCH" +BUILD - # Packed inside a container, from a copy that is not on a bind mount: a Windows host has no - # execute bit to give, and a tarball whose binary unpacks as 0644 fails at `./cerberus` — the - # first thing anyone types after unpacking. - MSYS_NO_PATHCONV=1 docker run -i --rm \ - -v "$(host_path "$OUT"):/out" \ - -e STAGE="$(basename "$stage")" \ - "$IMAGE" sh -s <<'PACK' -set -e -cd /tmp -cp -r "/out/$STAGE" . -cd "$STAGE" -chmod 0755 cerberus install.sh S99cerberus -chmod 0644 README.md LICENSE.md -cd /tmp -tar -czf "/out/$STAGE.tar.gz" "$STAGE" -PACK - rm -rf "$stage" - (cd "$OUT" && sha256sum "cerberus-$NAME_VERSION-linux-$arch.tar.gz" \ - >"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))" + echo " $OUT/cerberus-$arch ($(wc -c <"$OUT/cerberus-$arch") bytes)" done diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f263676 --- /dev/null +++ b/install.sh @@ -0,0 +1,105 @@ +#!/bin/sh +set -eu + +DIST="${CERBERUS_DIST:-${STREQ_DIST:-https://github.com/OpenIPC/cerberus/releases/latest/download}}" +API="${CERBERUS_API:-${STREQ_API:-https://cloud-api.openipc.ru/v1}}" +DOCS="${CERBERUS_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') || chip="" +machine=$(uname -m) + +case "$machine" in + aarch64|arm64) arch=aarch64 ;; + arm*) arch=arm ;; + mips*) + if [ "$(dd if=/bin/sh bs=1 skip=5 count=1 2>/dev/null | od -An -tx1 | tr -d ' ')" = 01 ]; then + arch=mipsel + else + arch="" + fi + ;; + x86_64|amd64) arch=x86_64 ;; + *) arch="" ;; +esac + +say "chip ${chip:-?}, cpu $machine" + +if [ -z "$arch" ]; then + curl -fsL -m 5 -o /dev/null -X POST -H 'Content-Type: application/json' \ + -d "{\"chip\":\"${chip:-$machine}\"}" "$API/builds/missing" 2>/dev/null || true + die 13 +fi + +tmp="/tmp/cerberus.$$" +rc="/tmp/S99cerberus.$$" +trap 'rm -f "$tmp" "$tmp.sums" "$rc"' EXIT INT TERM + +curl -fsL -o "$tmp" "$DIST/cerberus-$arch" || die 14 +curl -fsL -o "$rc" "$DIST/S99cerberus" || die 14 + +if command -v sha256sum >/dev/null 2>&1; then + curl -fsL -o "$tmp.sums" "$DIST/SHA256SUMS" || die 15 + for pair in "$tmp:cerberus-$arch" "$rc:S99cerberus"; do + file=${pair%%:*} + name=${pair#*:} + want=$(awk -v n="$name" '$2 == n || $2 == "*" n { print $1 }' "$tmp.sums") + have=$(sha256sum "$file" | cut -d' ' -f1) + [ -n "$want" ] && [ "$want" = "$have" ] || die 15 + done + say "cerberus-$arch sha256 ok" +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/cerberus || die 16 +chmod 0700 /etc/cerberus || die 16 +say "installed /usr/sbin/cerberus" + +if [ -z "$TOKEN" ]; then + say "E20" + say "$DOCS" + echo " fw_setenv cerberus_api '$API'" + echo " fw_setenv cerberus_enrollment_token 'tok_...'" + echo " /etc/init.d/S99cerberus restart" + exit 0 +fi + +fw_setenv cerberus_api "$API" || die 17 +fw_setenv cerberus_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 + st=$(CERBERUS_PING_INTERVAL=0 /usr/sbin/cerberus status 2>/dev/null || true) + node=$(echo "$st" | awk '$1 == "node" { print $2 }') + claim=$(echo "$st" | awk '$1 == "claim" && $2 == "code" { print $3 }') + if [ -n "$node" ] && [ "$node" != "(not" ] && [ -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/main.c b/main.c index 964bfb4..5d92766 100644 --- a/main.c +++ b/main.c @@ -56,9 +56,9 @@ /* 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) +#define sha256_once(in, len, out) mbedtls_sha256_ret((in), (len), (out), 0) #else -#define streq_sha256(in, len, out) mbedtls_sha256((in), (len), (out), 0) +#define sha256_once(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. */ @@ -118,7 +118,7 @@ static void die(const char *fmt, ...) /* ----------------------------------------------------------------- config */ -/* Bound from STREQ_* env, same convention as the .NET agent (AgentOptions). */ +/* Bound from CERBERUS_* 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 */ @@ -144,48 +144,57 @@ struct cfg { static struct cfg g_cfg; -static const char *env_or(const char *name, const char *fallback) +/* CERBERUS_, then the name this agent had before it was renamed — CERBERUS_ — so a script or an + * init file written for the old name keeps working. */ +static const char *cfg_env(const char *name, const char *fallback) { - const char *v = getenv(name); + char key[64]; + const char *v; + + snprintf(key, sizeof key, "CERBERUS_%s", name); + v = getenv(key); + if (v && *v) return v; + snprintf(key, sizeof key, "STREQ_%s", name); + v = getenv(key); 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")); + g_cfg.api = cfg_env("API", "https://api.localhost/v1"); + g_cfg.token = cfg_env("ENROLLMENT_TOKEN", ""); + g_cfg.kind = cfg_env("KIND", "Camera"); + g_cfg.hostname = cfg_env("HOSTNAME", NULL); + g_cfg.iface = cfg_env("IFACE", "wg0"); + g_cfg.subnet = cfg_env("SUBNET", "10.80.0.0/16"); + g_cfg.ca = cfg_env("CA", "/etc/ssl/certs/ca-certificates.crt"); + g_cfg.store = cfg_env("STORE", "auto"); + g_cfg.keyfile = cfg_env("KEYFILE", "/etc/streq/wg.key"); + g_cfg.insecure = atoi(cfg_env("INSECURE", "0")); + g_quiet = atoi(cfg_env("QUIET", "0")); + g_cfg.dry_run = atoi(cfg_env("DRY_RUN", "0")); + g_cfg.watch_interval = atoi(cfg_env("WATCH_INTERVAL", "30")); + g_cfg.stale_after = atoi(cfg_env("STALE_AFTER", "180")); /* The coordinator's contract: 20s by default, never faster than 5. */ - g_cfg.heartbeat = atoi(env_or("STREQ_HEARTBEAT", "20")); + g_cfg.heartbeat = atoi(cfg_env("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")); + g_cfg.ping_target = cfg_env("PING_TARGET", ""); + g_cfg.ping_interval = atoi(cfg_env("PING_INTERVAL", "60")); + g_cfg.ping_timeout = atoi(cfg_env("PING_TIMEOUT", "3")); + g_cfg.ping_fails = atoi(cfg_env("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")); + g_cfg.retry_min = atoi(cfg_env("RETRY_MIN", "5")); + g_cfg.retry_max = atoi(cfg_env("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; } @@ -225,7 +234,7 @@ 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) + if (sha256_once((const unsigned char *)in, strlen(in), d) != 0) die("sha256 failed"); for (i = 0; i < 32; i++) sprintf(out + i * 2, "%02x", d[i]); @@ -380,10 +389,26 @@ static int store_is_uboot(void) return have("fw_setenv"); /* auto */ } +#define STORE_FILE_DEFAULT "/etc/cerberus/agent.env" +#define STORE_FILE_LEGACY "/etc/streq/agent.env" + +static void mkparent(const char *path); + +/* The default file moved with the rename; one still in the old place is moved over, once. */ static const char *store_file(void) { + static const char *path; + if (!strncmp(g_cfg.store, "file:", 5)) return g_cfg.store + 5; - return "/etc/streq/agent.env"; + if (path) return path; + + path = STORE_FILE_DEFAULT; + if (access(STORE_FILE_DEFAULT, F_OK) != 0 && access(STORE_FILE_LEGACY, F_OK) == 0) { + mkparent(STORE_FILE_DEFAULT); + if (rename(STORE_FILE_LEGACY, STORE_FILE_DEFAULT) != 0) + path = STORE_FILE_LEGACY; + } + return path; } static void mkparent(const char *path) @@ -430,7 +455,8 @@ static int store_get(const char *key, char *out, size_t out_sz) return 0; } -static void store_set(const char *key, const char *value) +/* Sets `key`, or removes it when `value` is NULL. */ +static void store_put(const char *key, const char *value) { const char *path; char tmp[512], line[1024]; @@ -438,8 +464,8 @@ static void store_set(const char *key, const char *value) FILE *in, *out; if (store_is_uboot()) { - char *argv[] = { "fw_setenv", (char *)key, (char *)value, NULL }; - if (run(argv, NULL, 0) != 0) + char *argv[] = { "fw_setenv", (char *)key, (char *)value, NULL }; /* no value = unset */ + if (run(argv, NULL, 0) != 0 && value) die("fw_setenv %s failed — is /etc/fw_env.config right?", key); return; } @@ -447,11 +473,12 @@ static void store_set(const char *key, const char *value) /* 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(); + in = fopen(path, "r"); + if (!in && !value) return; 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; @@ -459,12 +486,106 @@ static void store_set(const char *key, const char *value) } fclose(in); } - fprintf(out, "%s=%s\n", key, value); + if (value) 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)); } +static void store_set(const char *key, const char *value) { store_put(key, value); } +static void store_unset(const char *key) { store_put(key, NULL); } + +/* + * The agent's keys used to be called streq_*: streq_device_key, streq_node_id, ... + * + * What is moved is only what the agent writes itself — identity and join material. Configuration + * (cerberus_api, cerberus_enrollment_token, ...) is written by people and read by the init script, + * which looks for both names, so there is nothing to move. + * + * The old keys are copied and left where they are, deliberately. An older S99cerberus, or an older + * binary someone rolls back to, still finds a camera that works; deleting them would buy a tidier + * `fw_printenv` and a camera that does not come up. Where an old copy could do harm — a node the + * coordinator has forgotten coming back to life on the next start — both names are removed together + * (store_forget_enrollment, `clean`). + * + * One read of the whole store per start, and no write at all once there is nothing left to move: on + * a camera every write is a flash erase. + */ +static const char *const g_agent_keys[] = { + "device_key", "claim_code", /* first: the identity matters most if this is interrupted */ + "node_id", "overlay_ip", "hub_endpoint", "hub_pubkey", "allowed_ips", "network_subnet", "hub_ip", + NULL +}; + +/* `key=value` out of a dump of the whole store; an empty value counts as absent, as in store_get. */ +static int dump_get(const char *dump, const char *key, char *out, size_t out_sz) +{ + size_t klen = strlen(key); + const char *p = dump; + + while (p && *p) { + if (!strncmp(p, key, klen) && p[klen] == '=') { + size_t n = strcspn(p + klen + 1, "\r\n"); + if (n + 1 > out_sz) n = out_sz - 1; + memcpy(out, p + klen + 1, n); + out[n] = 0; + return n > 0; + } + p = strchr(p, '\n'); + if (p) p++; + } + return 0; +} + +static void store_migrate_legacy(void) +{ + static char dump[32768]; + char newk[64], oldk[64], val[512], check[512]; + size_t n = 0; + int whole, i, moved = 0; + + if (store_is_uboot()) { + char *argv[] = { "fw_printenv", NULL }; + if (run(argv, dump, sizeof dump) != 0) return; + n = strlen(dump); + } else { + FILE *f = fopen(store_file(), "r"); + if (!f) return; + n = fread(dump, 1, sizeof dump - 1, f); + fclose(f); + dump[n] = 0; + } + /* An environment bigger than the buffer is read key by key instead of trusted half-read. */ + whole = n + 1 < sizeof dump; + if (whole && !strstr(dump, "streq_")) return; + + for (i = 0; g_agent_keys[i]; i++) { + snprintf(newk, sizeof newk, "cerberus_%s", g_agent_keys[i]); + snprintf(oldk, sizeof oldk, "streq_%s", g_agent_keys[i]); + if (whole ? dump_get(dump, newk, val, sizeof val) : store_get(newk, val, sizeof val)) + continue; + if (!(whole ? dump_get(dump, oldk, val, sizeof val) : store_get(oldk, val, sizeof val))) + continue; + store_set(newk, val); + if (!store_get(newk, check, sizeof check) || strcmp(check, val) != 0) + die("moving %s to %s did not stick — nothing else is touched until it does", oldk, newk); + moved++; + } + if (moved) + note("moved %d key(s) from streq_* to cerberus_*; the streq_* copies stay for older init scripts", + moved); +} + +/* Removes both spellings of an agent-written key, so the migration can never bring an old one back. */ +static void store_unset_both(const char *suffix) +{ + char key[64]; + snprintf(key, sizeof key, "cerberus_%s", suffix); + store_unset(key); + snprintf(key, sizeof key, "streq_%s", suffix); + store_unset(key); +} + /* * 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 @@ -473,22 +594,15 @@ static void store_set(const char *key, const char *value) */ 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 + static const char *suffixes[] = { + "node_id", "overlay_ip", "hub_endpoint", "hub_pubkey", "allowed_ips", "network_subnet", + "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"); + for (i = 0; suffixes[i]; i++) + store_unset_both(suffixes[i]); + warn_("enrolment discarded; restart with a fresh CERBERUS_ENROLLMENT_TOKEN to join again"); } /* ------------------------------------------------------------------- HTTPS */ @@ -597,7 +711,7 @@ static int http_post_json(const char *url, const char *body, char *resp, size_t 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)"); + warn_("TLS verification disabled (CERBERUS_INSECURE=1)"); mbedtls_ssl_conf_authmode(&c.conf, MBEDTLS_SSL_VERIFY_NONE); } else { struct stat st; @@ -607,7 +721,7 @@ static int http_post_json(const char *url, const char *body, char *resp, size_t ? 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); + die("no usable CA trust store at %s (set CERBERUS_CA)", g_cfg.ca); mbedtls_ssl_conf_authmode(&c.conf, MBEDTLS_SSL_VERIFY_REQUIRED); mbedtls_ssl_conf_ca_chain(&c.conf, &c.ca, NULL); } @@ -701,12 +815,12 @@ static int json_str(const char *json, const char *key, char *out, size_t out_sz) * * 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 + * — while `cerberus_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 + * An older key sitting in CERBERUS_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. */ @@ -717,9 +831,9 @@ static void device_key(char priv_b64[64], char pub_b64[64]) size_t olen = 0; FILE *f; - if (store_get("streq_device_key", stored, sizeof stored) && stored[0]) { + if (store_get("cerberus_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"); + die("cerberus_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; @@ -728,14 +842,14 @@ static void device_key(char priv_b64[64], char pub_b64[64]) 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); + store_set("cerberus_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); + store_set("cerberus_device_key", priv_b64); note("generated a device key into the %s store", store_is_uboot() ? "u-boot env" : store_file()); } @@ -755,7 +869,7 @@ static int key_materialise(char *path, size_t path_sz) int fd; device_key(priv_b64, pub_b64); - snprintf(path, path_sz, "%s", env_or("STREQ_KEY_TMP", "/tmp/.streq-wg.key")); + snprintf(path, path_sz, "%s", cfg_env("KEY_TMP", "/tmp/.cerberus-wg.key")); fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); if (fd < 0) { @@ -775,9 +889,9 @@ static int key_materialise(char *path, size_t path_sz) 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; + if (store_get("cerberus_claim_code", code, code_sz) && code[0]) return; claim_code_new(fresh); - store_set("streq_claim_code", fresh); + store_set("cerberus_claim_code", fresh); snprintf(code, code_sz, "%s", fresh); } @@ -792,14 +906,14 @@ static int enroll(void) 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); + if (store_get("cerberus_node_id", node, sizeof node)) { + store_get("cerberus_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"); + die("not enrolled and no CERBERUS_ENROLLMENT_TOKEN set"); device_key(priv_b64, pub_b64); claim_code_get(code, sizeof code); @@ -839,19 +953,19 @@ static int enroll(void) snprintf(allowed, sizeof allowed, "%s/32", ip); /* Подсеть своей сети и адрес хаба. Их может не быть — координатор старше арендаторов; тогда - * работает STREQ_SUBNET, как и раньше. */ + * работает CERBERUS_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); + store_set("cerberus_node_id", node); + store_set("cerberus_overlay_ip", ip); + store_set("cerberus_hub_endpoint", ep); + store_set("cerberus_hub_pubkey", hubkey); + store_set("cerberus_allowed_ips", allowed); + if (subnet[0]) store_set("cerberus_network_subnet", subnet); + if (hubip[0]) store_set("cerberus_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", @@ -876,16 +990,16 @@ static int prefix_of(const char *cidr) * it, or the camera keeps answering on an address the coordinator has already handed to somebody else. */ /* - * Подсеть, в которой стоит этот узел: из стора, если координатор её назвал, иначе STREQ_SUBNET. + * Подсеть, в которой стоит этот узел: из стора, если координатор её назвал, иначе CERBERUS_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 + * own /24 is what it may claim on the interface, and CERBERUS_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]) + if (store_get("cerberus_network_subnet", subnet, sizeof subnet) && subnet[0]) return subnet; return g_cfg.subnet; } @@ -894,7 +1008,7 @@ static const char *node_subnet(void) static const char *hub_overlay_ip(void) { static char hubip[64]; - if (store_get("streq_hub_ip", hubip, sizeof hubip) && hubip[0]) + if (store_get("cerberus_hub_ip", hubip, sizeof hubip) && hubip[0]) return hubip; return ""; } @@ -905,9 +1019,9 @@ static int link_apply(int flush) 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)) { + if (!store_get("cerberus_overlay_ip", ip, sizeof ip) || + !store_get("cerberus_hub_endpoint", ep, sizeof ep) || + !store_get("cerberus_hub_pubkey", hubkey, sizeof hubkey)) { warn_("no stored join material — run `cerberus enroll` first"); return 1; } @@ -988,7 +1102,7 @@ static long handshake_age(void) /* * Кого пинговать внутри туннеля. Хаб: единственный пир камеры и первый адрес подсети (10.80.0.0/16 → * 10.80.0.1, ровно так его поднимает хаб). Материал вступления его не называет — - * узлу он больше ни для чего не нужен — поэтому адрес выводится из подсети, а STREQ_PING_TARGET + * узлу он больше ни для чего не нужен — поэтому адрес выводится из подсети, а CERBERUS_PING_TARGET * перекрывает вывод там, где хаб стоит не на первом адресе. */ static const char *ping_target(void) @@ -1091,35 +1205,35 @@ static void apply_directive(const char *json) int rotated = 0; if (json_str(json, "hubEndpoint", ep, sizeof ep) && - (!store_get("streq_hub_endpoint", cur, sizeof cur) || strcmp(cur, ep) != 0)) { + (!store_get("cerberus_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); + store_set("cerberus_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)) { + (!store_get("cerberus_hub_pubkey", cur, sizeof cur) || strcmp(cur, hubkey) != 0)) { note("hub public key rotated"); - store_set("streq_hub_pubkey", hubkey); + store_set("cerberus_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)) { + (!store_get("cerberus_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); + store_set("cerberus_overlay_ip", ip); rotated = 1; } if (json_str(json, "allowedIps", allowed, sizeof allowed)) - store_set("streq_allowed_ips", allowed); + store_set("cerberus_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)) { + (!store_get("cerberus_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); + store_set("cerberus_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)) { + (!store_get("cerberus_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); + store_set("cerberus_hub_ip", fresh); rotated = 1; } @@ -1174,10 +1288,10 @@ static int watch(void) catch_signals(); /* повторно — безвредно; watch() бывает и точкой входа отладки */ - if (!store_get("streq_hub_endpoint", ep, sizeof ep) || - !store_get("streq_hub_pubkey", hubkey, sizeof hubkey)) + if (!store_get("cerberus_hub_endpoint", ep, sizeof ep) || + !store_get("cerberus_hub_pubkey", hubkey, sizeof hubkey)) return 1; - store_get("streq_node_id", node, sizeof node); + store_get("cerberus_node_id", node, sizeof node); snprintf(beat_url, sizeof beat_url, "%s/nodes/%s/heartbeat", g_cfg.api, node); tick = g_cfg.watch_interval; @@ -1199,8 +1313,8 @@ static int watch(void) 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); + store_get("cerberus_hub_endpoint", ep, sizeof ep); + store_get("cerberus_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()); @@ -1307,15 +1421,15 @@ 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("node %s\n", store_get("cerberus_node_id", v, sizeof v) ? v : "(not enrolled)"); + printf("device key %s\n", store_get("cerberus_device_key", v, sizeof v) ? "in the store" : "-"); + printf("overlay ip %s\n", store_get("cerberus_overlay_ip", v, sizeof v) ? v : "-"); + printf("hub endpoint %s\n", store_get("cerberus_hub_endpoint", v, sizeof v) ? v : "-"); + printf("hub public key %s\n", store_get("cerberus_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 : "-"); + store_get("cerberus_network_subnet", v, sizeof v) ? "" : " (from CERBERUS_SUBNET)"); + printf("hub overlay ip %s\n", *hub_overlay_ip() ? hub_overlay_ip() : "- (inside its own subnet)"); + printf("claim code %s\n", store_get("cerberus_claim_code", v, sizeof v) ? v : "-"); age = handshake_age(); if (age < 0) printf("handshake never\n"); else printf("handshake %lds ago\n", age); @@ -1350,12 +1464,11 @@ static int cmd_keygen(void) */ 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", + /* Both spellings of each: whatever an older install left under streq_* goes too. */ + static const char *suffixes[] = { + "node_id", "overlay_ip", "hub_endpoint", "hub_pubkey", "allowed_ips", "network_subnet", + "hub_ip", "claim_code", "device_key", "api", "enrollment_token", "hostname", "kind", "iface", + "subnet", "ca", "heartbeat", "ping_target", "ping_interval", "ping_timeout", "ping_fails", NULL }; char self[256]; @@ -1375,14 +1488,8 @@ static int cmd_clean(void) 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], ""); - } - } + for (i = 0; suffixes[i]; i++) + store_unset_both(suffixes[i]); if (!store_is_uboot()) unlink(store_file()); @@ -1402,31 +1509,32 @@ static int cmd_clean(void) static void usage(void) { fputs( - "cerberus " CERBERUS_VERSION " — on-camera Streq agent\n" + "cerberus " CERBERUS_VERSION " — on-camera cloud agent for OpenIPC\n" "\n" - "usage: cerberus [enroll|up|run|status|keygen]\n" + "usage: cerberus [enroll|up|run|status|keygen|clean]\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" + " clean remove the agent: interface, init script, stored material, the binary\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", + "config (env; the old STREQ_* names are accepted too):\n" + " CERBERUS_API coordinator base URL incl. /v1 [https://api.localhost/v1]\n" + " CERBERUS_ENROLLMENT_TOKEN one-shot token from the owner\n" + " CERBERUS_KIND node kind [Camera]\n" + " CERBERUS_IFACE WireGuard interface [wg0]\n" + " CERBERUS_SUBNET overlay CIDR routed to the hub [10.80.0.0/16]\n" + " CERBERUS_CA CA file or dir for TLS [/etc/ssl/certs/ca-certificates.crt]\n" + " CERBERUS_INSECURE=1 skip TLS verification (dev only)\n" + " CERBERUS_STORE auto | uboot | file:/path [auto] — holds the device key too\n" + " CERBERUS_KEYFILE legacy key file, migrated into the store [/etc/streq/wg.key]\n" + " CERBERUS_HEARTBEAT seconds between heartbeats; 0 disables [20]\n" + " CERBERUS_PING_TARGET in-tunnel probe target [the hub]\n" + " CERBERUS_PING_INTERVAL seconds between probes; 0 disables [60]\n" + " CERBERUS_PING_TIMEOUT seconds one probe waits [3]\n" + " CERBERUS_PING_FAILS failed probes before the interface is rebuilt [3]\n" + " CERBERUS_DRY_RUN=1 print ip/wg commands instead of running them\n", stderr); } @@ -1442,6 +1550,10 @@ int main(int argc, char **argv) cfg_load(); crypto_init(); + /* Before anything reads the store: `clean` removes both spellings itself. */ + if (strcmp(cmd, "clean") != 0) + store_migrate_legacy(); + if (!strcmp(cmd, "status")) return cmd_status(); if (!strcmp(cmd, "keygen")) return cmd_keygen(); if (!strcmp(cmd, "clean")) return cmd_clean(); diff --git a/tests/suite.sh b/tests/suite.sh index f71dd2d..08bdaf7 100755 --- a/tests/suite.sh +++ b/tests/suite.sh @@ -4,7 +4,7 @@ # 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 +# Most of it runs with CERBERUS_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. @@ -31,8 +31,9 @@ dump() { sed 's/^/ | /' "$1"; } # ------------------------------------------------------------------------------- toolchain echo "==> installing the toolchain" if command -v apk >/dev/null 2>&1; then + # openrc only for start-stop-daemon, which S99cerberus calls: BusyBox on a camera has it, Alpine's does not. apk add --no-cache gcc musl-dev make mbedtls-dev mbedtls-static wireguard-tools iproute2 \ - openssl python3 curl >/dev/null 2>&1 || { echo "apk add failed"; exit 1; } + openssl python3 curl openrc >/dev/null 2>&1 || { echo "apk add failed"; exit 1; } LIBC=musl else export DEBIAN_FRONTEND=noninteractive @@ -78,6 +79,37 @@ openssl x509 -req -in srv.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 2 \ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 2 \ -subj /CN=rogue-ca -keyout rogue.key -out rogue.crt >/dev/null 2>&1 +# A U-Boot environment in a file, behind the same two commands a camera has. `fw_setenv k` with +# no value removes k, as the real one does; every write is counted, because on a camera each one +# is a flash erase. +mkdir -p $T/fw +cat >$T/fw/fw_printenv <<'EOF' +#!/bin/sh +f=${FAKE_UBOOT:?} +touch "$f" +if [ "${1:-}" = -n ]; then + awk -v k="$2" 'index($0, k "=") == 1 { print substr($0, length(k) + 2); found = 1 } END { exit !found }' "$f" \ + || { echo "## Error: \"$2\" not defined" >&2; exit 1; } + exit 0 +fi +cat "$f" +EOF +cat >$T/fw/fw_setenv <<'EOF' +#!/bin/sh +f=${FAKE_UBOOT:?} +touch "$f" +k=$1 +shift +awk -v k="$k" 'index($0, k "=") != 1' "$f" >"$f.tmp" +[ $# -gt 0 ] && printf '%s=%s\n' "$k" "$*" >>"$f.tmp" +mv "$f.tmp" "$f" +echo "$k" >>"$f.writes" +EOF +chmod +x $T/fw/fw_printenv $T/fw/fw_setenv +uboot() { _u=$1; shift; env PATH="$T/fw:$PATH" FAKE_UBOOT="$_u" CERBERUS_STORE=uboot "$@"; } +ub() { FAKE_UBOOT="$1" $T/fw/fw_printenv -n "$2" 2>/dev/null; } +writes() { if [ -f "$1.writes" ]; then wc -l <"$1.writes" | tr -d ' '; else echo 0; fi; } + wg genkey >hub.key HUB_PUB=$(wg pubkey coordinator.log 2>&1 & COORD=$! trap 'kill $COORD 2>/dev/null' EXIT @@ -102,15 +134,15 @@ for _ in 1 2 3 4 5 6 7 8 9 10; do c -o /dev/null "$URL/_test/requests" && break; c -o /dev/null "$URL/_test/requests" || { echo "the fake coordinator never came up"; dump coordinator.log; exit 1; } A=/build/cerberus -export STREQ_API=$URL/v1 -export STREQ_CA=$T/ca.crt -export STREQ_STORE=file:$T/agent.env -export STREQ_KEYFILE=$T/no-legacy-key -export STREQ_KEY_TMP=$T/.wg.key -export STREQ_HOSTNAME=cerberus-test -export STREQ_IFACE=cbr0 -export STREQ_DRY_RUN=1 -export STREQ_PING_INTERVAL=0 +export CERBERUS_API=$URL/v1 +export CERBERUS_CA=$T/ca.crt +export CERBERUS_STORE=file:$T/agent.env +export CERBERUS_KEYFILE=$T/no-legacy-key +export CERBERUS_KEY_TMP=$T/.wg.key +export CERBERUS_HOSTNAME=cerberus-test +export CERBERUS_IFACE=cbr0 +export CERBERUS_DRY_RUN=1 +export CERBERUS_PING_INTERVAL=0 # ------------------------------------------------------------------------- identity section "device identity" @@ -118,28 +150,28 @@ $A keygen >keys 2>keygen.log PRIV=$(sed -n 1p keys) PUB=$(sed -n 2p keys) check "X25519 pubkey matches \`wg pubkey\`" "$(printf '%s\n' "$PRIV" | wg pubkey)" "$PUB" -check "the private key lives in the store" "$(sv streq_device_key agent.env)" "$PRIV" +check "the private key lives in the store" "$(sv cerberus_device_key agent.env)" "$PRIV" check "and nowhere on the rootfs" "$(yes_if test -e $T/no-legacy-key)" "no" check "the store is 0600" "$(stat -c %a agent.env)" "600" check "the key is stable across runs" "$($A keygen 2>/dev/null | sed -n 2p)" "$PUB" wg genkey >legacy.key -STREQ_STORE=file:$T/legacy.env STREQ_KEYFILE=$T/legacy.key $A keygen >legacy.keys 2>legacy.log -check "a legacy key file is migrated in" "$(sv streq_device_key legacy.env)" "$(cat legacy.key)" +CERBERUS_STORE=file:$T/legacy.env CERBERUS_KEYFILE=$T/legacy.key $A keygen >legacy.keys 2>legacy.log +check "a legacy key file is migrated in" "$(sv cerberus_device_key legacy.env)" "$(cat legacy.key)" check "unchanged" "$(sed -n 2p legacy.keys)" "$(wg pubkey broken.env -STREQ_STORE=file:$T/broken.env $A keygen >/dev/null 2>broken.log +printf 'cerberus_device_key=not-a-key\n' >broken.env +CERBERUS_STORE=file:$T/broken.env $A keygen >/dev/null 2>broken.log check "a corrupt stored key is refused" "$(yes_if grep -q 'not a base64 WireGuard key' broken.log)" "yes" # ------------------------------------------------------------------------------- TLS section "TLS (mbedTLS $MBEDTLS_VERSION)" -STREQ_STORE=file:$T/rogue.env STREQ_CA=$T/rogue.crt STREQ_ENROLLMENT_TOKEN=$TOKEN $A enroll >rogue.log 2>&1 +CERBERUS_STORE=file:$T/rogue.env CERBERUS_CA=$T/rogue.crt CERBERUS_ENROLLMENT_TOKEN=$TOKEN $A enroll >rogue.log 2>&1 check "an unrelated CA is rejected" "$(yes_if grep -q 'TLS handshake .* failed' rogue.log)" "yes" -STREQ_STORE=file:$T/sni.env STREQ_API=https://127.0.0.1:8443/v1 STREQ_ENROLLMENT_TOKEN=$TOKEN $A enroll >sni.log 2>&1 +CERBERUS_STORE=file:$T/sni.env CERBERUS_API=https://127.0.0.1:8443/v1 CERBERUS_ENROLLMENT_TOKEN=$TOKEN $A enroll >sni.log 2>&1 check "a certificate for another name is rejected" "$(yes_if grep -q 'TLS handshake .* failed' sni.log)" "yes" check "neither reached the coordinator" "$(q 'print(len(R))')" "0" -STREQ_STORE=file:$T/noca.env STREQ_CA=$T/missing.crt STREQ_ENROLLMENT_TOKEN=$TOKEN $A enroll >noca.log 2>&1 +CERBERUS_STORE=file:$T/noca.env CERBERUS_CA=$T/missing.crt CERBERUS_ENROLLMENT_TOKEN=$TOKEN $A enroll >noca.log 2>&1 check "no trust store is fatal, and says so" "$(yes_if grep -q 'no usable CA trust store' noca.log)" "yes" check "fatal errors point at the docs" "$(yes_if grep -q 'cerberus: https://' noca.log)" "yes" @@ -147,16 +179,16 @@ check "fatal errors point at the docs" "$(yes_if grep -q 'cerberus: https://' no section "enrolment" $A enroll >notoken.log 2>&1 check "no token: refuses, non-zero" "$?" "1" -check "and says why" "$(yes_if grep -q 'no STREQ_ENROLLMENT_TOKEN' notoken.log)" "yes" -STREQ_ENROLLMENT_TOKEN=tok_wrong $A enroll >badtoken.log 2>&1 +check "and says why" "$(yes_if grep -q 'no CERBERUS_ENROLLMENT_TOKEN' notoken.log)" "yes" +CERBERUS_ENROLLMENT_TOKEN=tok_wrong $A enroll >badtoken.log 2>&1 check "a wrong token is reported, non-zero" "$?" "1" check "with the coordinator's status" "$(yes_if grep -q 'enroll rejected: HTTP 401' badtoken.log)" "yes" -STREQ_ENROLLMENT_TOKEN=$TOKEN $A enroll >enroll.log 2>&1 +CERBERUS_ENROLLMENT_TOKEN=$TOKEN $A enroll >enroll.log 2>&1 check "enrolls" "$?" "0" check "over TLS" "$(yes_if grep -q '^cerberus: TLS ok' enroll.log)" "yes" -NODE=$(sv streq_node_id agent.env) -CLAIM=$(sv streq_claim_code agent.env) +NODE=$(sv cerberus_node_id agent.env) +CLAIM=$(sv cerberus_claim_code agent.env) check "node id stored" "$(echo "$NODE" | grep -c '^node_[0-9a-f]\{32\}$')" "1" check "claim code: 12 chars of the unambiguous alphabet" \ "$(yes_if sh -c "echo '$CLAIM' | grep -Eq '^[23456789ABCDEFGHJKMNPQRSTVWXYZ]{12}$'")" "yes" @@ -172,20 +204,20 @@ check "hostname" "$(q "$E; print(B['hostname'])")" "cerberus-test" check "agentVersion" "$(q "$E; print(B['agentVersion'])")" "$SRC_VERSION" check "User-Agent" "$(q "$E; print(E['userAgent'])")" "cerberus/$SRC_VERSION" -check "overlay ip stored" "$(sv streq_overlay_ip agent.env)" "$OVERLAY" -check "hub endpoint stored" "$(sv streq_hub_endpoint agent.env)" "$HUB_EP" -check "hub public key stored" "$(sv streq_hub_pubkey agent.env)" "$HUB_PUB" -check "network subnet stored" "$(sv streq_network_subnet agent.env)" "$SUBNET" -check "hub overlay ip stored" "$(sv streq_hub_ip agent.env)" "$HUB_IP" +check "overlay ip stored" "$(sv cerberus_overlay_ip agent.env)" "$OVERLAY" +check "hub endpoint stored" "$(sv cerberus_hub_endpoint agent.env)" "$HUB_EP" +check "hub public key stored" "$(sv cerberus_hub_pubkey agent.env)" "$HUB_PUB" +check "network subnet stored" "$(sv cerberus_network_subnet agent.env)" "$SUBNET" +check "hub overlay ip stored" "$(sv cerberus_hub_ip agent.env)" "$HUB_IP" check "store still 0600" "$(stat -c %a agent.env)" "600" BEFORE=$(q 'print(len(R))') -STREQ_ENROLLMENT_TOKEN=$TOKEN $A enroll >again.log 2>&1 +CERBERUS_ENROLLMENT_TOKEN=$TOKEN $A enroll >again.log 2>&1 check "a second enroll is a no-op" "$(yes_if grep -q 'already enrolled' again.log)" "yes" check "and spends nothing" "$(q 'print(len(R))')" "$BEFORE" cp agent.env enrolled.env -STREQ_ENROLLMENT_TOKEN=$TOKEN $A status >status.out 2>/dev/null +CERBERUS_ENROLLMENT_TOKEN=$TOKEN $A status >status.out 2>/dev/null check "status names the node" "$(yes_if grep -q "^node *$NODE" status.out)" "yes" check "status shows the claim code" "$(yes_if grep -q "^claim code *$CLAIM" status.out)" "yes" @@ -203,7 +235,7 @@ check "the key file does not outlive the call" "$(yes_if test -e $T/.wg.key)" "n section "heartbeat and the back-channel" runfor() { # runfor [env...] — `cerberus run` for a while, then SIGTERM _t=$1; _log=$2; shift 2 - env STREQ_HEARTBEAT=5 "$@" timeout "$_t" $A run >"$_log" 2>&1 + env CERBERUS_HEARTBEAT=5 "$@" timeout "$_t" $A run >"$_log" 2>&1 } cp enrolled.env agent.env @@ -234,13 +266,13 @@ check "an unknown command is ignored, loudly" "$(yes_if grep -q "unknown command ctl join '{"hubEndpoint":"192.0.2.9:51820"}' runfor 7 rot.log check "a moved hub is noticed" "$(yes_if grep -q "hub endpoint rotated: $HUB_EP -> 192.0.2.9:51820" rot.log)" "yes" -check "persisted" "$(sv streq_hub_endpoint agent.env)" "192.0.2.9:51820" +check "persisted" "$(sv cerberus_hub_endpoint agent.env)" "192.0.2.9:51820" check "and the interface reprogrammed" "$(yes_if grep -q 'flush dev cbr0' rot.log)" "yes" NEWHUB=$(wg genkey | wg pubkey) ctl join "{\"hubEndpoint\":\"$HUB_EP\",\"hubPublicKey\":\"$NEWHUB\",\"overlayIp\":\"10.80.7.9\"}" runfor 7 rekey.log check "a re-keyed hub is noticed" "$(yes_if grep -q 'hub public key rotated' rekey.log)" "yes" -check "persisted" "$(sv streq_hub_pubkey agent.env)" "$NEWHUB" +check "persisted" "$(sv cerberus_hub_pubkey agent.env)" "$NEWHUB" check "a new overlay address replaces the old" "$(yes_if grep -qF 'ip address replace 10.80.7.9/24 dev cbr0' rekey.log)" "yes" ctl join "{\"hubPublicKey\":\"$HUB_PUB\",\"overlayIp\":\"$OVERLAY\"}" cp enrolled.env agent.env @@ -254,13 +286,13 @@ check "and survived" "$(yes_if grep -q 'heartbeat recovered' outage.log)" "yes" # ---------------------------------------------------------------- waiting it out section "an unreachable coordinator" W0=$(date +%s) -STREQ_STORE=file:$T/wait.env STREQ_API=https://127.0.0.1:9/v1 STREQ_ENROLLMENT_TOKEN=$TOKEN \ - STREQ_RETRY_MIN=2 timeout 9 $A run >wait.log 2>&1 +CERBERUS_STORE=file:$T/wait.env CERBERUS_API=https://127.0.0.1:9/v1 CERBERUS_ENROLLMENT_TOKEN=$TOKEN \ + CERBERUS_RETRY_MIN=2 timeout 9 $A run >wait.log 2>&1 check "is waited out, not fatal" "$([ $(( $(date +%s) - W0 )) -ge 8 ] && echo yes || echo no)" "yes" check "with a backoff between tries" "$(yes_if grep -q 'retrying in 4s' wait.log)" "yes" -STREQ_STORE=file:$T/wait2.env STREQ_API=https://127.0.0.1:9/v1 STREQ_ENROLLMENT_TOKEN=$TOKEN \ - STREQ_RETRY_MIN=30 $A run >wait2.log 2>&1 & +CERBERUS_STORE=file:$T/wait2.env CERBERUS_API=https://127.0.0.1:9/v1 CERBERUS_ENROLLMENT_TOKEN=$TOKEN \ + CERBERUS_RETRY_MIN=30 $A run >wait2.log 2>&1 & WPID=$! sleep 3 S0=$(date +%s) @@ -288,7 +320,7 @@ if wg_ok; then ip -n hub route add $SUBNET dev wg-hub cp enrolled.env agent.env - export STREQ_DRY_RUN=0 STREQ_IFACE=wg0 + export CERBERUS_DRY_RUN=0 CERBERUS_IFACE=wg0 $A up >dp-up.log 2>&1 check "wg0 comes up with the overlay address" \ "$(yes_if sh -c "ip -4 -o addr show wg0 | grep -q ' $OVERLAY/24 '")" "yes" @@ -303,13 +335,13 @@ if wg_ok; then check "handshake with the hub" "$hs" "yes" check "ping $HUB_IP through the tunnel" "$(yes_if ping -c3 -W2 $HUB_IP)" "yes" - STREQ_PING_INTERVAL=60 $A status >dp-status.out 2>/dev/null + CERBERUS_PING_INTERVAL=60 $A status >dp-status.out 2>/dev/null check "status reads the handshake back" "$(yes_if grep -Eq '^handshake +[0-9]+s ago' dp-status.out)" "yes" check "and probes the tunnel itself" "$(yes_if grep -q "^tunnel ping ok ($HUB_IP)" dp-status.out)" "yes" # A tunnel that is up on paper and dead in fact: the handshake stays fresh, the route is gone. # Only the in-tunnel probe can see it, and the agent has to repair it by itself. - env STREQ_HEARTBEAT=0 STREQ_PING_INTERVAL=10 STREQ_PING_FAILS=1 timeout 26 $A run >dp-run.log 2>&1 & + env CERBERUS_HEARTBEAT=0 CERBERUS_PING_INTERVAL=10 CERBERUS_PING_FAILS=1 timeout 26 $A run >dp-run.log 2>&1 & RPID=$! sleep 3 ip route del $HUB_IP/32 dev wg0 @@ -320,7 +352,7 @@ if wg_ok; then check "route restored" "$(yes_if sh -c "ip route show $HUB_IP | grep -q 'dev wg0'")" "yes" ip link del wg0 2>/dev/null - export STREQ_DRY_RUN=1 STREQ_IFACE=cbr0 + export CERBERUS_DRY_RUN=1 CERBERUS_IFACE=cbr0 else if [ "${REQUIRE_WG:-}" = 1 ]; then check "kernel WireGuard and netns available" "no" "yes" @@ -329,6 +361,101 @@ else fi fi +# -------------------------------------------------------------- the old names +section "the rename: streq_* keys and STREQ_* variables" +# A camera installed before the rename: its whole identity under the old names, in U-Boot env. +OLD=$T/old-uboot.env +sed -n 's/^cerberus_\(.*\)=\(.*\)$/streq_\1=\2/p' enrolled.env >$OLD +uboot $OLD $A status >mig.out 2>mig.log +check "moves its identity to cerberus_*" "$(ub $OLD cerberus_device_key)" "$PRIV" +check "and its enrolment" "$(ub $OLD cerberus_node_id)" "$NODE" +check "every agent-written key" "$(yes_if grep -q 'moved 9 key(s)' mig.log)" "yes" +check "keeps the streq_* copies for an older init script" "$(ub $OLD streq_node_id)" "$NODE" +check "the camera is the same camera" "$(uboot $OLD $A keygen 2>/dev/null | sed -n 2p)" "$PUB" +check "and knows it is enrolled" "$(yes_if grep -q "^node *$NODE" mig.out)" "yes" +W=$(writes $OLD) +uboot $OLD $A status >/dev/null 2>&1 +uboot $OLD $A status >/dev/null 2>&1 +check "after that, starts write nothing to flash" "$(writes $OLD)" "$W" + +OLDF=$T/old-file.env +sed 's/^cerberus_/streq_/' enrolled.env >$OLDF +CERBERUS_STORE=file:$OLDF $A status >/dev/null 2>&1 +check "a file store is moved the same way" "$(sv cerberus_node_id $OLDF)" "$NODE" + +check "STREQ_* variables are still read" \ + "$( (unset CERBERUS_STORE; STREQ_STORE=file:$T/old-var.env $A keygen >/dev/null 2>&1); yes_if test -s $T/old-var.env)" "yes" +check "CERBERUS_* wins over STREQ_*" \ + "$( (STREQ_STORE=file:$T/loser.env $A keygen >/dev/null 2>&1); yes_if test -e $T/loser.env)" "no" + +# The init script reads the new name, and the old one when the new one is not there. +SUB=$T/s99.env +printf 'streq_subnet=10.99.0.0/16\n' >$SUB +mkdir -p /usr/sbin && cp $A /usr/sbin/cerberus +check "S99cerberus falls back to streq_* keys" \ + "$(env PATH="$T/fw:$PATH" FAKE_UBOOT=$SUB CERBERUS_STORE=file:$T/nothing.env sh /src/S99cerberus status 2>/dev/null | sed -n 's/^network subnet *//p')" \ + "10.99.0.0/16 (from CERBERUS_SUBNET)" +printf 'cerberus_subnet=10.98.0.0/16\n' >>$SUB +check "and prefers cerberus_*" \ + "$(env PATH="$T/fw:$PATH" FAKE_UBOOT=$SUB CERBERUS_STORE=file:$T/nothing.env sh /src/S99cerberus status 2>/dev/null | sed -n 's/^network subnet *//p')" \ + "10.98.0.0/16 (from CERBERUS_SUBNET)" +check "unprovisioned: start says so and exits 0" \ + "$(env PATH="$T/fw:$PATH" FAKE_UBOOT=$T/empty.env sh /src/S99cerberus start 2>&1; echo "rc=$?")" \ + "cerberus: not provisioned (fw_setenv cerberus_api ...) +rc=0" + +# ------------------------------------------------------------------ the installer +section "the installer, from a release laid out on disk" +# What ci/assemble.sh publishes, with this machine's build standing in for the release binary. +REL=$T/release +mkdir -p $REL +ARCH=$(uname -m) +cp $A $REL/cerberus-$ARCH +cp /src/install.sh /src/uninstall.sh /src/S99cerberus $REL/ +(cd $REL && for f in cerberus-$ARCH install.sh uninstall.sh S99cerberus; do + printf '%s %s\n' "$(sha256sum $f | cut -d' ' -f1)" "$f"; done >SHA256SUMS) +rm -f /usr/sbin/cerberus /etc/init.d/S99cerberus +mkdir -p /etc/init.d + +INST=$T/inst.env +printf 'cerberus_ca=%s\n' "$T/ca.crt" >$INST +inst() { env PATH="$T/fw:$PATH" FAKE_UBOOT=$INST CERBERUS_DIST=file://$REL CERBERUS_API=$URL/v1 \ + CERBERUS_STORE= CERBERUS_IFACE= sh /src/install.sh "$@"; } + +inst >inst-notoken.log 2>&1 +check "no token: installs, exits 0" "$?" "0" +check "picks the build for this CPU" "$(cmp -s /usr/sbin/cerberus $REL/cerberus-$ARCH && echo yes)" "yes" +check "verifies it against SHA256SUMS" "$(yes_if grep -q "cerberus-$ARCH sha256 ok" inst-notoken.log)" "yes" +check "installs the init script" "$(yes_if test -x /etc/init.d/S99cerberus)" "yes" +check "and says what to set, by the new names" "$(yes_if grep -q 'fw_setenv cerberus_api' inst-notoken.log)" "yes" + +cp $REL/SHA256SUMS $T/sums.good +sed -i "s/^[0-9a-f]* cerberus-$ARCH\$/$(printf '0%.0s' $(seq 64)) cerberus-$ARCH/" $REL/SHA256SUMS +inst >inst-bad.log 2>&1 +check "a binary that does not match its sum is refused" "$?" "15" +cp $T/sums.good $REL/SHA256SUMS + +# A claim code exists before the camera ever reaches the cloud — its hash is what enrolment sends — +# so seeing one proves nothing. The installer has to wait for the enrolment itself, and say E21 +# when it does not come. +env PATH="$T/fw:$PATH" FAKE_UBOOT=$INST CERBERUS_DIST=file://$REL CERBERUS_API=https://127.0.0.1:9/v1 \ + CERBERUS_STORE= CERBERUS_IFACE= sh /src/install.sh "$TOKEN" >inst-noreach.log 2>&1 +check "an unreachable cloud is reported as E21" "$(yes_if grep -q 'cerberus: E21' inst-noreach.log)" "yes" +check "not as a claim code" "$(yes_if grep -q 'cerberus: claim' inst-noreach.log)" "no" +env PATH="$T/fw:$PATH" FAKE_UBOOT=$INST sh /etc/init.d/S99cerberus stop >/dev/null 2>&1 +printf 'cerberus_ca=%s\n' "$T/ca.crt" >$INST + +inst "$TOKEN" >inst.log 2>&1 +check "with a token: installs, writes it, starts the agent" "$?" "0" +check "the token went to cerberus_enrollment_token" "$(ub $INST cerberus_enrollment_token)" "$TOKEN" +check "the agent enrolled and the installer read its claim code" "$(yes_if grep -Eq '^cerberus: claim [2-9A-Z]{12}$' inst.log)" "yes" +check "into the U-Boot environment" "$(ub $INST cerberus_node_id | grep -c '^node_')" "1" +env PATH="$T/fw:$PATH" FAKE_UBOOT=$INST sh /etc/init.d/S99cerberus stop >/dev/null 2>&1 + +env PATH="$T/fw:$PATH" FAKE_UBOOT=$INST sh /src/uninstall.sh >uninst.log 2>&1 +check "uninstall.sh removes it" "$(yes_if test -e /usr/sbin/cerberus)$(yes_if test -e /etc/init.d/S99cerberus)" "nono" +check "and every key it wrote" "$(grep -c -E '^(cerberus|streq)_' $INST)" "0" + # ------------------------------------------------------------------ revocation section "revocation and removal" cp enrolled.env agent.env @@ -337,15 +464,21 @@ F0=$(date +%s) runfor 20 gone.log check "a forgotten node discards its enrolment" "$(yes_if grep -q 'enrolment discarded' gone.log)" "yes" check "and stops by itself" "$([ $(( $(date +%s) - F0 )) -lt 15 ] && echo yes || echo no)" "yes" -check "node id cleared" "$(sv streq_node_id agent.env)" "" -check "hub endpoint cleared" "$(sv streq_hub_endpoint agent.env)" "" -check "device key kept" "$(sv streq_device_key agent.env)" "$PRIV" -check "claim code kept" "$(sv streq_claim_code agent.env)" "$CLAIM" +check "node id cleared" "$(sv cerberus_node_id agent.env)" "" +check "hub endpoint cleared" "$(sv cerberus_hub_endpoint agent.env)" "" +check "device key kept" "$(sv cerberus_device_key agent.env)" "$PRIV" +check "claim code kept" "$(sv cerberus_claim_code agent.env)" "$CLAIM" + +# The migration must never undo a revocation: forgetting removes the streq_* copies too. +check "U-Boot store: a revoked node is forgotten" "$( (uboot $OLD env CERBERUS_HEARTBEAT=5 timeout 20 $A run >gone-ub.log 2>&1); yes_if grep -q 'enrolment discarded' gone-ub.log)" "yes" +check "under both names" "$(ub $OLD cerberus_node_id)$(ub $OLD streq_node_id)" "" +check "and stays forgotten on the next start" "$(uboot $OLD $A status 2>/dev/null | sed -n 's/^node *//p')" "(not enrolled)" +check "with the device key kept" "$(ub $OLD cerberus_device_key)" "$PRIV" mkdir -p /etc/init.d $T/victim cp $A $T/victim/cerberus && touch /etc/init.d/S99cerberus cp enrolled.env clean.env -STREQ_STORE=file:$T/clean.env $T/victim/cerberus clean >clean.log 2>&1 +CERBERUS_STORE=file:$T/clean.env $T/victim/cerberus clean >clean.log 2>&1 check "clean: removes the init script" "$(yes_if test -e /etc/init.d/S99cerberus)" "no" check "the stored material" "$(yes_if test -e $T/clean.env)" "no" sleep 4 @@ -353,10 +486,10 @@ check "and, a moment later, itself" "$(yes_if test -e $T/victim/cerberus)" "no" # ------------------------------------------------------------------ installers section "shipped scripts" -for s in S99cerberus bootstrap.sh uninstall.sh; do +for s in S99cerberus install.sh uninstall.sh; do check "$s parses" "$(yes_if sh -n /src/$s)" "yes" done -for s in bootstrap.sh uninstall.sh; do +for s in install.sh uninstall.sh; do check "$s carries no comments" "$(tail -n +2 /src/$s | grep -c '^[[:space:]]*#')" "0" done diff --git a/uninstall.sh b/uninstall.sh index ef0e1be..866c08b 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -DOCS="${STREQ_DOCS:-https://cloud.openipc.ru/docs/openipc-camera/#codes}" +DOCS="${CERBERUS_DOCS:-${STREQ_DOCS:-https://cloud.openipc.ru/docs/openipc-camera/#codes}}" AGENT=/usr/sbin/cerberus INIT=/etc/init.d/S99cerberus @@ -15,18 +15,20 @@ if [ -x "$INIT" ]; then "$INIT" stop >/dev/null 2>&1 || true fi -iface=$(fw_printenv -n streq_iface 2>/dev/null || true) +iface=$(fw_printenv -n cerberus_iface 2>/dev/null || fw_printenv -n streq_iface 2>/dev/null || true) ip link del dev "${iface:-wg0}" >/dev/null 2>&1 || true -for key in 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; do - fw_setenv "$key" >/dev/null 2>&1 || die 30 +for name in node_id overlay_ip hub_endpoint hub_pubkey allowed_ips network_subnet hub_ip \ + claim_code device_key api enrollment_token hostname kind iface subnet ca heartbeat \ + ping_target ping_interval ping_timeout ping_fails; do + for prefix in cerberus streq; do + if fw_printenv -n "${prefix}_$name" >/dev/null 2>&1; then + fw_setenv "${prefix}_$name" >/dev/null 2>&1 || die 30 + fi + done done rm -f "$INIT" || die 30 rm -f "$AGENT" || die 30 -rm -rf /etc/streq || die 30 +rm -rf /etc/cerberus /etc/streq || die 30 say "removed"