diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9eaff8..bdaddfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,11 +104,12 @@ jobs: run: cargo audit --deny warnings cross: - name: Cross-compile for the NAS + name: Cross-compile for the NAS, and package it runs-on: ubuntu-latest # This is where a C dependency breaks first: SQLite is compiled from source, and # armv7-musl is the least forgiving target we ship. Catching it here beats catching - # it while cutting a release. + # it while cutting a release. The DSM packages are assembled on the same job for the + # same reason — they wrap these exact binaries and cost a few seconds more. steps: - uses: actions/checkout@v4 @@ -116,7 +117,11 @@ jobs: run: | rustup toolchain install stable --profile minimal rustup default stable - rustup target add armv7-unknown-linux-musleabihf + rustup target add armv7-unknown-linux-gnueabihf + # The x86_64 musl build is here for the package check below: it runs on the + # runner, which is what makes "the packaged binary reports the version INFO + # claims" a real assertion rather than a re-read of the same string. + rustup target add x86_64-unknown-linux-musl - uses: actions/cache@v4 with: @@ -138,13 +143,46 @@ jobs: echo "$HOME/zig" >> "$GITHUB_PATH" command -v cargo-zigbuild >/dev/null || cargo install cargo-zigbuild --locked + # The glibc floor is the point of this build, not an incidental flag: Synology's ARMv7 + # kernels are 3.10 and answer the time64 syscalls with EINVAL rather than ENOSYS, so + # musl 1.2 never falls back and every clock call fails on the machine this project + # exists for. glibc on 32-bit uses the time32 syscalls. - name: Build - run: cargo zigbuild --release --target armv7-unknown-linux-musleabihf + run: cargo zigbuild --release --target armv7-unknown-linux-gnueabihf.2.17 - - name: It must be static, or DSM will not run it + - name: It must not need a glibc the NAS does not have run: | set -euo pipefail - BIN=target/armv7-unknown-linux-musleabihf/release/rescriptum + BIN=target/armv7-unknown-linux-gnueabihf/release/rescriptum file "$BIN" - file "$BIN" | grep -q 'statically linked' + file "$BIN" | grep -q 'ARM' + # DSM 7 on armada38x ships glibc 2.20. Asking for anything newer fails at exec + # time, on the NAS, with an error that names a symbol version and nothing else. + WANT=$(readelf --dyn-syms "$BIN" | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1) + echo "needs at most $WANT" + [ "$(printf '%s\n' 'GLIBC_2.17' "$WANT" | sort -V | tail -1)" = 'GLIBC_2.17' ] echo "size: $(stat -c%s "$BIN") bytes" + + - name: Build x86_64-musl too + run: cargo zigbuild --release --target x86_64-unknown-linux-musl + + # Packaging breaks on the PR that breaks it, rather than at tag time. This is the + # cheap half of "does this package work"; the other half is installing it, which + # only a DSM machine can answer. + - name: Assemble the DSM packages + run: | + set -euo pipefail + packaging/dsm/make-spk.sh armv7 + packaging/dsm/make-spk.sh x86_64 + + - name: Check them structurally + run: packaging/dsm/check-spk.sh + + # Everything the package's *scripts* decide is testable without DSM — the env file + # written once and only once, the wizard's values and their absence, the service + # surviving its own start script, the exit codes Package Center reads, an upgrade + # that must not touch a hand-edited configuration, an uninstall that must not touch + # the answers. That is where the expensive mistakes live, so it runs on every push. + # What is left for a real machine is DSM's own machinery: packaging/dsm/vm/. + - name: Drive the package lifecycle + run: packaging/dsm/lifecycle-test.sh dist/rescriptum-*-x86_64.spk diff --git a/.github/workflows/dsm-rig.yml b/.github/workflows/dsm-rig.yml new file mode 100644 index 0000000..0e662ee --- /dev/null +++ b/.github/workflows/dsm-rig.yml @@ -0,0 +1,65 @@ +name: DSM rig + +# The half of the package's tests that needs a DSM machine — the data-share worker and its +# ACL, the port-config worker, the generated systemd unit, logrotate against a live +# descriptor, and whether Package Center accepts the archive at all. +# +# **It is inert until a rig exists.** It runs only by hand, only on a self-hosted runner +# labelled `dsm-rig`, and that runner is expected to sit next to a DSM 7 VM (see +# packaging/dsm/vm/README.md) with the same toolchain a developer has: Rust, and Zig plus +# cargo-zigbuild if you point it at the ARMv7 machine. Everything that does *not* need a +# machine already runs on every push, in ci.yml. +# +# It is destructive on the target by design: it upgrades over a hand-edited configuration +# and then uninstalls. Point it at a machine whose answers nobody cares about. +on: + workflow_dispatch: + inputs: + host: + description: "user@host of the DSM machine (the VM, or the NAS)" + required: true + default: "admin@localhost" + port: + description: "SSH port (2222 for the QEMU rig)" + required: false + default: "2222" + abi: + description: "x86_64, armv7 or aarch64 — blank asks the machine" + required: false + default: "" + +jobs: + rig: + name: Install, upgrade and uninstall on DSM + runs-on: [self-hosted, dsm-rig] + steps: + - uses: actions/checkout@v4 + + - name: The key for the rig + env: + KEY: ${{ secrets.DSM_RIG_SSH_KEY }} + run: | + set -euo pipefail + if [ -z "${KEY:-}" ]; then + echo "::error::set the DSM_RIG_SSH_KEY secret to a key that can reach the rig" + exit 1 + fi + install -m 700 -d "$HOME/.ssh" + printf '%s\n' "$KEY" > "$HOME/.ssh/dsm-rig" + chmod 600 "$HOME/.ssh/dsm-rig" + # The rig is a machine on the maintainer's own network, reinstalled often. + ssh-keyscan -p "${{ inputs.port }}" -H "$(echo '${{ inputs.host }}' | cut -d@ -f2)" \ + >> "$HOME/.ssh/known_hosts" 2>/dev/null || true + + - name: Run the checks + run: | + set -euo pipefail + ABI="" + [ -n "${{ inputs.abi }}" ] && ABI="--abi ${{ inputs.abi }}" + # shellcheck disable=SC2086 + packaging/dsm/vm/on-dsm.sh "${{ inputs.host }}" -p "${{ inputs.port }}" \ + -i "$HOME/.ssh/dsm-rig" $ABI + + - name: Forget the key + if: always() + run: rm -f "$HOME/.ssh/dsm-rig" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2905eda..115d3a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,14 @@ on: tag: description: "Tag to build (e.g. v0.1.0)" required: true + # An SPK version is all-numeric segments, and its last one is a package build + # number. `verify` requires the tag to equal Cargo.toml, so v0.1.0 can only ever + # produce 0.1.0-1: a packaging-only fix has no tag to stand on. Bumping this and + # dispatching by hand attaches rescriptum-0.1.0-2-.spk to the same Release. + spk_build: + description: "SPK build number (bump for a packaging-only repack)" + required: false + default: "1" permissions: contents: write @@ -50,8 +58,11 @@ jobs: fail-fast: false matrix: include: - # The DS416j this was written for. - - target: armv7-unknown-linux-musleabihf + # The DS416j this was written for. glibc, not musl, and the version is + # deliberate: Synology's 3.10 kernels answer the time64 syscalls with EINVAL + # rather than ENOSYS, so musl 1.2 never falls back and every clock call fails. + - target: armv7-unknown-linux-gnueabihf + zig_target: armv7-unknown-linux-gnueabihf.2.17 os: ubuntu-latest cross: true - target: aarch64-unknown-linux-musl @@ -92,7 +103,8 @@ jobs: run: | set -euo pipefail if [ "${{ matrix.cross }}" = "true" ]; then - cargo zigbuild --release --target ${{ matrix.target }} + # zig_target carries the glibc floor where the Rust target cannot. + cargo zigbuild --release --target "${{ matrix.zig_target || matrix.target }}" else cargo build --release --target ${{ matrix.target }} fi @@ -118,9 +130,56 @@ jobs: path: dist/*.tar.gz* retention-days: 7 + package-dsm: + name: Synology packages + needs: [verify, build] + runs-on: ubuntu-latest + # Nothing is compiled here: the binaries are already built and statically linked, and + # an .spk is a release format — the same artifact, wrapped for one platform's package + # manager. See packaging/dsm/ for why we assemble it ourselves rather than through + # pkgscripts-ng. + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + # The SPK's CHANGELOG is generated from git log between the previous tag and + # this one, and Package Center is the only place a DSM user ever sees it. + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Assemble + run: | + set -euo pipefail + VERSION="${{ needs.verify.outputs.version }}" + SPK_BUILD="${{ inputs.spk_build || '1' }}" + mkdir -p bins + # One .spk per *build*; the arch line inside each covers the platforms that + # build serves. aarch64 (arch="armv8") joins this list once the binary has been + # run on one of its platforms — make-spk.sh already knows the mapping. + for pair in "x86_64:x86_64-unknown-linux-musl" "armv7:armv7-unknown-linux-musleabihf"; do + abi="${pair%%:*}"; target="${pair#*:}" + tar -xzf "artifacts/$target/rescriptum-$VERSION-$target.tar.gz" -C bins + packaging/dsm/make-spk.sh "$abi" \ + --bin "bins/rescriptum-$VERSION-$target/rescriptum" \ + --version "$VERSION" --spk-build "$SPK_BUILD" + done + ls -l dist + + - name: Check them structurally + run: packaging/dsm/check-spk.sh + + - uses: actions/upload-artifact@v4 + with: + name: dsm-packages + path: dist/*.spk* + retention-days: 7 + publish: name: Publish the release - needs: [verify, build] + needs: [verify, build, package-dsm] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -135,7 +194,7 @@ jobs: run: | set -euo pipefail mkdir -p dist - find artifacts -type f -name '*.tar.gz*' -exec cp {} dist/ \; + find artifacts -type f \( -name '*.tar.gz*' -o -name '*.spk*' \) -exec cp {} dist/ \; ls -l dist - name: Release @@ -145,7 +204,15 @@ jobs: set -euo pipefail TAG="${{ inputs.tag || github.ref_name }}" # `gh` is preinstalled on the runner, so this needs no third-party action. - gh release create "$TAG" dist/* \ - --title "rescriptum $TAG" \ - --generate-notes \ - --verify-tag + # + # A dispatch that repacks — a packaging-only fix shipping as spk_build=2, or a + # re-run after a job failed — meets a Release that already exists. Uploading + # into it is the documented path for that, and `gh release create` would fail. + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/* --clobber + else + gh release create "$TAG" dist/* \ + --title "rescriptum $TAG" \ + --generate-notes \ + --verify-tag + fi diff --git a/AGENTS.md b/AGENTS.md index 6774f59..012e48a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,3 +14,20 @@ equivalent), follow the notabene review protocol: Non-negotiable: only process comments with `status: "open"` and `hold: false`; never delete the store; never commit without being asked. + +## Changing the Synology package + +`packaging/dsm/` is shell that runs as root-adjacent code on someone's NAS, and **the local +harness cannot prove it**. Two of the three real defects found so far were invisible to a +fake-tree test and only appeared on a real DSM. + +- The procedure is + [`packaging/dsm/vm/README.md`](packaging/dsm/vm/README.md) → *Changing the package? This is + the procedure*. Follow it rather than inventing a shortcut. +- A **DSM 7.2.2 virtual machine already exists on the maintainer's machine**, in Docker, with + a `clean` snapshot to restore. It is set up by `packaging/dsm/vm/bootstrap.sh` and driven + by `packaging/dsm/vm/on-dsm.sh`; neither needs anything outside Docker. +- The machine checks are **destructive on purpose** — they upgrade over a hand-edited + configuration and then uninstall. Restore the snapshot before and after. +- **Never name a DSM account after the package user** (`rescriptum`): DSM deletes it with the + package. The rig's account is `rigadmin`. diff --git a/CLAUDE.md b/CLAUDE.md index c222fa2..0244a96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit both were overridden deliberately once the requirement became "absorb a professional provisioning burst". Direct deps: `tokio`, `hyper` (server + http1), `hyper-util`, `http-body-util`, plus `toml_edit`, `serde_json`, `serde_yaml_ng` and `quick-xml` for - answer documents — 64 crates, 2.4 MB static on armv7 (1.3 MB without SQLite). Still **no + answer documents — 64 crates, 2.4 MB on armv7 (1.3 MB without SQLite). Still **no `serde` derive anywhere**. - **hyper directly, not axum.** axum gives no way to set a header-read timeout, which is precisely the slowloris guard that motivated going async. Routing here is one `if` on method @@ -94,13 +94,21 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit - `src/format/` — one interface per document format. `xml.rs` holds the XML tree and its merge rules. - `src/merge.rs` — the TOML merge, used by `format`. -- `src/cli.rs` — the `render`, `check`, `import` and `export` subcommands. +- `src/cli.rs` — the `render`, `check`, `import`, `export` and `config` subcommands. + `config` is dispatched **before** `Config::from_env` and `validate`, unlike every other + one: a file that will not parse and a token one character short are the states people run + it to get *out* of, so it loads the file itself and reports rather than dying. - `src/admin.rs` — the write API: its own listener, the constant-time token, the failure guard, and the rollback that keeps a write from breaking the answer set. - `src/capture.rs` — recording request bodies (`RESCRIPTUM_CAPTURE_DIR`). - `src/config.rs` — environment configuration. `Config::from_lookup` takes a lookup closure so tests never touch the process environment. -- `src/envfile.rs` — the optional file of defaults `RESCRIPTUM_ENV_FILE` names. Never +- `src/envfile.rs` — the optional file of defaults `RESCRIPTUM_ENV_FILE` names, and the + writer behind `config set`: `rewrite()` edits lines where they stand, **uncommenting** a + commented setting rather than appending a duplicate, because on a packaged install those + comments are the only documentation the configuration has. `write_atomic()` preserves the + file's **owner** as well as its mode — a root-owned rewrite of a `0600` file the service + owns is a server that stops starting one restart later. Never discovered, only named (this runs as root; a `./.env` would hand the admin token to whoever could write in the working directory), the real environment wins over it, and a file that was asked for and cannot be read is a startup error. @@ -124,6 +132,8 @@ These are deliberate design decisions, not oversights. Do not "improve" them wit `render … > answer.toml` work; both are contracts, not conveniences. - `docs/` — the documentation site (see *Documentation* below). Nothing in `src/` knows it exists. +- `packaging/dsm/` — the Synology package (see *The DSM package* below). Nothing in `src/` + knows it exists either, and that is the whole design. ## Selecting a machine @@ -555,6 +565,7 @@ Local development (once the crate exists): ```bash cargo build cargo run -- check # validate an answers directory +cargo run -- config # show the configuration and where each value comes from cargo run -- render # print one machine's composed answer cargo test # all tests cargo test # single test by name substring @@ -571,15 +582,26 @@ npm run docs:build # the public artifact into ./_site npm run docs:lint # no broken internal links (CI gate) ``` -Cross-compile for the NAS (ARMv7 hard-float, static musl): +Cross-compile for the NAS (ARMv7 hard-float — **glibc, not musl**, see below): ```bash -cargo zigbuild --release --target armv7-unknown-linux-musleabihf +cargo zigbuild --release --target armv7-unknown-linux-gnueabihf.2.17 ``` +**armv7 is the one target that is not static musl, and it is not a preference.** Synology's +ARMv7 kernels are 3.10 and answer the *time64* syscalls with `EINVAL` rather than `ENOSYS`; +musl 1.2 falls back to the 32-bit syscalls only on `ENOSYS`, so every `clock_gettime`, +`clock_nanosleep` and timed futex fails. Measured on a DS416j: the musl build installs, +answers `--version`, and panics at `time.rs:131` with `Os { code: 22, kind: InvalidInput }` +the moment it wants a timestamp. glibc on 32-bit uses time32, DSM ships 2.20 on +`armada38x`, and glibc is backward compatible — so the floor is **2.17** and the same binary +still runs on newer ARMv7 Linux. x86_64 and aarch64 are 64-bit, have no time32/time64 split, +and stay static musl. What CI asserts for armv7 is therefore *not* "static" but "needs no +glibc newer than 2.17". + `cargo-zigbuild` uses Zig as the linker, avoiding a full cross toolchain. The toolchain is **installed and verified**: Rust 1.93, targets `aarch64-apple-darwin` + -`armv7-unknown-linux-musleabihf`, `cargo-zigbuild` 0.23.0, Zig 0.16.0. A scratch crate built +`armv7-unknown-linux-gnueabihf`, `cargo-zigbuild` 0.23.0, Zig 0.16.0. A scratch crate built with the release profile below produced `ELF 32-bit LSB executable, ARM, EABI5, statically linked, stripped` at 296 KB — so the chain works end to end. @@ -607,10 +629,159 @@ codegen-units = 1 strip = true ``` +## The DSM package + +`packaging/dsm/` wraps an already-built binary as a DSM 7 `.spk`. It is a **release +format**, exactly like the `.tar.gz` archives — no DSM-specific build, no feature flag, +nothing in `src/`. The three places DSM pressed back are answered in packaging: log rotation +by a `copytruncate` stanza, a CLI that cannot find its configuration by a three-line wrapper +(`rescriptum-cli`, which names `RESCRIPTUM_ENV_FILE`), and no settings panel by the desktop +application below. If this ever seems to need a `#[cfg]`, the design has gone wrong. + +```bash +./build.sh --spk x86_64-unknown-linux-musl # build, then wrap +packaging/dsm/make-spk.sh armv7 # wrap an existing build +packaging/dsm/check-spk.sh # structural check ⎫ both run +packaging/dsm/lifecycle-test.sh # drive the scripts ⎭ by ci.yml +packaging/dsm/vm/on-dsm.sh admin@nas # what only DSM can answer +``` + +**The package is tested in three places, and none of it is Rust** — `cargo test` does not +touch it. `check-spk.sh` asserts the archive's shape; `lifecycle-test.sh` unpacks an `.spk` +into a fake `/var/packages` tree and drives the real scripts through install (with a wizard +and without), start, `/health`, the exit codes, an upgrade over a hand-edited env file and +a canary — with `etc/` surviving and with it wiped — and an uninstall; both run on every +push. `vm/on-dsm.sh` runs the rest on a DSM 7 VM and then on the DS416j: `data-share`'s +ACL, `port-config`, the generated unit, `logrotate -f` against a live descriptor, and +whether Package Center accepts the archive at all. **Nothing ships on VM evidence alone**, +and `lifecycle-test.sh` was watched failing — breaking four guards turns 33 green into 25 +green and 8 red. + +### The desktop application + +`packaging/dsm/payload/ui/` is a **real DSM application** — `SYNO.SDS.AppWindow`, +`syno_formpanel`, `syno_textfield`, `syno_combobox`, `syno_button` — not a page of ours in a +frame. `dsmuidir="ui"` makes DSM symlink it into +`/usr/syno/synoman/webman/3rdparty/rescriptum`, and `dsmappname` names the class `ui/config` +declares. It manages the server's configuration, shows its status and tails its log. + +**ExtJS, not Vue, and the machine decided that.** DSM 7.2 ships a Vue framework and +Synology's current guide documents only that one — the first version of this was written +against it. The DS416j is capped at **DSM 7.1.1**, where `Vue` is undefined. ExtJS is on both +(7.1.1 and 7.2.2, measured), so one application covers every DSM this package supports; +`os_min_ver` is **7.1**, and 7.0 is not claimed because nothing has run there. The API is +documented in the ExtJS reference Synology generated for DSM, mirrored at + as `docs/synoextjsdocs.tar.gz`. + +The design rule holds: nothing in `src/` knows any of this exists. What the server gained is +a *generic* `config` subcommand, and the application's backend — `ui/api.cgi` — is a hundred +lines of shell that authenticate and then shell out to `rescriptum-cli config`. The env-file +semantics stay in Rust where they are tested rather than being written a second time in `sh`. + +**Four things were measured on the machine and every one of them is load-bearing. None is in +the developer guide** (they are in `docs/development/traps.md` at length): + +- **A CGI there runs as the owner of the script**, which for a package tree is the package + user. Not `http`, not root. That is what lets it read the `0600` env file it owns, and why + it cannot start or stop anything — restarting goes through DSM's own + `SYNO.Core.Package.Control`, from the application, with the administrator's session. +- **DSM does not authenticate that path.** An unauthenticated request gets `200`. So + `authenticate.cgi` plus an `administrators` check *is* the door, and a write additionally + needs a header a cross-origin page cannot make a browser send. Losing any of them would be + silent, which is why `check-spk.sh` greps for them **with the comments stripped** — the + first version of that check passed because the word appeared in a comment. +- **No `su`, ever.** It hangs a CGI outright without ``**, so the package root is the + fixed `/var/packages/`, never `dirname "$SYNOPKG_PKGDEST"`. `RESCRIPTUM_PKG_ROOT` is + the seam that lets `lifecycle-test.sh` drive the scripts against a writable tree. +- **`etc/` and `var/` survive an uninstall** (they are symlinks into `@appconf`/`@appdata`), + so the env file and its tokens outlive the package — said plainly in the Synology page. +- **`$SYNOPKG_TEMP_UPGRADE_FOLDER` outlives its upgrade**, so restoring from it requires + `SYNOPKG_PKG_STATUS = UPGRADE` or a fresh install resurrects a removed configuration. +- **The firewall directory is `/usr/local/etc/services.d/`** (plural; the guide is wrong), + and `port-config` acquires *after* `postinst` — the wizard's port does reach it. Both + `port-config` and `usr-local-linker` acquire when the package is **enabled**, not at + `postinst`. +- **The generated unit has no `Restart=`**: DSM does not restart the process if it dies. + +**Changing anything under `packaging/dsm/` means running the machine**, not just the local +harness — the procedure is in `packaging/dsm/vm/README.md` (*Changing the package? This is +the procedure*), and `AGENTS.md` points at it. A DSM 7.2.2 VM already exists in Docker on +the maintainer's machine with a `clean` snapshot; `bootstrap.sh` sets one up from scratch, +`on-dsm.sh` drives it, and the run is destructive on purpose. It asks the server for a real +answer — a machine file merged over the group that claims it — rather than settling for +`/health`. + +The harnesses catch a broken archive and broken scripts; only Package Center catches a +broken package. **A tag must not be the first time an `.spk` meets a DSM machine** — the +rig is `packaging/dsm/vm/`: `docker-compose.yml` runs Synology's own Virtual DSM (DSM 7.2, +close to the DS416j's 7.2.1). KVM makes it fast, not possible — without `/dev/kvm` the image +falls back to emulation on its own, about ten times slower, which is what +`docker-compose.emulated.yml` is for. What does stop a host is **14 GiB free**, hardcoded in +the image and not derived from `DISK_SIZE`. `run-vm.sh` is the loader-image fallback. + ## Testing expectations -308 tests. `docs/development/testing.md` has the per-suite table; the rules that decide where -a test goes: +333 tests, plus the package's own harnesses (see *The DSM package*, and note that +`cargo test` does not run those). `docs/development/testing.md` has the per-suite table; +the rules that decide where a test goes: - **A behaviour belongs in `tests/stores.rs`**, which runs it against both stores and requires the identical outcome. One that covers a single backend proves half of what it claims, and @@ -702,14 +873,18 @@ SemVer tags. Keep PRs focused. What does **not** carry over from notabene: it is an npm package and publishes prereleases to an `@dev` dist-tag. This project ships a **compiled binary**, so the release artifact is a -GitHub Release with cross-compiled binaries attached, built by a CI matrix. A DSM community -package may follow later. +GitHub Release with cross-compiled binaries attached, built by a CI matrix, plus a `.spk` +per Linux ABI from the `package-dsm` job. A `spk_build` dispatch input ships a +packaging-only fix as `0.1.0-2` without a new tag. Submission to SynoCommunity may follow +later; a package source that Package Center could poll deliberately will not — there are no +update notifications, and the documentation says so. CI gates on every push: **gates** (fmt, clippy, tests, the no-SQLite build), **docs** (public build plus `notabene lint`), **audit** (`cargo audit --deny warnings`, which fails on an unmaintained or yanked crate as well as a vulnerability — an unfixable one gets `--ignore RUSTSEC-…` with a reason, not the flag removed), and **cross** (ARMv7-musl, then -asserting the binary is statically linked). Every action is an official `actions/*`; Zig and +asserting it needs no glibc newer than the floor DSM has, then assembling both `.spk`s and checking them +structurally). Every action is an official `actions/*`; Zig and `cargo-audit` are installed directly, because this toolchain vets and links a binary people run as root. @@ -720,7 +895,7 @@ Release target matrix (settled): | Target | For | |---|---| -| `armv7-unknown-linux-musleabihf` | the DS416j, the reason this project exists | +| `armv7-unknown-linux-gnueabihf` (floor 2.17) | the DS416j, the reason this project exists | | `aarch64-unknown-linux-musl` | modern ARM NAS / Raspberry Pi | | `x86_64-unknown-linux-musl` | most other Linux hosts | | `aarch64-apple-darwin` | local development | diff --git a/README.fr.md b/README.fr.md index d91cf51..c8349a2 100644 --- a/README.fr.md +++ b/README.fr.md @@ -129,15 +129,21 @@ Chacun de ces points est un lien vers la enregistrer ce que les machines envoient réellement, le rejouer hors ligne avec `render --body`. - **[Assez petit pour un NAS](https://z29k.github.io/rescriptum/fr/guide/operations/synology)** — - builds musl statiques pour ARMv7, aarch64 et x86_64. Synology DSM 7 n'a pas de systemd, il a - donc sa propre page ; partout ailleurs c'est une unité systemd ou un conteneur. + builds pour ARMv7, aarch64 et x86_64 — musl statique, sauf ARMv7 qui vise la glibc de DSM + parce que musl 1.2 ne tourne pas sur les noyaux 3.10 de Synology — plus un **paquet DSM 7** qui pose + une **application de bureau** DSM pour la configuration, l'état et le journal, et qui crée le + dossier partagé, enregistre le port auprès du pare-feu et démarre au boot. Partout + ailleurs c'est une unité systemd ou un conteneur. ## Installation Téléchargez un binaire depuis la [page des releases](https://github.com/z29k/rescriptum/releases) -— Linux `armv7`, `aarch64` et `x86_64` (musl, statique), plus macOS — vérifiez sa somme +— Linux `armv7` (glibc ≥ 2.17), `aarch64` et `x86_64` (musl, statique), plus macOS — vérifiez sa somme SHA-256, et lancez-le. Il n'y a rien à installer. +Sur un Synology, prenez plutôt le `.spk` et passez par **Package Center → Installation +manuelle**. + ```console $ RESCRIPTUM_ANSWERS_DIR=/srv/answers ./rescriptum $ curl http://localhost:8000/health diff --git a/README.md b/README.md index 8703701..d0875ae 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,11 @@ go deep only where you are curious. - **[Request capture](https://z29k.github.io/rescriptum/guide/operations/capture)** — record what machines actually send, replay it offline with `render --body`. - **[Small enough for a NAS](https://z29k.github.io/rescriptum/guide/operations/synology)** — - static musl builds for ARMv7, aarch64 and x86_64. Synology DSM 7 has no systemd, so it - gets a page of its own; everywhere else it is a systemd unit or a container. + builds for ARMv7, aarch64 and x86_64 — static musl, except ARMv7, which targets DSM's own + glibc because musl 1.2 cannot run on Synology's 3.10 kernels — plus a **DSM 7 package** that creates + the shared folder, registers the port with the firewall, starts at boot, and puts a + **desktop application** on DSM for the configuration, the status and the log. Everywhere + else it is a systemd unit or a container. ## Install @@ -133,6 +136,8 @@ Download a binary from the [releases page](https://github.com/z29k/rescriptum/re `armv7`, `aarch64` and `x86_64` Linux (musl, static), plus macOS — check its SHA-256, and run it. There is nothing to install. +On a Synology, take the `.spk` instead and use **Package Center → Manual Install**. + ```console $ RESCRIPTUM_ANSWERS_DIR=/srv/answers ./rescriptum $ curl http://localhost:8000/health diff --git a/build.sh b/build.sh index 76c63de..c760c4c 100755 --- a/build.sh +++ b/build.sh @@ -8,6 +8,7 @@ # ./build.sh armv7-unknown-linux-musleabihf # ./build.sh --all # ./build.sh --no-sqlite armv7-unknown-linux-musleabihf +# ./build.sh --spk armv7-unknown-linux-musleabihf # and wrap it as a DSM package # # Cross-compiling needs cargo-zigbuild and Zig: # cargo install cargo-zigbuild && brew install zig (or see the README) @@ -15,22 +16,40 @@ set -euo pipefail cd "$(dirname "$0")" +# armv7 is the one target that is not musl, and the reason is not a preference. Synology's +# ARMv7 kernels are 3.10, and they answer the *time64* syscalls with EINVAL rather than +# ENOSYS — musl 1.2 only falls back to the 32-bit ones on ENOSYS, so every clock_gettime, +# clock_nanosleep and timed futex fails on the machine this project exists for. Measured on +# a DS416j: the musl build runs `--version` and then panics the moment it wants the time. +# glibc on 32-bit uses the time32 syscalls, and DSM ships its own; targeting the oldest +# floor that covers it keeps the binary running on newer ARMv7 Linux too, since glibc is +# backward compatible. RELEASE_TARGETS=( - armv7-unknown-linux-musleabihf # the Synology DS416j this was written for + armv7-unknown-linux-gnueabihf # the Synology DS416j this was written for — see above aarch64-unknown-linux-musl x86_64-unknown-linux-musl aarch64-apple-darwin x86_64-apple-darwin ) +# What cargo-zigbuild is told, when it differs from the Rust target: the glibc floor. +zig_target() { + case "$1" in + armv7-unknown-linux-gnueabihf) echo "$1.2.17" ;; + *) echo "$1" ;; + esac +} + FEATURES=() TARGETS=() +SPK=no for arg in "$@"; do case "$arg" in --all) TARGETS+=("${RELEASE_TARGETS[@]}") ;; --no-sqlite) FEATURES=(--no-default-features) ;; - -h|--help) sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --spk) SPK=yes ;; + -h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; -*) echo "unknown option: $arg" >&2; exit 2 ;; *) TARGETS+=("$arg") ;; esac @@ -67,9 +86,10 @@ build_one() { echo " adding the target" rustup target add "$target" fi - # Zig links these, which is what keeps musl static builds painless. + # Zig links these, which is what keeps cross builds painless — and, for armv7, what + # lets us name the glibc version DSM actually has instead of the host's. # shellcheck disable=SC2046 - cargo zigbuild --release --target "$target" $(expand) + cargo zigbuild --release --target "$(zig_target "$target")" $(expand) bin="target/$target/release/rescriptum" fi @@ -77,24 +97,70 @@ build_one() { bytes=$(size_of "$bin") printf ' %-38s %10s %s\n' "${target:-native}" "$(human "$bytes")" "$bin" - # A DSM 7 box has a glibc far older than anything we build against, so a dynamic - # binary would fail at exec time rather than at build time. Say so here instead. + # Two different promises, so two different checks. A musl target must come out static — + # that is the whole point of it. The armv7 glibc target must not require a glibc newer + # than the floor we aimed at, or it fails at exec time on the NAS rather than here. if command -v file >/dev/null; then local kind kind=$(file -b "$bin") case "$target" in *-linux-musl*) if ! grep -q 'statically linked' <<<"$kind"; then - echo " WARNING: not statically linked — DSM will refuse to run this" >&2 + echo " WARNING: not statically linked — that target is meant to be" >&2 echo " $kind" >&2 fi ;; + armv7-unknown-linux-gnueabihf) + if command -v readelf >/dev/null; then + local want + want=$(readelf --dyn-syms "$bin" 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1) + echo " needs at most ${want:-GLIBC_?} — DSM 7 on armada38x has 2.20" + if [ -n "$want" ] && [ "$(printf '%s\n' "GLIBC_2.17" "$want" | sort -V | tail -1)" != "GLIBC_2.17" ]; then + echo " WARNING: that is newer than the 2.17 floor this target aims at" >&2 + fi + fi + ;; esac fi } +# A `.spk` is a release format, not a build: the binary is already finished, and this only +# wraps it for one platform's package manager. Which is why it is a flag here rather than a +# second build system — see packaging/dsm/. +# +# **The armv7 row is glibc, and it was musl once.** When the ARMv7 target moved to +# glibc — musl 1.2 cannot run on Synology's 3.10 kernels — this table was not moved with +# it, so `./build.sh --spk armv7-unknown-linux-gnueabihf` built the binary and then quietly +# produced no package at all, for the one machine this project exists for. The old name is +# kept as an alias so an existing script does not start failing instead. +spk_abi() { + case "$1" in + armv7-unknown-linux-gnueabihf) echo armv7 ;; + armv7-unknown-linux-musleabihf) echo armv7 ;; + aarch64-unknown-linux-musl) echo aarch64 ;; + x86_64-unknown-linux-musl) echo x86_64 ;; + *) return 1 ;; + esac +} + +package_one() { + local target="$1" abi + if ! abi=$(spk_abi "$target"); then + echo " no DSM package for ${target:-this machine} — a .spk carries a Linux build" >&2 + return 0 + fi + echo "==> packaging $abi for DSM" + packaging/dsm/make-spk.sh "$abi" +} + if [ ${#TARGETS[@]} -eq 0 ]; then build_one "" + # An `&&` here would be the script's last command, and `set -e` would make its + # "SPK=no" false the exit status of a successful build. deploy.sh keys on that. + if [ "$SPK" = yes ]; then package_one ""; fi else - for t in "${TARGETS[@]}"; do build_one "$t"; done + for t in "${TARGETS[@]}"; do + build_one "$t" + if [ "$SPK" = yes ]; then package_one "$t"; fi + done fi diff --git a/deploy.sh b/deploy.sh index 8dd0318..9df172b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -9,7 +9,7 @@ # script only replaces a running instance. # # Environment: -# TARGET rust target triple (default armv7-unknown-linux-musleabihf) +# TARGET rust target triple (default armv7-unknown-linux-gnueabihf) # ANSWERS answers directory (default /answers) # PORT listen port (default 8000) @@ -18,7 +18,7 @@ cd "$(dirname "$0")" HOST="${1:-}" REMOTE_DIR="${2:-/volume1/netboot}" -TARGET="${TARGET:-armv7-unknown-linux-musleabihf}" +TARGET="${TARGET:-armv7-unknown-linux-gnueabihf}" PORT="${PORT:-8000}" ANSWERS="${ANSWERS:-$REMOTE_DIR/answers}" @@ -47,6 +47,10 @@ echo "==> copying to $HOST:$REMOTE_DIR" scp -q "$BIN" "$HOST:$REMOTE_DIR/rescriptum.new" echo "==> restarting" +# The delimiter is deliberately unquoted: $REMOTE_DIR, $ANSWERS and $PORT are *ours*, and +# have to be interpolated here before the script is sent. Everything meant for the remote +# shell is escaped. +# shellcheck disable=SC2087 ssh "$HOST" bash -s < -1 errno=22 (Invalid argument) +syscall 263 (time32) -> 0 ok +syscall 403 (time64) -> -1 errno=22 (Invalid argument) +``` + +Le symptôme : un binaire qui répond à `--version` puis panique dès qu'il veut un horodatage +— `time.rs:131`, `Os { code: 22, kind: InvalidInput }`. Ce n'est ni un problème d'ABI ni un +noyau trop vieux pour les instructions, ce à quoi ça ressemble pourtant. + +La glibc, en 32 bits, utilise les appels time32, et DSM fournit la sienne (2.20 sur +`armada38x`). Le build armv7 vise donc un **plancher glibc 2.17** — assez bas pour DSM, et +comme la glibc est rétrocompatible, le même binaire tourne aussi sur un Linux ARMv7 récent. + +**Ce qu'il faut vérifier n'est donc plus qu'il est statique, mais qu'il ne réclame aucune +glibc plus récente que le plancher.** Plus récent échoue au moment de l'exec, sur le NAS, +en nommant une version de symbole et rien d'autre : ```console -$ file target/armv7-unknown-linux-musleabihf/release/rescriptum -ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, stripped +$ readelf --dyn-syms target/armv7-unknown-linux-gnueabihf/release/rescriptum \ + | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1 +GLIBC_2.17 ``` -La CI l'affirme à chaque push, pour exactement cette cible. +La CI l'affirme à chaque push. Les cibles musl, elles, restent vérifiées comme statiques, +parce que pour elles c'est la promesse. ### Installer Zig sur la machine du mainteneur @@ -61,7 +91,7 @@ symbolique dans `~/.local/bin/zig`. **Pour le mettre à jour, remplacez ce répe `brew upgrade zig` ne fait rien. Toolchain vérifiée : Rust 1.93, `cargo-zigbuild` 0.23.0, Zig 0.16.0, avec les cibles -`aarch64-apple-darwin` et `armv7-unknown-linux-musleabihf` installées. +`aarch64-apple-darwin` et `armv7-unknown-linux-gnueabihf` installées. ## Le profil release @@ -99,6 +129,54 @@ cargo build --no-default-features # le plus petit cargo test --all-features # ce que lance la CI ``` +## Le paquet Synology + +Un `.spk` est un **format de release**, pas un build : le binaire est fini avant que +l'empaquetage commence, il n'y a pas de build spécifique à DSM, et rien dans `src/` ne sait +que Synology existe. + +```bash +./build.sh --spk x86_64-unknown-linux-musl # compiler, puis emballer +packaging/dsm/make-spk.sh armv7 # emballer un build qui existe déjà +packaging/dsm/check-spk.sh # contrôle structurel sur dist/*.spk +``` + +| ABI | `arch` dans `INFO` | Depuis | +|---|---|---| +| `x86_64` | `x86_64` — le nom de *famille*, donc toutes les plateformes Intel | `x86_64-unknown-linux-musl` | +| `armv7` | `armada38x` — le raccourci de famille n'atteint pas les plateformes Marvell | `armv7-unknown-linux-gnueabihf` | +| `aarch64` | `armv8` | `aarch64-unknown-linux-musl`, une fois le binaire lancé sur l'une d'elles | + +La règle pour élargir : **revendiquer un ABI une fois le binaire lancé sur son membre au +noyau le plus ancien**, jamais parce qu'une plateforme est plausible. + +`make-spk.sh` est déterministe — mtimes fixes, propriété `0:0`, `ustar`, `gzip -n`, liste de +fichiers pré-triée — donc les mêmes entrées donnent un `.spk` identique octet pour octet, ce +qui est ce qui donne du sens à la somme publiée. + +`check-spk.sh` tourne dans la CI à chaque push. Il vérifie que l'archive externe est un tar +*non compressé*, que `INFO` a ses six champs obligatoires et une version tout en segments +numériques, que les icônes font exactement 64×64 et 256×256, que les scripts de cycle de vie +s'analysent et sont exécutables, et que **le `--version` du binaire empaqueté correspond à +`INFO`** — le build x86_64 tourne sur le runner, donc cette dernière assertion est réelle et +non une relecture de la même chaîne. + +`lifecycle-test.sh` déroule ensuite les scripts du paquet contre un faux arbre +`/var/packages` — installation, démarrage, `/health`, les codes de sortie, une mise à jour +par-dessus une configuration éditée à la main, une désinstallation par-dessus un canary dans +le partage — et tourne lui aussi à chaque push. + +```bash +packaging/dsm/lifecycle-test.sh +``` + +Ce que rien de tout cela ne peut prouver, c'est que DSM acceptera le paquet ; seule une +installation le peut. C'est le banc d'essai de +[`packaging/dsm/vm/`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/README.md) : +un lanceur QEMU, et un script qui joue les vérifications sur la machine — la VM pendant +qu'on itère, le DS416j pour le verdict. Voir +[tests](./testing.md#le-paquet-aussi-est-testé-à-trois-endroits). + ## Déployer un build ```bash @@ -112,6 +190,6 @@ copie sous un nom temporaire, redémarre, et confirme `/health`. Voir | Environnement | Défaut | |---|---| -| `TARGET` | `armv7-unknown-linux-musleabihf` | +| `TARGET` | `armv7-unknown-linux-gnueabihf` | | `ANSWERS` | `/answers` | | `PORT` | `8000` | diff --git a/docs/development/building.md b/docs/development/building.md index 5d9d070..43143af 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -12,7 +12,7 @@ sidebar: ./build.sh # this machine, and print the size ./build.sh --all # every target a release ships ./build.sh --no-sqlite # the smallest binary -./build.sh armv7-unknown-linux-musleabihf +./build.sh armv7-unknown-linux-gnueabihf ./build.sh --help ``` @@ -26,7 +26,7 @@ Plain `cargo build` works too; `build.sh` exists for the size report and that wa | Target | For | Cross | |---|---|---| -| `armv7-unknown-linux-musleabihf` | the DS416j, the reason this project exists | zigbuild | +| `armv7-unknown-linux-gnueabihf` | the DS416j, the reason this project exists — **glibc, not musl**, see below | zigbuild, floor 2.17 | | `aarch64-unknown-linux-musl` | modern ARM NAS, Raspberry Pi | zigbuild | | `x86_64-unknown-linux-musl` | most other Linux hosts | zigbuild | | `aarch64-apple-darwin` | local development | native | @@ -39,18 +39,48 @@ which avoids a full cross toolchain per target: ```bash cargo install cargo-zigbuild -cargo zigbuild --release --target armv7-unknown-linux-musleabihf +cargo zigbuild --release --target armv7-unknown-linux-gnueabihf.2.17 ``` -**Verify it really is static.** A dynamically linked musl binary fails at exec time, on -the NAS, with an error that does not obviously say so: +## Why armv7 is the one target that is not musl + +Every other target is static musl. ARMv7 is glibc, and it is not a preference — it is the +only way the machine this project exists for runs the binary at all. + +**Synology's ARMv7 kernels are 3.10, and they answer the *time64* syscalls with `EINVAL` +rather than `ENOSYS`.** musl 1.2 made `time_t` 64-bit on 32-bit architectures and tries +`clock_gettime64` (and `clock_nanosleep`, and the timed futex) first, falling back to the +32-bit syscall **only on `ENOSYS`**. On a kernel that says `EINVAL` the fallback never +happens, so every call for the time fails. Measured on a DS416j running DSM 7.1, kernel +3.10.108: + +```console +$ ./probe +libc clock_gettime(CLOCK_REALTIME) -> -1 errno=22 (Invalid argument) +syscall 263 (time32) -> 0 ok +syscall 403 (time64) -> -1 errno=22 (Invalid argument) +``` + +The symptom is a binary that answers `--version` and then panics the moment it wants a +timestamp — `time.rs:131`, `Os { code: 22, kind: InvalidInput }`. It is not an ABI problem +and not a kernel-too-old-for-the-instructions problem, which is what it looks like. + +glibc on 32-bit uses the time32 syscalls, and DSM ships its own (2.20 on `armada38x`). So +the armv7 build targets a **glibc floor of 2.17** — low enough for DSM, and since glibc is +backward compatible, the same binary runs on newer ARMv7 Linux as well. + +**What to verify, then, is not that it is static — it is that it needs no glibc newer than +the floor.** Anything newer fails at exec time on the NAS, naming a symbol version and +nothing else: ```console -$ file target/armv7-unknown-linux-musleabihf/release/rescriptum -ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, stripped +$ readelf --dyn-syms target/armv7-unknown-linux-gnueabihf/release/rescriptum \ + | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1 +GLIBC_2.17 ``` -CI asserts this on every push, for exactly that target. +CI asserts exactly that on every push. The musl targets are still checked for being static, +because for them that is the promise. ### Installing Zig on the maintainer's machine @@ -60,7 +90,7 @@ untrusted third-party taps unrelated to Zig. It lives in `~/.local/zig`, symlink nothing. Verified toolchain: Rust 1.93, `cargo-zigbuild` 0.23.0, Zig 0.16.0, with targets -`aarch64-apple-darwin` and `armv7-unknown-linux-musleabihf` installed. +`aarch64-apple-darwin` and `armv7-unknown-linux-gnueabihf` installed. ## The release profile @@ -97,6 +127,51 @@ cargo build --no-default-features # smallest cargo test --all-features # what CI runs ``` +## The Synology package + +A `.spk` is a **release format**, not a build: the binary is finished before packaging +begins, there is no DSM-specific build, and nothing in `src/` knows Synology exists. + +```bash +./build.sh --spk x86_64-unknown-linux-musl # build, then wrap it +packaging/dsm/make-spk.sh armv7 # wrap a build that already exists +packaging/dsm/check-spk.sh # structural check over dist/*.spk +``` + +| ABI | `arch` in `INFO` | From | +|---|---|---| +| `x86_64` | `x86_64` — the *family* name, so it covers every Intel platform | `x86_64-unknown-linux-musl` | +| `armv7` | `armada38x` — the family shorthand does not reach the Marvell platforms | `armv7-unknown-linux-musleabihf` | +| `aarch64` | `armv8` | `aarch64-unknown-linux-musl`, once the binary has been run on one | + +The rule for widening that: **claim an ABI once the binary has run on the oldest-kernel +member of it**, never because a platform is plausible. + +`make-spk.sh` is deterministic — fixed mtimes, ownership `0:0`, `ustar`, `gzip -n`, a +pre-sorted file list — so the same inputs give a byte-identical `.spk`, which is what makes +the published checksum worth something. + +`check-spk.sh` runs in CI on every push. It asserts the outer archive is an *uncompressed* +tar, that `INFO` has its six required fields and an all-numeric version, that the icons are +exactly 64×64 and 256×256, that the lifecycle scripts parse and are executable, and that +**the packaged binary's own `--version` matches `INFO`** — the x86_64 build runs on the +runner, so that last one is a real assertion rather than a re-read of the same string. + +`lifecycle-test.sh` then drives the package's own scripts against a fake `/var/packages` +tree — install, start, `/health`, the exit codes, an upgrade over a hand-edited +configuration, an uninstall over a canary in the share — and also runs on every push. + +```bash +packaging/dsm/lifecycle-test.sh +``` + +What none of that can prove is that DSM will accept the package; only installing it can. +That is the rig in +[`packaging/dsm/vm/`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/README.md): +a QEMU launcher, and one script that runs the on-machine checks against the VM while you +iterate and against the DS416j for the verdict. See +[testing](./testing.md#the-package-is-tested-too-in-three-places). + ## Deploying a build ```bash diff --git a/docs/development/releasing.fr.md b/docs/development/releasing.fr.md index a79d7bc..cffd7d0 100644 --- a/docs/development/releasing.fr.md +++ b/docs/development/releasing.fr.md @@ -70,11 +70,27 @@ git push origin main --follow-tags 3. Empaquette chacune en `rescriptum--.tar.gz`, avec `README.md` et `LICENSE` à côté du binaire, plus une **somme SHA-256** — qui fait tourner cela en root devrait pouvoir vérifier ce qu'il a téléchargé. -4. Crée la GitHub Release avec `gh` et `--generate-notes`. +4. Emballe les builds musl Linux en [paquets Synology](./building.md#le-paquet-synology), + `rescriptum---.spk`, et contrôle structurellement chacun avant qu'il + puisse être publié. +5. Crée la GitHub Release avec `gh` et `--generate-notes`, ou verse dedans si elle existe + déjà. Il est relançable à la main via `workflow_dispatch` avec un tag, pour quand un job échoue après que le tag est déjà poussé. +**Un correctif d'empaquetage seul n'a pas besoin de tag.** Les versions SPK sont faites de +segments tous numériques et le dernier est un numéro de build de paquet, donc `v0.1.0` donne +`0.1.0-1` ; un déclenchement manuel avec `spk_build: 2` attache +`rescriptum-0.1.0-2-.spk` à la même Release. Une préversion ne produit aucun `.spk` — +les archives sont le canal des préversions. + +**Un tag ne doit pas être la première fois qu'un `.spk` est installé sur une machine DSM.** +Le contrôle structurel attrape une archive cassée ; seul Package Center attrape un paquet +cassé, et le premier publié est celui dont les scripts de désinstallation tourneront pendant +la première mise à jour de tout le monde. La liste des vérifications est dans +[`packaging/dsm/README.md`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/README.md). + Chaque action utilisée est une action officielle `actions/*`, et `gh` est déjà sur le runner. C'est délibéré, pour la même raison que tout le reste de cette page. diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 92732ef..890e8cb 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -70,11 +70,26 @@ git push origin main --follow-tags 3. Packages each as `rescriptum--.tar.gz`, with `README.md` and `LICENSE` alongside the binary, plus a **SHA-256 sum** — whoever runs this as root should be able to check what they downloaded. -4. Cuts the GitHub Release with `gh` and `--generate-notes`. +4. Wraps the Linux musl builds as [Synology packages](./building.md#the-synology-package), + `rescriptum---.spk`, and checks each structurally before it can be + published. +5. Cuts the GitHub Release with `gh` and `--generate-notes`, or uploads into it if it + already exists. It is re-runnable by hand through `workflow_dispatch` with a tag, for when a job fails after the tag is already pushed. +**A packaging-only fix needs no tag.** SPK versions are all-numeric segments and the last +one is a package build number, so `v0.1.0` produces `0.1.0-1`; dispatching by hand with +`spk_build: 2` attaches `rescriptum-0.1.0-2-.spk` to the same Release. A prerelease +does not produce an `.spk` at all — the archives are the prerelease channel. + +**A tag must not be the first time an `.spk` is installed on a DSM machine.** The +structural check catches a broken archive; only Package Center catches a broken package, +and the first published one is the one whose uninstall scripts will run during everybody's +first upgrade. The checklist is in +[`packaging/dsm/README.md`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/README.md). + Every action used is an official `actions/*` one, and `gh` is already on the runner. That is deliberate for the same reason as everything else in this file. diff --git a/docs/development/testing.fr.md b/docs/development/testing.fr.md index 32e7a00..4e6b0a7 100644 --- a/docs/development/testing.fr.md +++ b/docs/development/testing.fr.md @@ -8,7 +8,7 @@ sidebar: # Tests -308 tests. `cargo test` les fait tous tourner en quelques secondes. +333 tests. `cargo test` les fait tous tourner en quelques secondes. ```bash cargo test # tout @@ -21,19 +21,19 @@ cargo test --all-features # ce que lance la CI | Suite | Cas | Pour | |---|---|---| -| `tests/stores.rs` | 38 | **chaque comportement, contre les deux stores** | +| `tests/stores.rs` | 39 | **chaque comportement, contre les deux stores** | | `tests/integration.rs` | 45 | le vrai binaire sur une vraie socket | | `src/select.rs` | 27 | normalisation, scoring, superposition, remplissage de templates | | `src/format/mod.rs` | 27 | parsing, fusion, clés de contrôle, alias d'endpoint | | `src/facts.rs` | 22 | parsing de query, aplatissement JSON, globbing | | `tests/admin.rs` | 26 | l'API d'administration de bout en bout, formats compris | -| `tests/cli.rs` | 29 | `render`, `check`, `import`, `export` et le fichier d'environnement — contre le vrai binaire | +| `tests/cli.rs` | 39 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | | `src/log.rs` | 4 | lecture des niveaux, et l'arithmétique d'horodatage | | `src/format/xml.rs` | 18 | l'arbre XML — appariement, entités, fidélité | -| `src/config.rs` | 19 | l'environnement, et ce qui refuse de démarrer | +| `src/config.rs` | 24 | l'environnement, ce qui refuse de démarrer, et qui l'emporte du fichier ou de l'environnement | | `src/merge.rs` | 11 | la fusion profonde TOML | | `tests/guards.rs` | 7 | le jeton de réponse, et le verrouillage qui délibérément n'existe pas | -| `src/envfile.rs` | 14 | le parseur du fichier d'environnement, et ce qu'il refuse | +| `src/envfile.rs` | 23 | le parseur et l'écrivain du fichier d'environnement, et ce que chacun refuse | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | comportement unitaire | ## `tests/stores.rs` — la suite de conformité @@ -122,6 +122,43 @@ travaillé de chaque format, et c'est le seul endroit où ils sont montrés en t composer ensemble. Deux d'entre eux ont attrapé de vrais bugs — un doctype manquant et un attribut `pass` non apparié. Gardez-les fonctionnels. +## Le paquet aussi est testé, à trois endroits + +`cargo test` ne touche pas au paquet DSM, parce que rien là-dedans n'est du Rust. Trois +harnais s'en chargent, et chacun prouve ce que les autres ne peuvent pas. + +| | Prouve | Coût | +|---|---|---| +| [`packaging/dsm/check-spk.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/check-spk.sh) | l'archive est structurellement ce que DSM attend — tar externe non compressé, les six champs d'`INFO`, une version tout en segments numériques, `os_min_ver` au moins 7.1, icônes 64×64 et 256×256, scripts exécutables sans CRLF, **le `--version` du binaire empaqueté**, et l'application de bureau : un `dsmappname` nommant une classe que son `ui/config` déclare vraiment, un nom de fichier JavaScript qui porte la version, et un backend qui vérifie toujours la session DSM et `administrators` | des secondes, **à chaque push** | +| [`packaging/dsm/lifecycle-test.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/lifecycle-test.sh) | tout ce que les *scripts* du paquet décident, contre un faux arbre `/var/packages` : le fichier d'environnement écrit une fois et une seule, les valeurs de l'assistant **et leur absence**, le service qui survit à son propre script de démarrage et répond à `/health`, les codes de sortie que lit Package Center, une mise à jour qui ne doit pas toucher une configuration éditée à la main, une désinstallation qui ne doit pas toucher aux réponses — **et le backend de l'application de bureau**, piloté avec un authentificateur bouchonné : refuser l'absence de session, refuser un non-administrateur, refuser une écriture sans en-tête d'intention, refuser celle qui empêcherait le serveur de démarrer, et ne jamais livrer un jeton au navigateur | des secondes, **à chaque push** | +| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | la machinerie propre à DSM — le worker `data-share` et son ACL, le worker `port-config`, l'unité systemd générée, logrotate contre un descripteur vivant, si Package Center accepte l'archive — **et qu'une machine qui demande sa configuration en reçoit une** : un POST avec le matériel dans le corps, auquel répond le fichier de cette machine fusionné par-dessus le groupe qui la revendique | des minutes, sur une VM DSM 7 — puis sur le DS416j | + +```bash +packaging/dsm/lifecycle-test.sh # le premier .spk de dist/ qui tourne ici +docker compose -f packaging/dsm/vm/docker-compose.yml up -d # une machine DSM 7.2 +packaging/dsm/vm/on-dsm.sh admin@ -p 2222 # contre elle +packaging/dsm/vm/on-dsm.sh admin@nas # le verdict +``` + +La VM, c'est `vdsm/virtual-dsm`, qui installe la Virtual DSM officielle de Synology — aucune +image de loader à trouver. KVM la rend rapide, pas possible : sans `/dev/kvm` elle émule, dix +fois plus lentement, et c'est à ça que sert `docker-compose.emulated.yml`. En revanche elle +veut **14 Gio libres** pour son stockage, en dur dans l'image. + +Le dernier est **destructeur exprès** — il met à jour par-dessus un fichier d'environnement +édité à la main et un canary dans le dossier partagé, puis désinstalle, puis vérifie que les +deux ont survécu. Ces deux gardes sont les choses les plus coûteuses à rater dans ce paquet, +et le premier `.spk` publié est celui dont les scripts de désinstallation tourneront pendant +la première mise à jour de tout le monde. +[`packaging/dsm/vm/README.md`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/README.md) +décrit le banc d'essai : ce dont il est une preuve, et ce dont il ne l'est pas. + +La même règle que partout ailleurs vaut pour eux : **cassez ce qu'ils gardent et +regardez-les virer au rouge.** Annuler la garde de `postinst` à la mise à jour, faire +supprimer le partage par `postuninst`, renvoyer `1` pour un paquet arrêté et refuser +`prestart` transforme 33 vérifications vertes en 25 vertes et 8 rouges — c'est ainsi qu'on +sait que le harnais teste quelque chose. + ## CI `.github/workflows/ci.yml`, à chaque push sur `main` et `develop` et à chaque pull request : @@ -131,7 +168,7 @@ attribut `pass` non apparié. Gardez-les fonctionnels. | **gates** | `cargo fmt --all --check`, `cargo clippy --all-targets --all-features -D warnings`, `cargo test --all-features`, `cargo build --release --no-default-features` | | **docs** | construit le site public et lance `notabene lint` | | **audit** | `cargo audit --deny warnings` sur l'arbre de dépendances | -| **cross** | un build ARMv7-musl complet, puis affirme que le binaire est bien `statically linked` | +| **cross** | un build ARMv7-musl complet, puis affirme que le binaire est bien `statically linked`, puis assemble les deux `.spk`, les contrôle structurellement et déroule le cycle de vie du paquet | Le job cross n'est pas redondant. **SQLite est compilé depuis les sources, et `armv7-musl` est la cible la moins indulgente qui soit livrée** — c'est là qu'une dépendance C casse en premier. diff --git a/docs/development/testing.md b/docs/development/testing.md index f6602db..120610f 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -8,7 +8,7 @@ sidebar: # Testing -308 tests. `cargo test` runs all of them in a couple of seconds. +333 tests. `cargo test` runs all of them in a couple of seconds. ```bash cargo test # everything @@ -21,19 +21,19 @@ cargo test --all-features # what CI runs | Suite | Cases | For | |---|---|---| -| `tests/stores.rs` | 38 | **every behaviour, against both stores** | +| `tests/stores.rs` | 39 | **every behaviour, against both stores** | | `tests/integration.rs` | 45 | the real binary over a real socket | | `src/select.rs` | 27 | normalization, scoring, layering, template filling | | `src/format/mod.rs` | 27 | parsing, merging, control keys, endpoint aliases | | `src/facts.rs` | 22 | query parsing, JSON flattening, globbing | | `tests/admin.rs` | 26 | the admin API end to end, formats included | -| `tests/cli.rs` | 29 | `render`, `check`, `import`, `export`, and the env file — against the real binary | +| `tests/cli.rs` | 39 | `render`, `check`, `import`, `export`, `config`, and the env file — against the real binary | | `src/log.rs` | 4 | level parsing, and the timestamp arithmetic | | `src/format/xml.rs` | 18 | the XML tree — pairing, entities, fidelity | -| `src/config.rs` | 19 | the environment, and what refuses to start | +| `src/config.rs` | 24 | the environment, what refuses to start, and which of the file and the environment wins | | `src/merge.rs` | 11 | the TOML deep merge | | `tests/guards.rs` | 7 | the answer token, and the lockout that deliberately is not there | -| `src/envfile.rs` | 14 | the env-file parser, and what it refuses | +| `src/envfile.rs` | 23 | the env-file parser and writer, and what each refuses | | `src/admin.rs`, `src/capture.rs`, `src/store/mod.rs` | 21 | unit-level behaviour | ## `tests/stores.rs` — the conformance suite @@ -115,6 +115,41 @@ RESCRIPTUM_ANSWERS_DIR=examples cargo run -- check of every format, and it is the only place they are shown composing together. Two of them caught real bugs — a missing doctype and an unpaired `pass` attribute. Keep them working. +## The package is tested too, in three places + +`cargo test` does not touch the DSM package, because none of it is Rust. Three harnesses +do, and each proves something the others cannot. + +| | Proves | Cost | +|---|---|---| +| [`packaging/dsm/check-spk.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/check-spk.sh) | the archive is structurally what DSM expects — uncompressed outer tar, six `INFO` fields, an all-numeric version, `os_min_ver` at least 7.1, 64×64 and 256×256 icons, executable scripts with no CRLF, **the packaged binary's own `--version`**, and the desktop application: `dsmappname` naming a class its `ui/config` actually declares, a JavaScript filename that carries the version, and a backend that still checks the DSM session and `administrators` | seconds, **on every push** | +| [`packaging/dsm/lifecycle-test.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/lifecycle-test.sh) | everything the package's *scripts* decide, against a fake `/var/packages` tree: the env file written once and only once, the wizard's values **and their absence**, the service surviving its own start script and answering `/health`, the exit codes Package Center reads, an upgrade that must not touch a hand-edited configuration, an uninstall that must not touch the answers — **and the desktop application's backend**, driven with a stubbed authenticator: refusing no session, refusing a non-administrator, refusing a write with no intent header, refusing one that would stop the server starting, and never handing a token to the browser | seconds, **on every push** | +| [`packaging/dsm/vm/on-dsm.sh`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/on-dsm.sh) | DSM's own machinery — the `data-share` worker and its ACL, the `port-config` worker, the generated systemd unit, logrotate against a live descriptor, whether Package Center accepts the archive — **and that a machine asking for its configuration gets one**: a POST with hardware in the body, answered by that machine's file merged over the group claiming it | minutes, on a DSM 7 VM — and then on the DS416j | + +```bash +packaging/dsm/lifecycle-test.sh # the first .spk in dist/ that runs here +docker compose -f packaging/dsm/vm/docker-compose.yml up -d # a DSM 7.2 machine +packaging/dsm/vm/on-dsm.sh admin@ -p 2222 # against it +packaging/dsm/vm/on-dsm.sh admin@nas # the verdict +``` + +The VM is `vdsm/virtual-dsm`, which installs Synology's own Virtual DSM release — no loader +image to find. KVM makes it fast rather than possible: without `/dev/kvm` it emulates, about +ten times slower, which is what `docker-compose.emulated.yml` is for. It does want **14 GiB +free** for the storage, hardcoded in the image. + +The last one is **destructive on purpose** — it upgrades over a hand-edited env file and a +canary in the shared folder, then uninstalls, then checks both survived. Those two guards +are the most expensive things in the package to get wrong, and the first published `.spk` +is the one whose uninstall scripts will run during everybody's first upgrade. +[`packaging/dsm/vm/README.md`](https://github.com/z29k/rescriptum/blob/main/packaging/dsm/vm/README.md) +is the rig: what it is evidence about, and what it is not. + +The same rule as everywhere else applies to these: **break the thing they guard and watch +them go red.** Reverting the `postinst` upgrade guard, making `postuninst` delete the +share, returning `1` for a stopped package and refusing `prestart` turns 33 green checks +into 25 green and 8 red — which is how we know the harness is testing anything at all. + ## CI `.github/workflows/ci.yml`, on every push to `main` and `develop` and on every pull @@ -125,7 +160,7 @@ request: | **gates** | `cargo fmt --all --check`, `cargo clippy --all-targets --all-features -D warnings`, `cargo test --all-features`, `cargo build --release --no-default-features` | | **docs** | builds the public site and runs `notabene lint` | | **audit** | `cargo audit --deny warnings` over the dependency tree | -| **cross** | a full ARMv7-musl build, then asserts the binary really is `statically linked` | +| **cross** | a full ARMv7 build against the glibc floor DSM has, asserting it needs nothing newer, then assembles both `.spk`s, checks them structurally and drives the package lifecycle | The cross job is not redundant. **SQLite is compiled from source, and `armv7-musl` is the least forgiving target shipped** — it is where a C dependency breaks first. Catching that diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index e5b32a8..0534856 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -49,6 +49,16 @@ recevait un reset. Il draine maintenant brièvement d'abord, comme le faisait d ## Sélection et formats +**Un Mac qui édite le répertoire de réponses en SMB peut détourner la réponse d'une +machine.** macOS écrit un fichier AppleDouble `._` à côté d'un fichier dont le système +n'accepte pas les attributs étendus — `._98-fa-9b-50-d8-10.toml` a une extension *présente* +dans la liste, et la normalisation retire le `._` de tête : il revendique donc la même +identité que le vrai fichier, avec un contenu binaire. La machine qu'on configurait reçoit +une erreur d'analyse au lieu de sa réponse, et `check` fait échouer le *groupe* avec elle. +`.DS_Store` n'est inoffensif que par chance (son extension n'est pas dans la liste). Le store +fichiers ignore désormais toute entrée dont le nom commence par `.` ; trouvé sur un vrai NAS, +pas en lisant quoi que ce soit. + **Normaliser un motif de sélecteur retire `*` et `?`** à moins d'utiliser `normalize_pattern` — ce qui transforme chaque glob en littéral, en silence. @@ -113,6 +123,162 @@ TOML valide, un texte différent. l'histoire de ce projet n'ont silencieusement rien fait et n'ont été attrapées qu'en vérifiant le nombre de tests ensuite. Vérifiez que l'ancien texte a été trouvé avant d'écrire. +## Empaqueter pour DSM + +**Un script shell qui marche sur macOS n'est pas un script qui marche en CI.** Deux cas +trouvés en faisant tourner les harnais dans un conteneur Linux plutôt qu'en leur faisant +confiance : `stat -f '%Lp'` est le drapeau de format sur BSD et *statut du système de +fichiers* sur GNU — où il **réussit**, en déversant des informations d'overlayfs dans une +variable censée contenir un mode de fichier, si bien que le repli ne se déclenche jamais. +Demander d'abord à GNU (`stat -c '%a' || stat -f '%Lp'`), qui échoue proprement sur macOS. +Et `shasum` est un script Perl qu'une Debian minimale n'a pas : `sha256sum` vient de +coreutils et existe partout sur Linux. Les runners Ubuntu ont les deux, ce qui est +exactement la façon dont un script pareil part cassé chez tous les autres. + +**musl 1.2 ne peut pas tourner sur les noyaux ARMv7 de Synology, et le symptôme ne nomme +rien.** Ces noyaux sont des 3.10 et répondent `EINVAL` aux appels *time64* ; musl ne se +replie sur les appels 32 bits que sur `ENOSYS`, donc `clock_gettime`, `clock_nanosleep` et le +futex temporisé échouent tous. Le binaire s'installe, répond à `--version`, puis panique à +`time.rs:131` avec `Os { code: 22, kind: InvalidInput }` dès qu'il veut un horodatage — ce +qui ressemble à un problème d'ABI ou de noyau trop vieux, et n'est ni l'un ni l'autre. La +cible armv7 est en glibc avec un plancher 2.17 pour cette raison ; les cibles 64 bits n'ont +pas le clivage time32/time64 et ne sont pas concernées. Prouvé par une sonde C de dix lignes +sur la machine, pas en lisant quoi que ce soit. + +**`SYNOPKG_PKGDEST` vaut `/volume1/@appstore/`, pas `/var/packages//target`.** +Le second est un lien vers le premier, donc `dirname "$SYNOPKG_PKGDEST"` donne +`/volume1/@appstore` et tout ce qu'on y accroche — `etc/`, `var/`, `shares/` — atterrit là où +rien ne lit. La racine du paquet est un chemin fixe. Ça coûte un service qui s'installe +parfaitement et ne démarre jamais, et un harnais sur faux arbre ne peut pas l'attraper : dans +un arbre qu'on a construit soi-même, `dirname` tombe juste par construction. + +**`$SYNOPKG_TEMP_UPGRADE_FOLDER` survit à la mise à jour qui l'a créé.** Une installation +*neuve* qui le lit y trouve la configuration d'une installation que l'utilisateur a +supprimée, et la restaure en silence — jetons compris. La restauration doit exiger +`SYNOPKG_PKG_STATUS = UPGRADE`. + +**`etc/` et `var/` survivent à une désinstallation.** Ce sont des liens vers +`/volume1/@appconf/` et `/volume1/@appdata/`, que DSM conserve. Le fichier +d'environnement, jetons inclus, reste donc sur le volume après la disparition du paquet — ce +que la documentation doit dire, et qui fait échouer le tour *suivant* d'un banc qui ne les +efface pas, pour des raisons appartenant au précédent. + +**Un compte DSM portant le nom de l'utilisateur du paquet est détruit avec lui.** Le +`username` de `conf/privilege` crée un utilisateur système à l'installation ; un +administrateur du même nom est masqué par lui puis supprimé à la désinstallation. + +**Le répertoire du pare-feu est `/usr/local/etc/services.d/`** — au pluriel. Le guide +développeur dit `service.d`, qui n'existe pas. Le worker `port-config` acquiert **après +`postinst`**, donc le port de l'assistant atteint bien l'entrée pare-feu dès l'installation. + +**`port-config` et `usr-local-linker` acquièrent quand le paquet est *activé*,** pas quand +`postinst` tourne : vérifiés plus tôt, ils sont toujours absents. + +**L'unité générée n'a pas de `Restart=`** — `Type=oneshot`, `RemainAfterExit=yes`, +`TimeoutStartSec=3600`. DSM ne relance pas le processus s'il meurt. + +**`postinst` tourne aussi à une mise à jour, et il tourne *avant* `postupgrade`.** Donc +« le fichier d'environnement est absent » n'est pas la même question que « c'est une +installation neuve » : sur une mise à jour où `etc/` n'a pas survécu, y écrire les valeurs +par défaut détruit le port et les jetons de l'utilisateur avant que la restauration ne +tourne. `postinst` consulte `$SYNOPKG_TEMP_UPGRADE_FOLDER` avant de décider. Trouvé en +simulant ce cas précis, pas en lisant la séquence documentée. + +**Les `preuninst`/`postuninst` de l'ancienne version tournent pendant une mise à jour.** +Tout ce qu'ils ont de destructeur tourne donc à chaque mise à jour — et le **premier `.spk` +publié** est celui dont les scripts de désinstallation tourneront pendant la première mise +à jour de tout le monde. Ils ne peuvent pas être corrigés après coup. + +**`status` qui renvoie `1` veut dire « planté, pidfile resté »**, pas « arrêté ». Un paquet +proprement arrêté, c'est `3`. Renvoyer `1` dit à Package Center que le service est mort. + +**`prestart` tourne au boot**, et DSM l'appelle que vous l'ayez écrit ou non — +`precheckstartstop` vaut `"yes"` par défaut. Un `case` qui sort non-zéro sur un verbe +inconnu empêche le paquet de démarrer après un reboot pour toujours, avec un symptôme +(« marche à la main, jamais après un reboot ») qui ressemble à tout sauf à un bras de `case` +manquant. + +**Les scripts de cycle de vie ne sont pas root.** `run-as: package` les gouverne, pas +seulement le service — donc un chown hors de l'arbre du paquet, ou `synopkghelper`, échoue, +possiblement en silence. + +**`data-share` tourne au *démarrage* du paquet, pas à l'installation**, donc rien dans +`postinst` ne peut supposer que le dossier partagé existe. Et un nom d'utilisateur qui ne +correspond pas à sa liste de permissions crée le partage et l'accorde à personne, sans un +mot. + +**Une strophe logrotate sans `copytruncate` arrête silencieusement la journalisation** : +`log::init` ouvre le fichier une fois et ne le rouvre jamais, donc une rotation déplace +l'inode sous un serveur qui continue d'écrire dans un fichier sans nom. + +**Un `.spk` dont le tar externe est gzippé est rejeté** avec « invalid file format » et rien +de plus. Idem pour un qui embarque des membres `._` de macOS. `check-spk.sh` vérifie les + +## L'application de bureau DSM + +Sept choses, mesurées sur une machine virtuelle DSM 7.2.2 et sur un DS416j en 7.1.1, et +aucune dans le guide du développeur. + +**Un CGI sous `/webman/3rdparty//` tourne sous le propriétaire du script.** Pas en +`http`, et pas en root — sous celui qui possède le fichier. DSM attribue l'arborescence d'un +paquet à l'utilisateur du paquet : le backend de l'application tourne donc en `rescriptum` et +peut lire le fichier d'environnement en `0600` qu'il possède, ce qui est toute la raison pour +laquelle la configuration reste modifiable pendant que le serveur est arrêté. Prouvé en +attribuant le même script de deux façons et en regardant `id` changer. Un script resté +possédé par root, lui, **tourne bien** en root là-bas : n'en laissez pas traîner. + +**Ce chemin n'est pas authentifié par DSM.** Une requête non authentifiée atteint le script +et reçoit `200`. Ce qui garde le CGI d'un paquet, c'est le paquet qui l'a écrit — ici +`authenticate.cgi` plus un contrôle `administrators`, et en perdre un serait silencieux. + +**`su` dans un CGI bloque la requête.** Sans `` beside a file whose extended attributes the filesystem will not +take — `._98-fa-9b-50-d8-10.toml` has an extension that *is* on the allowlist, and +normalization strips the leading `._`, so it claims the same identity as the real file with a +body that is binary. The machine being configured then receives a parse error instead of its +answer, and `check` reports the failure against the *group* as well. `.DS_Store` is harmless +only by luck (its extension is not on the list). The file store now skips every entry whose +name starts with `.`; found on a real NAS, not by reading anything. + **Normalizing a selector pattern strips `*` and `?`** unless you use `normalize_pattern` — which turns every glob into a literal, quietly. @@ -104,6 +113,151 @@ key's original decor, so the output can read `value= 3` — valid TOML, differen project's history silently no-opped and were only caught by checking test counts afterwards. Assert the old text was found before writing. +## Packaging for DSM + +**A shell script that works on macOS is not a shell script that works on CI.** Two found by +running the harnesses in a Linux container rather than trusting them: `stat -f '%Lp'` is the +format flag on BSD and *filesystem status* on GNU — where it **succeeds**, printing overlayfs +trivia into a variable that was supposed to hold a file mode, so the fallback never fires. +Ask GNU first (`stat -c '%a' || stat -f '%Lp'`), which fails cleanly on macOS. And `shasum` +is a Perl script that a minimal Debian does not have: `sha256sum` is coreutils and is +everywhere on Linux. Ubuntu runners carry both, which is exactly how a script like that ships +broken to everyone else. + +**musl 1.2 cannot run on Synology's ARMv7 kernels, and the symptom names nothing.** Those +kernels are 3.10 and answer the *time64* syscalls with `EINVAL`; musl falls back to the +32-bit ones only on `ENOSYS`, so `clock_gettime`, `clock_nanosleep` and the timed futex all +fail. The binary installs, answers `--version`, and panics at `time.rs:131` with +`Os { code: 22, kind: InvalidInput }` the moment it wants a timestamp — which looks like an +ABI or a too-old-kernel problem and is neither. The armv7 target is glibc with a 2.17 floor +for this reason; 64-bit targets have no time32/time64 split and are unaffected. Proven with +a ten-line C probe on the machine, not by reading anything. + +**`SYNOPKG_PKGDEST` is `/volume1/@appstore/`, not `/var/packages//target`.** +The second is a symlink to the first, so `dirname "$SYNOPKG_PKGDEST"` is `/volume1/@appstore` +and everything hung off it — `etc/`, `var/`, `shares/` — lands where nothing reads it. The +package root is a fixed path. This one costs a service that installs perfectly and never +starts, and a fake-tree harness cannot catch it: in a tree you built yourself, `dirname` is +right by construction. + +**`$SYNOPKG_TEMP_UPGRADE_FOLDER` outlives the upgrade that created it.** A *fresh* install +that reads it finds the configuration of an installation the user removed, and silently +restores it — tokens and all. Restoring from it has to require `SYNOPKG_PKG_STATUS = UPGRADE`. + +**`etc/` and `var/` survive an uninstall.** They are symlinks into `/volume1/@appconf/` +and `/volume1/@appdata/`, which DSM keeps. So the env file, tokens included, stays on +the volume after the package is gone — which the documentation has to say, and which makes +a rig that does not clear them fail on the *next* run for reasons belonging to the last one. + +**A DSM account named after the package user is destroyed with it.** `conf/privilege`'s +`username` creates a system user at install; an administrator of the same name is shadowed +by it and removed on uninstall. + +**The firewall directory is `/usr/local/etc/services.d/`** — plural. The developer guide says +`service.d`, which does not exist. The `port-config` worker acquires **after `postinst`**, so +the wizard's port does reach the firewall entry on a fresh install. + +**`port-config` and `usr-local-linker` acquire when the package is *enabled*,** not when +`postinst` runs: checked any earlier they are always absent. + +**The generated unit has no `Restart=`** — `Type=oneshot`, `RemainAfterExit=yes`, +`TimeoutStartSec=3600`. DSM does not restart the process if it dies. + +**`postinst` runs on an upgrade too, and it runs *before* `postupgrade`.** So "the env +file is absent" is not the same question as "this is a fresh install": on an upgrade where +`etc/` did not survive, writing defaults there destroys the user's port and tokens before +the restore ever runs. `postinst` checks `$SYNOPKG_TEMP_UPGRADE_FOLDER` before it decides. +Found by simulating that exact case, not by reading the documented sequence. + +**The old version's `preuninst`/`postuninst` run during an upgrade.** Anything destructive +in them therefore runs every time somebody upgrades — and the *first published* `.spk` is +the one whose uninstall scripts will run during everybody's first upgrade. They cannot be +fixed later. + +**`status` returning `1` means "crashed, stale pidfile"**, not "stopped". A cleanly stopped +package is `3`. Returning `1` tells Package Center the service died. + +**`prestart` runs at boot**, and DSM calls it whether or not you wrote it — +`precheckstartstop` defaults to `"yes"`. A `case` that exits non-zero on an unrecognised +verb stops the package from ever starting after a reboot, with a symptom ("works by hand, +never after a reboot") that looks like anything but a missing case arm. + +**The lifecycle scripts are not root.** `run-as: package` governs them, not only the +service — so a chown outside the package tree, or `synopkghelper`, fails, possibly +silently. + +**`data-share` runs at package *start*, not at install**, so nothing in `postinst` may +assume the shared folder exists. And a username that does not match its permission list +creates the share and grants it to nobody, without a word. + +**A logrotate stanza without `copytruncate` silently ends logging**: `log::init` opens the +file once and never reopens it, so a rotation moves the inode out from under a server that +carries on writing to a file with no name. + +**A `.spk` whose outer tar is gzipped is rejected** with "invalid file format" and no +further detail. So is one carrying macOS `._` members. `check-spk.sh` asserts both. + +## The DSM desktop application + +Seven things, measured on a DSM 7.2.2 virtual machine and on a DS416j running 7.1.1, and +none of them in the developer guide. + +**A CGI under `/webman/3rdparty//` runs as the owner of the script.** Not as `http`, +and not as root — as whoever owns the file. DSM chowns a package's tree to the package +user, so the application's backend runs as `rescriptum` and can read the `0600` env file it +owns, which is the entire reason the configuration can be edited while the server is +stopped. Proven by chowning the same script two ways and watching `id` change. A script +left owned by root **does** run as root there, so do not leave one lying about. + +**That path is not authenticated by DSM.** An unauthenticated request reaches the script +and is answered `200`. Whatever guards a package's CGI, the package wrote it — here that is +`authenticate.cgi` plus an `administrators` check, and losing either would be silent. + +**`su` in a CGI hangs the request.** Without `-armv7.spk` pour le DS416j et +les autres machines `armada38x`, `-x86_64.spk` pour tous les modèles Intel — et installez-le +par **Package Center → Installation manuelle**. Il crée le dossier partagé, enregistre le +port auprès du pare-feu, lie le CLI dans le `PATH` et démarre au boot. Les détails, et ce +qu'il ne fait délibérément pas pour vous, sont sur la +[page Synology](./operations/synology.md). + +Les builds Linux sont liés à musl statiquement — sauf `armv7`, qui vise la glibc 2.17 +parce que musl 1.2 ne peut pas tourner sur les noyaux 3.10 de Synology (voir la +[page de build](../development/building.md#pourquoi-armv7-est-la-seule-cible-qui-ne-soit-pas-musl)) : ```console $ file /usr/local/bin/rescriptum @@ -58,9 +68,9 @@ $ ./build.sh La compilation croisée pour le NAS demande [`cargo-zigbuild`](https://github.com/rust-cross/cargo-zigbuild) et Zig, qui remplacent une toolchain croisée complète. La [page de build](../development/building.md) donne les -détails, y compris comment confirmer que le résultat est bien statique — un binaire musl -lié dynamiquement échoue au moment de l'exec, sur le NAS, et non au build sur votre -portable. +détails, y compris ce qu'il faut vérifier selon la cible : que les builds musl sont bien +statiques, et que le build armv7 ne réclame pas une glibc plus récente que celle du NAS — +les deux échouent au moment de l'exec, sur la machine, et non au build sur votre portable. ## Le lancer diff --git a/docs/guide/install.md b/docs/guide/install.md index 3eaa2f4..c60daaf 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -7,7 +7,7 @@ sidebar: # Install -rescriptum is **one statically linked binary**. There is no runtime to install, no +rescriptum is **one self-contained binary**. There is no runtime to install, no interpreter, no container image, and nothing written outside the directory you point it at. Copy it somewhere and run it. @@ -18,7 +18,7 @@ Binaries for every published target are attached to each | Target | For | |---|---| -| `armv7-unknown-linux-musleabihf` | Synology DS416j and other ARMv7 NAS boxes | +| `armv7-unknown-linux-gnueabihf` | Synology DS416j and other ARMv7 NAS boxes (glibc ≥ 2.17) | | `aarch64-unknown-linux-musl` | modern ARM NAS, Raspberry Pi | | `x86_64-unknown-linux-musl` | most other Linux hosts | | `aarch64-apple-darwin` | local development, Apple silicon | @@ -36,6 +36,15 @@ $ sudo install -m755 rescriptum-$VERSION-$TARGET/rescriptum /usr/local/bin/ Check the sum. This binary runs as root on hardware you are about to install, which is about as much trust as a program gets. +## On a Synology + +Take the `.spk` for your model instead — `rescriptum--armv7.spk` for the DS416j +and other `armada38x` machines, `-x86_64.spk` for every Intel model — and install it with +**Package Center → Manual Install**. It creates the shared folder, registers the port with +the firewall, links the CLI onto `PATH` and starts at boot. Details, and what it +deliberately does not do for you, are on the +[Synology page](./operations/synology.md). + The Linux builds are linked against musl, statically, so they do not care how old the host's glibc is: diff --git a/docs/guide/operations/deployment.fr.md b/docs/guide/operations/deployment.fr.md index e3558e5..983468e 100644 --- a/docs/guide/operations/deployment.fr.md +++ b/docs/guide/operations/deployment.fr.md @@ -91,7 +91,7 @@ EXPOSE 8000 ENTRYPOINT ["/rescriptum"] ``` -Utilisez le build musl de la bonne architecture — il est lié statiquement, ce qui est ce qui +Utilisez le build de la bonne architecture — les builds musl sont liés statiquement, ce qui est ce qui fait fonctionner `FROM scratch`. Montez le répertoire de réponses en lecture seule. ## Le dimensionner @@ -122,7 +122,7 @@ $ ./deploy.sh admin@nas /volume1/netboot # un autre répertoire distant Ce qu'il fait, dans l'ordre : -1. **Construit** pour la cible (`TARGET`, par défaut `armv7-unknown-linux-musleabihf`). +1. **Construit** pour la cible (`TARGET`, par défaut `armv7-unknown-linux-gnueabihf`). 2. **Vérifie les réponses locales** avec `rescriptum check` et refuse de continuer si quoi que ce soit échoue — expédier un jeu de réponses cassé est pire que ne pas déployer. 3. **Copie le binaire sous un nom temporaire**, puis le renomme en place. Remplacer un @@ -135,7 +135,7 @@ Ce qu'il fait, dans l'ordre : | Environnement | Défaut | |---|---| -| `TARGET` | `armv7-unknown-linux-musleabihf` | +| `TARGET` | `armv7-unknown-linux-gnueabihf` | | `ANSWERS` | `/answers` | | `PORT` | `8000` | diff --git a/docs/guide/operations/deployment.md b/docs/guide/operations/deployment.md index c593047..58aefda 100644 --- a/docs/guide/operations/deployment.md +++ b/docs/guide/operations/deployment.md @@ -91,7 +91,7 @@ EXPOSE 8000 ENTRYPOINT ["/rescriptum"] ``` -Use the musl build for the right architecture — it is statically linked, which is what +Use the build for the right architecture — the musl ones are statically linked, which is what makes `FROM scratch` work. Mount the answers directory read-only. ## Sizing it @@ -121,7 +121,7 @@ $ ./deploy.sh admin@nas /volume1/netboot # a different remote directory What it does, in order: -1. **Builds** for the target (`TARGET`, default `armv7-unknown-linux-musleabihf`). +1. **Builds** for the target (`TARGET`, default `armv7-unknown-linux-gnueabihf`). 2. **Checks the local answers** with `rescriptum check` and refuses to continue if anything fails — shipping a broken answer set is worse than not deploying. 3. **Copies the binary under a temporary name**, then renames it into place. Replacing a @@ -132,7 +132,7 @@ What it does, in order: | Environment | Default | |---|---| -| `TARGET` | `armv7-unknown-linux-musleabihf` | +| `TARGET` | `armv7-unknown-linux-gnueabihf` | | `ANSWERS` | `/answers` | | `PORT` | `8000` | diff --git a/docs/guide/operations/index.fr.md b/docs/guide/operations/index.fr.md index 8bbc5f8..b2c057d 100644 --- a/docs/guide/operations/index.fr.md +++ b/docs/guide/operations/index.fr.md @@ -14,8 +14,8 @@ dehors du store que vous lui indiquez. Bien l'exploiter consiste surtout à déc le droit de servir, et à qui. - **[Déploiement](./deployment.md)** — une unité systemd, un conteneur, ou rien du tout. -- **[Synology DSM 7](./synology.md)** — la cible d'origine : pas de systemd, donc l'autostart - est une entrée du planificateur de tâches. +- **[Synology DSM 7](./synology.md)** — la cible d'origine : une installation par Package + Center qui crée le partage, enregistre le port et démarre au boot. - **[Sécurité](./security.md)** — les deux jetons, pourquoi ils se comportent différemment, et ce qu'aucun des deux ne protège. - **[Capturer les requêtes](./capture.md)** — enregistrer ce que les machines envoient diff --git a/docs/guide/operations/index.md b/docs/guide/operations/index.md index 539448a..f0e378b 100644 --- a/docs/guide/operations/index.md +++ b/docs/guide/operations/index.md @@ -14,8 +14,8 @@ nothing outside the store you point it at. Running it well is mostly about decid it is allowed to serve and to whom. - **[Deployment](./deployment.md)** — a systemd unit, a container, or nothing at all. -- **[Synology DSM 7](./synology.md)** — the original target: no systemd, so autostart is - a Task Scheduler entry. +- **[Synology DSM 7](./synology.md)** — the original target: a Package Center install + that creates the share, registers the port and starts at boot. - **[Security](./security.md)** — the two tokens, why they behave differently, and what neither of them protects. - **[Capturing requests](./capture.md)** — record what machines actually send, and replay diff --git a/docs/guide/operations/security.fr.md b/docs/guide/operations/security.fr.md index b81496e..71b0418 100644 --- a/docs/guide/operations/security.fr.md +++ b/docs/guide/operations/security.fr.md @@ -122,6 +122,50 @@ L'API d'administration est le seul endroit où cela compte par défaut : elle pa en clair, donc le jeton traverse le réseau en clair. Sur la boucle locale c'est sans objet. Ailleurs, mettez un proxy terminant TLS devant. +## L'application de bureau + +Sur Synology uniquement, et uniquement là — l'[application DSM](./synology.md#lapplication-de-bureau) +fait partie du paquet, pas du serveur. Son backend est un CGI que DSM sert depuis +`/webman/3rdparty/rescriptum/`, et deux choses concernant ce chemin décident de tout son +modèle de sécurité. **Les deux ont été mesurées sur une machine DSM 7.2.2 plutôt que lues +dans un guide, qui n'en mentionne aucune :** + +1. **Un CGI y tourne sous le propriétaire du script.** DSM attribue l'arborescence d'un + paquet à l'utilisateur du paquet : le backend tourne donc en tant que `rescriptum`, la + même identité qui possède le fichier d'environnement en `0600` et le journal. C'est ce + qui lui permet d'éditer la configuration et de lire le journal *pendant que le serveur + est arrêté*, c'est-à-dire précisément quand un panneau de réglages sert à quelque chose. + Ce n'est pas root, et il ne peut devenir personne : il n'a aucun droit de démarrer ou + d'arrêter le paquet, et c'est pourquoi le redémarrage passe par l'API de DSM avec la + session de l'administrateur. (Un script resté possédé par root, lui, **tourne bien** en + root là-bas. Bon à savoir, et à ne jamais faire.) +2. **DSM n'authentifie pas ce chemin.** Une requête non authentifiée atteint le script et + reçoit une réponse. DSM protège ses propres pages ; celles d'un paquet regardent le + paquet. + +Mis ensemble : les contrôles à l'intérieur du script sont la seule chose devant lui. Il en +fait donc trois, dans cet ordre, avant de toucher à quoi que ce soit. + +- **Une session DSM.** Il exécute l'`authenticate.cgi` de DSM, qui affiche le nom de + l'utilisateur connecté et n'affiche rien du tout s'il n'y a pas de session. +- **Un administrateur.** Être connecté ne suffit pas ; l'utilisateur doit appartenir à + `administrators`. Moins que cela laisserait n'importe quel compte du NAS fixer le mot de + passe root de chaque machine qu'il installe. +- **L'intention, pour une écriture.** Une écriture doit porter un en-tête que l'application + envoie et qu'un formulaire d'un autre site ne peut pas : un navigateur n'envoie pas un + en-tête inventé en cross-origin sans un préalable (*preflight*), et ce script n'y répond + pas. Le `SynoToken` de DSM est envoyé en plus, ce qui garde l'application fonctionnelle + avec la protection contre la falsification de requête inter-sites activée. + +`check-spk.sh` vérifie que les deux premiers sont toujours dans le script, et +`lifecycle-test.sh` le pilote avec un authentificateur bouchonné pour prouver que les trois +refusent réellement. Ils ont été vus échouer : retirer le contrôle de session fait passer +quatre verts au rouge. + +L'application ne reçoit jamais de jeton. `RESCRIPTUM_ANSWER_TOKEN` et +`RESCRIPTUM_ADMIN_TOKEN` lui parviennent comme *défini* ou *non défini*, et rien de plus — +la commande qu'elle appelle refuse d'afficher un identifiant, quoi qu'on lui demande. + ## Connu et accepté - **La limitation par adresse n'arrête pas un attaquant disposant de nombreuses adresses.** diff --git a/docs/guide/operations/security.md b/docs/guide/operations/security.md index 4d65dc3..4f85b9a 100644 --- a/docs/guide/operations/security.md +++ b/docs/guide/operations/security.md @@ -119,6 +119,47 @@ The admin API is the one place where this matters by default: it speaks plain HT the token crosses the network in the clear. On loopback that is moot. Anywhere else, put a TLS-terminating proxy in front. +## The desktop application + +Only on Synology, and only there — the [DSM application](./synology.md#the-desktop-application) +is part of the package, not of the server. Its backend is a CGI that DSM serves from +`/webman/3rdparty/rescriptum/`, and two things about that path decide its whole security +model. **Both were measured on a DSM 7.2.2 machine rather than read in a guide, which does +not mention either:** + +1. **A CGI there runs as the owner of the script.** DSM chowns a package's files to the + package user, so the backend runs as `rescriptum` — the same identity that owns the + `0600` env file and the log. That is what lets the application edit the configuration + and read the log *while the server itself is stopped*, which is exactly when a settings + panel earns its place. It is not root, and it cannot become anybody: it has no + privilege to start or stop the package, which is why restarting goes through DSM's own + API with the administrator's session instead. (A script left owned by root **does** run + as root there. Worth knowing, and worth never doing.) +2. **DSM does not authenticate that path.** An unauthenticated request reaches the script + and is answered. DSM protects its own pages; a package's are the package's problem. + +Put together: the checks inside the script are the only thing in front of it, so it makes +three, in this order, before it touches anything. + +- **A DSM session.** It runs DSM's own `authenticate.cgi`, which prints the signed-in + user's name and prints nothing at all when there is no session. +- **An administrator.** Being signed in is not enough; the user must be in + `administrators`. Anything less would let any account on the NAS set the root password of + every machine it installs. +- **Intent, for a write.** A write must carry a header the application sends and a form on + another site cannot: a browser will not send an invented header cross-origin without a + preflight first, and this script answers no preflight. DSM's own `SynoToken` is sent + along too, which is what keeps the application working with DSM's cross-site request + forgery protection switched on. + +`check-spk.sh` asserts that the first two are still in the script, and `lifecycle-test.sh` +drives the script with a stubbed authenticator to prove all three actually refuse. They +were watched failing: removing the session check turns four green into four red. + +The application never receives a token. `RESCRIPTUM_ANSWER_TOKEN` and +`RESCRIPTUM_ADMIN_TOKEN` reach it as *set* or *not set* and nothing more — the command it +calls will not print a credential, whatever it is asked. + ## Known and accepted - **Per-address rate limiting does not stop an attacker with many addresses.** The admin diff --git a/docs/guide/operations/synology.fr.md b/docs/guide/operations/synology.fr.md index 87f722c..af4ca57 100644 --- a/docs/guide/operations/synology.fr.md +++ b/docs/guide/operations/synology.fr.md @@ -1,6 +1,6 @@ --- title: Synology DSM 7 -description: La cible d'origine — un DS416j ARMv7 avec 512 Mo et pas de Docker. Autostart, pare-feu, et remplacer une instance en cours. +description: La cible d'origine — un DS416j ARMv7 avec 512 Mo et pas de Docker. Une installation par Package Center, ce qu'elle fait et ne fait pas pour vous, et la route manuelle si vous la préférez. sidebar: label: Synology DSM 7 order: 2 @@ -12,17 +12,282 @@ Un Synology DS416j est la raison d'être de ce projet : ARMv7, 512 Mo de RAM, DS Docker. Un binaire statique sans runtime n'y est pas une préférence esthétique — c'est la seule chose qui rentre. -DSM ne vous donne pas de systemd, donc l'autostart passe par le planificateur de tâches. +DSM 7 fait tourner systemd, mais il n'offre aucun endroit supporté pour une unité à vous : +les fichiers de `/usr/lib/systemd/system` appartiennent à Synology, et une mise à jour de +DSM est libre de les remplacer. La route supportée vers un service, c'est un **paquet** — +installez-en un et DSM génère `pkgctl-rescriptum.service` à partir de lui. C'est par là que +cette page commence ; la [route par le planificateur de tâches](#sans-le-paquet) fonctionne +toujours et reste en bas. -## 1. Mettre le binaire dessus +## Installer le paquet -Utilisez le build **`armv7-unknown-linux-musleabihf`** de la +Téléchargez le `.spk` de votre modèle depuis la +[page des releases](https://github.com/z29k/rescriptum/releases) : + +| Fichier | Pour | +|---|---| +| `rescriptum--armv7.spk` | DS416j et les autres modèles Marvell `armada38x` | +| `rescriptum--x86_64.spk` | tous les modèles Intel | + +Un doute ? Demandez à la machine : + +```console +$ ssh admin@nas synogetkeyvalue /etc.defaults/synoinfo.conf unique +synology_armada38x_ds416j +``` + +Puis **Package Center → Installation manuelle**, choisissez le fichier, et passez +l'avertissement disant que le paquet n'est pas vérifié par Synology. Cet avertissement ne +vise pas ce paquet en particulier : DSM 7 a supprimé la signature tierce et n'offre plus de +réglage de niveau de confiance, donc tout paquet non-Synology l'affiche. Notre vérification +à nous, c'est la somme SHA-256 publiée à côté du `.spk` : + +```console +$ shasum -a 256 -c rescriptum-0.1.0-1-armv7.spk.sha256 +``` + +L'assistant pose deux questions — **où vivent les réponses** et **sur quel port écouter** — +puis le paquet : + +- crée un **dossier partagé `rescriptum`** et s'accorde un accès lecture/écriture dessus (si + vous en avez déjà un de ce nom, il est conservé et gagne simplement le droit) ; +- crée le répertoire `answers` dedans à chaque démarrage ; +- **enregistre le port** auprès du pare-feu DSM, pour que le service soit sélectionnable par + son nom ; +- lie **`rescriptum-cli`** dans `/usr/local/bin` ; +- démarre au boot, et s'arrête et redémarre depuis Package Center comme n'importe quoi + d'autre. + +## Ce que le paquet ne fait pas + +Quatre choses à savoir avant qu'elles ne vous surprennent. + +- **Il n'ouvre pas le pare-feu.** Enregistrer le port fait apparaître *rescriptum* par son + nom dans l'éditeur de règles au lieu d'un numéro à taper. Si votre pare-feu est actif avec + une règle par défaut qui refuse, il faut toujours créer la règle. +- **Il ne vous annonce pas les mises à jour.** Il n'y a pas de source de paquets à + interroger — le modèle de distribution est : téléchargez le nouveau `.spk` depuis la page + des releases et installez-le à la main, pour une mise à jour comme pour une première + installation. Surveillez les releases. +- **Un chemin de réponses personnalisé, les permissions sont à vous.** Le paquet tourne sans + privilèges et ne peut pas s'accorder l'accès à un dossier que vous nommez ; si vous + pointez hors du partage `rescriptum`, donnez vous-même l'accès en lecture à l'utilisateur + `rescriptum`. +- **Le droit sur le partage est réappliqué à chaque démarrage.** Si vous le restreignez + délibérément, vous le retrouverez rétabli au démarrage suivant du paquet. + +## Où vit quoi + +| Quoi | Où | Survit à une mise à jour | Survit à la désinstallation | +|---|---|---|---| +| binaire, `rescriptum-cli`, le fichier d'environnement d'exemple | `/var/packages/rescriptum/target/` | non — remplacé | non | +| **le fichier d'environnement** | `/var/packages/rescriptum/etc/rescriptum.env` | **oui** | **oui** — voir ci-dessous | +| journal, pidfile, captures | `/var/packages/rescriptum/var/` | oui | **oui** | +| **les réponses** | `/var/packages/rescriptum/shares/rescriptum/answers/` | **oui** | **oui — toujours** | +| **la base SQLite**, si vous en utilisez une | à côté des réponses, dans le même partage | **oui** | **oui — toujours** | + +Utilisez le chemin `shares/` plutôt que `/volume1/…` : c'est un lien symbolique maintenu par +DSM, donc il continue de marcher sur un NAS dont les données ne sont pas sur le volume 1. + +La désinstallation laisse le dossier partagé et tout ce qu'il contient tranquilles. C'est à +la fois le comportement de DSM et le nôtre : quand le magasin est SQLite, la base *est* vos +réponses. + +**Elle laisse aussi votre configuration derrière elle, et ça vaut d'être su.** `etc/` et +`var/` sont des liens vers `/volume1/@appconf/rescriptum` et `/volume1/@appdata/rescriptum`, +que DSM conserve — le fichier d'environnement reste donc sur le volume après la disparition +du paquet, **avec les jetons qu'il contient**. Une réinstallation le reprend, ce qui est +généralement ce qu'on veut. Si vous retirez rescriptum pour de bon et qu'il portait un +jeton, supprimez `/volume1/@appconf/rescriptum` vous-même. + +## L'application de bureau + +Le paquet installe une application sur le bureau DSM — l'icône est dans le menu principal, +et le bouton **Ouvrir** de Package Center y mène. C'est une vraie application DSM, bâtie sur +le framework d'interface du bureau : elle est dans le thème DSM et dans la langue de DSM. +Le français d'un DSM en français est aussi celui de l'application. + +Elle a trois onglets : + +- **Réglages** — chaque variable de configuration, sous forme de formulaire. Chaque champ + dit d'où vient sa valeur, et une valeur définie dans l'*environnement* est affichée mais + verrouillée, parce que modifier le fichier n'y changerait rien. Enregistrer écrit le + fichier et propose de redémarrer le paquet, le serveur ne lisant sa configuration qu'une + fois, au démarrage. +- **État** — la version, si le paquet tourne, le dossier des réponses et s'il est vraiment + lisible *par l'utilisateur du service*, et la sortie de `check`. +- **Journal** — les dernières lignes du journal des requêtes et de `startup.log`. + +Trois propriétés valent mieux d'être sues que découvertes : + +- **Elle édite le fichier, pas le serveur qui tourne.** Elle fonctionne donc encore quand le + serveur refuse de démarrer, c'est-à-dire précisément quand un panneau de réglages sert à + quelque chose. Une modification qui laisserait le serveur incapable de démarrer est + refusée avant toute écriture, avec la raison affichée. +- **Elle ne vous montre jamais un jeton.** `RESCRIPTUM_ANSWER_TOKEN` et + `RESCRIPTUM_ADMIN_TOKEN` apparaissent comme *défini* ou *non défini*, et un champ vide + veut dire « n'y touche pas », jamais « efface-le ». En saisir un nouveau le remplace. +- **Elle exige un administrateur DSM.** Être connecté à DSM ne suffit pas. Voir + [sécurité](./security.md#lapplication-de-bureau) pour pourquoi ce contrôle est toute la + porte. + +*Redémarrer maintenant* arrête et redémarre le paquet via DSM lui-même, donc **DSM ferme la +fenêtre pendant ce temps** — rouvrez-la pour voir le nouvel état. L'application le dit à +côté du bouton plutôt que de vous laisser la surprise. + +Elle demande **DSM 7.1 ou plus récent** (`os_min_ver="7.1-42661"`). Elle est bâtie sur le +framework ExtJS de DSM, présent en 7.1.1 comme en 7.2.2 — les deux mesurés. DSM 7.2 embarque +un framework Vue plus récent, et le guide actuel de Synology ne documente que celui-là ; mais +le DS416j qui justifie ce projet plafonne en 7.1.1, où `Vue` n'existe pas. ExtJS couvre donc +tous les DSM que ce paquet prend en charge plutôt que les seuls récents. Le 7.0 n'est pas +revendiqué : rien n'y a jamais tourné. + +## Le configurer + +L'application ci-dessus est la voie confortable. Tout ce qu'elle fait se fait aussi depuis +un shell, et sur une machine où le bureau n'est pas à portée c'est plus rapide : + +```console +$ sudo rescriptum-cli config +env file: /var/packages/rescriptum/etc/rescriptum.env + + RESCRIPTUM_STORE files default + RESCRIPTUM_ANSWERS_DIR /var/packages/rescriptum/shares/rescriptum/answers file + RESCRIPTUM_LISTEN_ADDR 0.0.0.0:8000 file + … + +$ sudo rescriptum-cli config set RESCRIPTUM_LOG=problems +wrote /var/packages/rescriptum/etc/rescriptum.env +``` + +`config set` conserve les commentaires du fichier, décommente un réglage au lieu de le +dupliquer, et **refuse une modification qui empêcherait le serveur de démarrer**. Son code +de sortie dit si la configuration en est une sur laquelle le serveur démarrerait, ce qui le +rend utilisable depuis un script. + +Dessous, c'est le même fichier, et l'éditer à la main reste parfaitement raisonnable : + +```console +$ sudo vi /var/packages/rescriptum/etc/rescriptum.env +``` + +`postinst` l'écrit complet à une installation neuve, avec les variables utilisées +décommentées et les autres commentées avec une ligne disant à quoi elles servent. +**Arrêtez et redémarrez le paquet depuis Package Center pour appliquer un changement** — le +serveur lit le fichier à chaque démarrage. + +Une mise à jour n'y touche jamais. L'exemple complet pour la version que vous avez est dans +`/var/packages/rescriptum/target/etc/rescriptum.env.example`, réécrit à chaque installation +et chaque mise à jour : c'est ainsi qu'une nouvelle variable devient visible sans déranger +votre fichier vivant. Toutes les variables sont dans la +[référence de configuration](../reference/configuration.md). + +Le fichier est en `chmod 600` et appartient à l'utilisateur du paquet. C'est là que vivent +`RESCRIPTUM_ANSWER_TOKEN` et `RESCRIPTUM_ADMIN_TOKEN` et — étant sous `etc/` — c'est un +passager plausible d'une sauvegarde de configuration DSM. Mieux vaut le savoir que le +découvrir. + +L'[API d'administration](./admin-api.md) est désactivée par défaut et, quand vous l'activez, +devrait rester sur la boucle locale et être atteinte par un tunnel SSH ; elle n'est +délibérément **pas** enregistrée auprès du pare-feu. Elle exige aussi +`RESCRIPTUM_STORE=sqlite` et un jeton d'au moins 16 caractères, deux **erreurs de +démarrage** — donc se tromper se manifeste par un paquet qui ne démarre pas, avec la raison +dans `/var/log/packages/rescriptum.log`. + +## Mettre les réponses en place + +Déposez les fichiers dans le répertoire `answers` du dossier partagé `rescriptum`, via File +Station ou en SSH, exactement comme ailleurs — voir +[écrire des réponses](../answers/index.md). Puis validez-les **en tant qu'utilisateur du +paquet** : + +```console +$ sudo -u rescriptum rescriptum-cli check +``` + +Le `sudo -u` compte. Lancé en root, il réussit quoi que disent les permissions du dossier +partagé, ce qui rend un succès dénué de sens. `rescriptum-cli` est l'enveloppe fournie par +le paquet : elle nomme le fichier d'environnement, pour que `check` et `render` regardent +les réponses de cette machine plutôt que `/srv/answers`. + +## Le pare-feu + +**Panneau de configuration → Sécurité → Pare-feu** — créez une règle autorisant *rescriptum* +depuis votre réseau de provisionnement. Le service apparaît par son nom parce que le paquet +a enregistré son port. + +Le pare-feu de DSM est la première raison pour laquelle une machine « ne contacte jamais le +serveur ». + +Si vous changez le port plus tard, modifiez `RESCRIPTUM_LISTEN_ADDR` dans le fichier +d'environnement puis déplacez l'entrée du pare-feu, qui ne suit pas toute seule : + +```console +$ sudo /usr/syno/sbin/synopkghelper update rescriptum port-config +``` + +## Le journal + +`RESCRIPTUM_LOG_FILE` pointe le serveur vers +`/var/packages/rescriptum/var/rescriptum.log`, et le paquet installe une strophe logrotate +pour lui — hebdomadaire, huit conservés, `copytruncate` (le serveur ouvre son journal une +fois et ne le rouvre jamais, donc tout le reste arrêterait silencieusement la +journalisation). À côté, `var/startup.log` contient ce que le serveur dit avant de savoir où +vit son journal : une erreur de configuration, un fichier d'environnement mal formé. + +Une fois qu'un déploiement devient routinier, `RESCRIPTUM_LOG=problems` garde les échecs et +laisse tomber les réponses réussies, seule chose à fort volume là-dedans. + +## Quand ça ne démarre pas + +Trois endroits disent pourquoi, dans cet ordre : + +```console +$ cat /var/log/packages/rescriptum.log # la sortie des scripts du paquet +$ cat /var/packages/rescriptum/var/startup.log # ce que le serveur a dit avant d'avoir un journal +$ cat /var/packages/rescriptum/var/rescriptum.log +$ systemctl status pkgctl-rescriptum # ce qu'a vu le gestionnaire de services +``` + +Une **configuration refusée** — un jeton d'administration de moins de 16 caractères, un +magasin impossible à ouvrir — est signalée *après* que le serveur sait où vit son journal : +elle atterrit donc dans `rescriptum.log` ; un fichier d'environnement mal formé est signalé +avant, et atterrit dans `startup.log`. Le `start` du paquet affiche la fin des deux quand le +serveur sort immédiatement, pour que Package Center vous montre la raison et pas seulement +l'échec. + +**DSM ne relance pas le processus s'il meurt.** L'unité qu'il génère est `Type=oneshot` avec +`RemainAfterExit=yes` et sans `Restart=` : un serveur qui sort reste arrêté jusqu'à ce que +vous le redémarriez depuis Package Center. Ce n'est pas une régression — la route par le +planificateur ne le relançait pas non plus — mais mieux vaut le savoir avant de compter +dessus. + +Un paquet qui s'installe, démarre, puis répond `404` à tout, c'est presque toujours le +répertoire des réponses : vérifiez avec `sudo -u rescriptum rescriptum-cli check`. Sur un +NAS avec un dossier partagé chiffré, c'est aussi à cela que ressemble un démarrage avant que +le volume soit déverrouillé — déverrouillez-le et redémarrez le paquet. + +## Vérifier + +```console +$ curl http://IP_DU_NAS:8000/health +OK +``` + +## Sans le paquet + +La route manuelle fonctionne toujours, et c'est le choix honnête si vous préférez ne rien +installer du tout. + +Utilisez le build **`armv7-unknown-linux-gnueabihf`** (ou `x86_64-unknown-linux-musl`, ou +`aarch64-unknown-linux-musl` pour un modèle ARM plus récent) de la [page des releases](https://github.com/z29k/rescriptum/releases), ou compilez-en un vous-même (voir [construire](../../development/building.md)). ```console $ scp rescriptum admin@nas:/volume1/netboot/rescriptum $ ssh admin@nas chmod +x /volume1/netboot/rescriptum +$ ssh admin@nas mkdir -p /volume1/netboot/answers ``` Si ARMv7 se comporte mal, confirmez la vraie architecture avant de supposer : @@ -32,30 +297,22 @@ $ ssh admin@nas uname -m armv7l ``` -Un DS918+ ou tout modèle x86 veut `x86_64-unknown-linux-musl` ; un DS220j et les modèles ARM -plus récents veulent `aarch64-unknown-linux-musl`. - -Le build doit être **lié statiquement** — la glibc de DSM est assez ancienne pour qu'un -binaire lié dynamiquement échoue au moment de l'exec, sur le NAS, avec une erreur qui ne le -dit pas franchement : +**Prenez le build ARMv7 publié, pas un build musl que vous auriez fait vous-même.** Le +binaire `armv7` publié est lié à la glibc 2.17, que DSM possède ; un build musl du même code +s'installe, répond à `--version`, puis meurt dès qu'il veut l'heure. Les noyaux 3.10 de +Synology répondent `EINVAL` aux appels *time64* là où musl 1.2 n'attend qu'`ENOSYS` pour se +replier — la [page de build](../../development/building.md#pourquoi-armv7-est-la-seule-cible-qui-ne-soit-pas-musl) +porte la mesure. Les builds x86_64 et aarch64 sont en musl statique et ne sont pas +concernés. ```console $ file rescriptum -ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, stripped -``` - -## 2. Mettre vos réponses à côté - -```console -$ ssh admin@nas mkdir -p /volume1/netboot/answers +ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), dynamically linked, ... ``` -`/volume1/netboot/` est l'emplacement d'un dossier partagé DSM, donc le foyer naturel du -binaire comme des réponses. Ce n'est pas une valeur par défaut : `RESCRIPTUM_ANSWERS_DIR` -vaut `/srv/answers`, qui n'existe pas sur DSM, il faut donc la définir explicitement ici. Le -fichier d'environnement ci-dessous est l'endroit le plus propre pour le faire. - -## 3. Autostart +`RESCRIPTUM_ANSWERS_DIR` vaut `/srv/answers` par défaut, qui n'existe pas sur DSM : il faut +donc la définir explicitement. Le fichier d'environnement ci-dessous est l'endroit le plus +propre pour le faire. **Panneau de configuration → Planificateur de tâches → Créer → Tâche déclenchée → Script défini par l'utilisateur** @@ -66,10 +323,6 @@ défini par l'utilisateur** | Utilisateur | `root` | | Commande | voir ci-dessous | -```sh -RESCRIPTUM_ANSWERS_DIR=/volume1/netboot/answers /volume1/netboot/rescriptum -``` - Si vous utilisez un jeton, **ne le mettez pas dans cette case.** Tout ce qui se trouve dans les arguments d'un processus — et, dans le cas de DSM, dans la définition de la tâche — est lisible par tous les utilisateurs de la machine via `ps`. Mettez la configuration dans un @@ -78,6 +331,7 @@ fichier réservé à root et nommez-le : ```sh # /volume1/netboot/rescriptum.env (chmod 600, appartenant à root) RESCRIPTUM_ANSWERS_DIR=/volume1/netboot/answers +RESCRIPTUM_LOG_FILE=/volume1/netboot/rescriptum.log RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/volume1/netboot/answers.db RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001 @@ -86,82 +340,59 @@ RESCRIPTUM_ANSWER_TOKEN=… ``` ```sh -# l'entrée du planificateur lance ceci +# l'entrée du planificateur de tâches exécute ceci RESCRIPTUM_ENV_FILE=/volume1/netboot/rescriptum.env exec /volume1/netboot/rescriptum ``` -**Préférez ceci au sourcing.** L'ancienne forme — +**Préférez ceci au sourcing.** La forme plus ancienne — `. /volume1/netboot/rescriptum.env && exec …` — fonctionne, et fonctionne toujours, mais -elle échoue *en silence* : que le `.` initial saute, qu'une ligne comporte une faute de -frappe, ou que les permissions soient mauvaises, et le shell ne source rien pendant que le -serveur démarre sur ses **valeurs par défaut** — répertoire de réponses par défaut, pas de -jeton admin, et pas un mot dans le log. Avec `RESCRIPTUM_ENV_FILE`, le binaire lit le -fichier lui-même et **refuse de démarrer** s'il n'y arrive pas. Il avertit aussi si le -fichier est lisible par quelqu'un d'autre que root, et nomme toute clé qu'il ne reconnaît -pas — un `RESCRIPTUM_ADMIN_TOKENN` est donc attrapé au lieu d'être ignoré discrètement. - -Le détail du format est dans la +elle échoue *silencieusement* : oubliez le `.` du début, tapez une ligne de travers, ou +ratez les permissions, et le shell ne source rien pendant que le serveur démarre sur ses +**valeurs par défaut** — le répertoire de réponses par défaut, aucun jeton d'administration, +et pas un mot dans le journal. Avec `RESCRIPTUM_ENV_FILE`, le binaire lit le fichier +lui-même et **refuse de démarrer** s'il ne peut pas. Il prévient aussi si le fichier est +lisible par quelqu'un d'autre que root, et nomme toute clé qu'il ne reconnaît pas, de sorte +qu'un `RESCRIPTUM_ADMIN_TOKENN` est attrapé plutôt qu'ignoré en silence. + +Les détails du format sont dans la [référence de configuration](../reference/configuration.md#le-fichier-denvironnement). -Lancez la tâche une fois à la main depuis le planificateur plutôt que d'attendre un -redémarrage pour découvrir qu'elle ne marche pas. - -## 4. Ouvrir le port - -**Panneau de configuration → Sécurité → Pare-feu** — autorisez le TCP 8000 (ou ce que vous -avez mis dans `RESCRIPTUM_LISTEN_ADDR`) depuis votre réseau de provisioning. - -Le pare-feu de DSM est de loin la raison la plus fréquente pour laquelle une machine -« ne contacte jamais le serveur ». - -## 5. Vérifier - -```console -$ curl http://NAS_IP:8000/health -OK -``` - -## Où va le log - -Nulle part, par défaut : le planificateur de DSM jette la sortie d'une tâche. Nommez un -fichier dans le fichier d'environnement et le serveur y écrit lui-même, sans redirection -shell à rater : - -```sh -RESCRIPTUM_LOG_FILE=/volume1/netboot/rescriptum.log -``` - -La ligne de log est tout le diagnostic disponible quand une installation PXE ne démarre pas, -ce n'est donc pas optionnel. Une fois le déploiement devenu routinier, -`RESCRIPTUM_LOG=problems` garde les échecs et jette les réponses réussies, la seule chose -volumineuse là-dedans. Faites tourner le fichier vous-même ; le serveur ne le fait pas. +Lancez la tâche une fois à la main depuis le planificateur plutôt que d'attendre un reboot +pour découvrir qu'elle ne marche pas. Puis ouvrez le port dans le pare-feu par son numéro, +et faites tourner le journal vous-même — le serveur ne le fait pas, et rien d'autre non +plus. -## Remplacer une instance en cours +### Remplacer une instance en cours ```console $ ./deploy.sh admin@nas ``` -Il construit pour ARMv7, [vérifie les réponses d'abord](../answers/validating.md), copie le -binaire sous un nom temporaire pour qu'un fichier à moitié copié ne soit jamais exécuté, +Il compile pour ARMv7, [vérifie les réponses d'abord](../answers/validating.md), copie le +binaire sous un nom temporaire pour qu'un fichier à moitié copié ne soit jamais exécuté, le redémarre, et confirme que `/health` répond. Détails dans [déploiement](./deployment.md#remplacer-une-instance-en-cours). -L'entrée du planificateur reste ce qui le démarre après un redémarrage — `deploy.sh` ne -remplace que ce qui tourne maintenant. +L'entrée du planificateur reste ce qui le démarre après un reboot — `deploy.sh` ne remplace +que ce qui tourne maintenant. Sur une installation par paquet, passez par Package Center. ## Arrêt -Le planificateur de DSM envoie `SIGTERM` à l'extinction, ce que le serveur gère : il arrête -d'accepter et sort. Il n'y a de toute façon aucun état à perdre. +Les deux routes envoient `SIGTERM`, que le serveur gère : il arrête d'accepter et sort. Il +n'y a rien à perdre dans un cas comme dans l'autre. -## À quoi s'attendre d'un DS416j +## Ce qu'on peut attendre d'un DS416j -512 Mo et un cœur ARMv7, ce n'est pas beaucoup, et ce n'est pas nécessaire. Une connexion -coûte des kilo-octets plutôt qu'un thread, le listing du répertoire est mis en cache et -invalidé par mtime plutôt que parcouru à chaque requête, et un groupe sans surcharge machine -est rendu une fois au chargement puis servi comme chaîne préparée. +512 Mo et un cœur ARMv7, ce n'est pas grand-chose, et il n'y a pas besoin que ça le soit. +Mesuré sur un DS416j faisant tourner le paquet, à travers le réseau local : **3 à 4 ms pour +composer et servir une réponse**, aller-retour réseau compris, pour une machine revendiquée +par un groupe et fusionnée avec son propre fichier. Une connexion coûte des kilooctets +plutôt qu'un thread, le listing du répertoire est mis en +cache et invalidé par la mtime plutôt que parcouru à chaque requête, et un groupe sans +surcharge par machine est rendu une fois au chargement puis servi comme une chaîne +préparée. -La seule chose à savoir : le travail sur le système de fichiers se fait sur un pool de +La chose qui vaut d'être sue : le travail sur le système de fichiers se fait sur un pool de threads bloquants, parce que `read_dir` sur un NAS dont le disque dort n'est pas un appel -rapide, et bloquer un worker asynchrone bloquerait toutes les autres connexions qu'il pilote. +rapide, et bloquer un worker asynchrone bloquerait toutes les autres connexions qu'il +pilote. diff --git a/docs/guide/operations/synology.md b/docs/guide/operations/synology.md index dc86c58..54a0ea4 100644 --- a/docs/guide/operations/synology.md +++ b/docs/guide/operations/synology.md @@ -1,6 +1,6 @@ --- title: Synology DSM 7 -description: The original target — an ARMv7 DS416j with 512 MB and no Docker. Autostart, firewall, and replacing a running instance. +description: The original target — an ARMv7 DS416j with 512 MB and no Docker. A Package Center install, what it does and does not do for you, and the manual route if you prefer it. sidebar: label: Synology DSM 7 order: 2 @@ -12,50 +12,286 @@ A Synology DS416j is why this project exists: ARMv7, 512 MB of RAM, DSM 7, no Do static binary with no runtime is not an aesthetic preference there — it is the only thing that fits. -DSM gives you no systemd, so autostart goes through the Task Scheduler. +DSM 7 does run systemd, but it offers no supported place for a unit of your own: files in +`/usr/lib/systemd/system` are Synology's, and a DSM update is free to replace them. The +supported route to a service is a **package** — install one and DSM generates +`pkgctl-rescriptum.service` from it. That is what this page leads with; the older +[Task Scheduler route](#without-the-package) still works and is kept at the bottom. -## 1. Get the binary onto it +## Install the package -Use the **`armv7-unknown-linux-musleabihf`** build from the -[releases page](https://github.com/z29k/rescriptum/releases), or cross-compile one -yourself (see [building](../../development/building.md)). +Download the `.spk` for your model from the +[releases page](https://github.com/z29k/rescriptum/releases): + +| File | For | +|---|---| +| `rescriptum--armv7.spk` | DS416j and other Marvell `armada38x` models | +| `rescriptum--x86_64.spk` | every Intel model | + +Not sure which? Ask the machine: ```console -$ scp rescriptum admin@nas:/volume1/netboot/rescriptum -$ ssh admin@nas chmod +x /volume1/netboot/rescriptum +$ ssh admin@nas synogetkeyvalue /etc.defaults/synoinfo.conf unique +synology_armada38x_ds416j ``` -If ARMv7 misbehaves, confirm the real architecture before assuming: +Then **Package Center → Manual Install**, pick the file, and click through the warning that +the package is not verified by Synology. That warning is not about this package in +particular: DSM 7 removed third-party signing altogether and no longer offers a trust-level +setting, so every non-Synology package shows it. Our verification is the SHA-256 sum +published beside the `.spk`: ```console -$ ssh admin@nas uname -m -armv7l +$ shasum -a 256 -c rescriptum-0.1.0-1-armv7.spk.sha256 +``` + +The wizard asks two things — **where the answers live** and **which port to listen on** — +and then the package: + +- creates a **`rescriptum` shared folder** and grants itself read/write access to it (if + you already have one by that name, it is kept and simply gains the grant); +- creates the `answers` directory inside it at every start; +- **registers the port** with the DSM firewall, so the service is selectable by name; +- links **`rescriptum-cli`** into `/usr/local/bin`; +- starts at boot, and stops and starts from Package Center like anything else. + +## What the package does not do + +Four things worth knowing before they surprise you. + +- **It does not open the firewall.** Registering the port makes *rescriptum* appear by name + in the rule editor instead of you typing a number. If your firewall is on with a + default-deny rule, you still have to create the rule. +- **It does not tell you about updates.** There is no package source to poll — the + distribution model is: download the new `.spk` from the releases page and install it by + hand, for an upgrade as much as for a first install. Watch the releases. +- **A custom answers path is yours to permission.** The package runs unprivileged and + cannot grant itself access to a folder you name; if you point it outside the `rescriptum` + share, give the `rescriptum` user read access yourself. +- **The share's permission is reapplied at every start.** If you deliberately narrow it, + you will find it restored the next time the package starts. + +## Where everything lives + +| What | Where | Survives an upgrade | Survives uninstall | +|---|---|---|---| +| binary, `rescriptum-cli`, the example env file | `/var/packages/rescriptum/target/` | no — replaced | no | +| **the env file** | `/var/packages/rescriptum/etc/rescriptum.env` | **yes** | **yes** — see below | +| log, pidfile, captures | `/var/packages/rescriptum/var/` | yes | **yes** | +| **answers** | `/var/packages/rescriptum/shares/rescriptum/answers/` | **yes** | **yes — always** | +| **the SQLite database**, if you use one | beside the answers, in the same share | **yes** | **yes — always** | + +Use the `shares/` path rather than `/volume1/…`: it is a symlink DSM maintains, so it keeps +working on a NAS whose data is not on volume 1. + +Uninstalling leaves the shared folder and everything in it alone. That is both DSM's own +behaviour and ours: when the store is SQLite, the database *is* your answers. + +**Uninstalling also leaves your configuration behind, and that is worth knowing.** +`etc/` and `var/` are symlinks into `/volume1/@appconf/rescriptum` and +`/volume1/@appdata/rescriptum`, which DSM keeps — so the env file stays on the volume after +the package is gone, **with whatever tokens are in it**. Reinstalling picks it back up, +which is usually what you want. If you are removing rescriptum for good and it held a +token, delete `/volume1/@appconf/rescriptum` yourself. + +## The desktop application + +The package installs an application on the DSM desktop — the icon is in the main menu, and +Package Center's **Open** button leads to it. It is a real DSM application, built on the +desktop's own UI framework, so it is in the DSM theme and in the DSM language; the French +of a French DSM is the application's French too. + +It has three tabs: + +- **Settings** — every configuration variable, as a form. Each field says where its value + comes from, and a value the *environment* sets is shown but locked, because editing the + file would not change it. Saving writes the file and offers to restart the package, + since the server reads its configuration once, at startup. +- **Status** — the version, whether the package is running, the answers folder and whether + it is really readable *by the service's own user*, and the output of `check`. +- **Log** — the last lines of the request log and of `startup.log`. + +Three properties are worth knowing rather than discovering: + +- **It edits the file, not the running server.** So it still works when the server will not + start, which is exactly when a settings panel earns its place. A change that would leave + the server unable to start is refused before anything is written, with the reason shown. +- **It never shows you a token.** `RESCRIPTUM_ANSWER_TOKEN` and `RESCRIPTUM_ADMIN_TOKEN` + appear as *set* or *not set*, and an empty box means "leave it alone" rather than "clear + it". Typing a new one replaces it. +- **It requires a DSM administrator.** Being signed in to DSM is not enough. See + [security](./security.md#the-desktop-application) for why that check is the whole door. + +*Restart now* stops and starts the package through DSM itself, so **DSM closes the window +while it does** — open it again to see the new state. The application says so next to the +button rather than letting it surprise you. + +It needs **DSM 7.1 or newer** (`os_min_ver="7.1-42661"`). It is built on DSM's ExtJS +framework, which is present on 7.1.1 and on 7.2.2 — both measured. DSM 7.2 ships a newer +Vue framework and Synology's current guide documents only that one; the DS416j this project +exists for is capped at 7.1.1, where `Vue` is undefined, so ExtJS is what covers every DSM +this package supports rather than only the recent ones. 7.0 is not claimed because nothing +has been run there. + +## Configuring it + +The application above is the comfortable way. Everything it does can also be done from a +shell, and on a machine where the desktop is not to hand that is the faster route: + +```console +$ sudo rescriptum-cli config +env file: /var/packages/rescriptum/etc/rescriptum.env + + RESCRIPTUM_STORE files default + RESCRIPTUM_ANSWERS_DIR /var/packages/rescriptum/shares/rescriptum/answers file + RESCRIPTUM_LISTEN_ADDR 0.0.0.0:8000 file + … + +$ sudo rescriptum-cli config set RESCRIPTUM_LOG=problems +wrote /var/packages/rescriptum/etc/rescriptum.env ``` -A DS918+ or any x86 model wants `x86_64-unknown-linux-musl`; a DS220j and other newer ARM -models want `aarch64-unknown-linux-musl`. +`config set` keeps the file's comments, uncomments a setting rather than duplicating it, +and **refuses a change that would stop the server starting**. Its exit code says whether +the configuration is one the server would start on, which makes it usable from a script. -The build must be **statically linked** — DSM's glibc is old enough that a dynamically -linked binary fails at exec time, on the NAS, with an error that does not obviously say -so: +Underneath both is the same file, and editing it by hand is still perfectly reasonable: ```console -$ file rescriptum -ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, stripped +$ sudo vi /var/packages/rescriptum/etc/rescriptum.env +``` + +`postinst` writes it complete on a fresh install, with the variables in use uncommented and +the rest commented with a line saying what they do. **Stop and start the package from +Package Center to apply a change** — the server reads the file at every start. + +An upgrade never touches it. The complete example for the version you have is at +`/var/packages/rescriptum/target/etc/rescriptum.env.example`, rewritten on every install +and upgrade, which is how a new variable becomes visible without disturbing your live file. +Every variable is in the [configuration reference](../reference/configuration.md). + +The file is `chmod 600` and owned by the package user. It is where +`RESCRIPTUM_ANSWER_TOKEN` and `RESCRIPTUM_ADMIN_TOKEN` live, and — being under `etc/` — it +is a plausible passenger in a DSM configuration backup. Worth knowing rather than +discovering. + +The [admin API](./admin-api.md) is off by default and, when you enable it, should stay on +loopback and be reached through an SSH tunnel; it is deliberately *not* registered with the +firewall. It also requires `RESCRIPTUM_STORE=sqlite` and a token of at least 16 characters, +both of which are **startup errors** — so getting them wrong shows up as a package that +will not start, with the reason in `/var/log/packages/rescriptum.log`. + +## Putting answers in place + +Drop files into the `rescriptum` shared folder's `answers` directory, over File Station or +over SSH, exactly as you would anywhere else — see [writing answers](../answers/index.md). +Then validate them **as the package user**: + +```console +$ sudo -u rescriptum rescriptum-cli check +``` + +The `sudo -u` matters. Run as root it succeeds whatever the shared folder's permissions +say, which makes a successful run meaningless. `rescriptum-cli` is the packaged wrapper: it +names the env file, so `check` and `render` look at this machine's answers rather than at +`/srv/answers`. + +## The firewall + +**Control Panel → Security → Firewall** — create a rule allowing *rescriptum* from your +provisioning network. The service appears by name because the package registered its port. + +DSM's firewall is the single most common reason a machine "never contacts the server". + +If you change the port later, edit `RESCRIPTUM_LISTEN_ADDR` in the env file and then move +the firewall entry, which does not follow by itself: + +```console +$ sudo /usr/syno/sbin/synopkghelper update rescriptum port-config +``` + +## The log + +`RESCRIPTUM_LOG_FILE` points the server at `/var/packages/rescriptum/var/rescriptum.log`, +and the package installs a logrotate stanza for it — weekly, eight kept, `copytruncate` +(the server opens its log once and never reopens it, so anything else would silently end +logging). Beside it, `var/startup.log` holds what the server says before it knows where its +log lives: a configuration error, a malformed env file. + +Once a rollout is routine, `RESCRIPTUM_LOG=problems` keeps the failures and drops the +successful answers, which are the only high-volume thing in there. + +## When it will not start + +Three places say why, in this order: + +```console +$ cat /var/log/packages/rescriptum.log # the package scripts' own output +$ cat /var/packages/rescriptum/var/startup.log # what the server said before it had a log +$ cat /var/packages/rescriptum/var/rescriptum.log +$ systemctl status pkgctl-rescriptum # what DSM's service manager saw +``` + +A **refused configuration** — an admin token under 16 characters, a store that cannot be +opened — is reported *after* the server knows where its log lives, so it lands in +`rescriptum.log`; a malformed env file is reported before, and lands in `startup.log`. The +package's `start` prints the tail of both when the server exits immediately, so Package +Center shows you the reason rather than only the failure. + +**DSM does not restart the process if it dies.** The unit it generates is `Type=oneshot` +with `RemainAfterExit=yes` and no `Restart=`, so a server that exits stays stopped until you +start it from Package Center. That is not a regression — the Task Scheduler route did not +restart it either — but it is worth knowing before you rely on it. + +A package that installs, starts, and then answers `404` to everything is almost always the +answers directory: check `sudo -u rescriptum rescriptum-cli check`. On a NAS with an +encrypted shared folder, that is also what a boot before the volume is unlocked looks like +— unlock it and restart the package. + +## Verify + +```console +$ curl http://NAS_IP:8000/health +OK ``` -## 2. Put your answers next to it +## Without the package + +The manual route still works, and is the honest choice if you would rather not install a +package at all. + +Use the **`armv7-unknown-linux-gnueabihf`** build (or `x86_64-unknown-linux-musl`, or +`aarch64-unknown-linux-musl` for a newer ARM model) from the +[releases page](https://github.com/z29k/rescriptum/releases), or cross-compile one yourself +(see [building](../../development/building.md)). ```console +$ scp rescriptum admin@nas:/volume1/netboot/rescriptum +$ ssh admin@nas chmod +x /volume1/netboot/rescriptum $ ssh admin@nas mkdir -p /volume1/netboot/answers ``` -`/volume1/netboot/` is where a DSM shared folder lives, so it is the natural home for both -the binary and the answers. It is not a default: `RESCRIPTUM_ANSWERS_DIR` defaults to -`/srv/answers`, which does not exist on DSM, so set it explicitly here. The env file below -is the tidiest place to do that. +If ARMv7 misbehaves, confirm the real architecture before assuming: + +```console +$ ssh admin@nas uname -m +armv7l +``` + +**Take the ARMv7 build, not a musl one you built yourself.** The published `armv7` binary +is linked against glibc 2.17, which DSM has; a musl build of the same code installs, answers +`--version`, and then dies the moment it wants the time. Synology's 3.10 kernels answer the +*time64* syscalls with `EINVAL` rather than `ENOSYS`, and musl 1.2 only falls back on +`ENOSYS` — the [build page](../../development/building.md#why-armv7-is-the-one-target-that-is-not-musl) +has the measurement. The x86_64 and aarch64 builds are static musl and unaffected. + +```console +$ file rescriptum +ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), dynamically linked, ... +``` -## 3. Autostart +`RESCRIPTUM_ANSWERS_DIR` defaults to `/srv/answers`, which does not exist on DSM, so set it +explicitly. The env file below is the tidiest place to do that. **Control Panel → Task Scheduler → Create → Triggered Task → User-defined script** @@ -65,17 +301,14 @@ is the tidiest place to do that. | User | `root` | | Command | see below | -```sh -RESCRIPTUM_ANSWERS_DIR=/volume1/netboot/answers /volume1/netboot/rescriptum -``` - -If you use a token, **do not put it in that box.** Anything in a process's arguments — -and in DSM's case, in the task definition — is readable by every user on the machine -through `ps`. Put the configuration in a root-only file and name it instead: +If you use a token, **do not put it in that box.** Anything in a process's arguments — and +in DSM's case, in the task definition — is readable by every user on the machine through +`ps`. Put the configuration in a root-only file and name it instead: ```sh # /volume1/netboot/rescriptum.env (chmod 600, owned by root) RESCRIPTUM_ANSWERS_DIR=/volume1/netboot/answers +RESCRIPTUM_LOG_FILE=/volume1/netboot/rescriptum.log RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/volume1/netboot/answers.db RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001 @@ -100,38 +333,11 @@ the file is readable by anyone but root, and names any key it does not recognise Details of the format are in the [configuration reference](../reference/configuration.md#the-env-file). -Run the task once by hand from the Task Scheduler rather than waiting for a reboot to -find out it does not work. - -## 4. Open the port - -**Control Panel → Security → Firewall** — allow TCP 8000 (or whatever you set -`RESCRIPTUM_LISTEN_ADDR` to) from your provisioning network. - -DSM's firewall is the single most common reason a machine "never contacts the server". - -## 5. Verify - -```console -$ curl http://NAS_IP:8000/health -OK -``` - -## Where the log goes - -Nowhere, by default: DSM's scheduler discards a task's output. Name a file in the env file -and the server writes there itself, with no shell redirection to get wrong: - -```sh -RESCRIPTUM_LOG_FILE=/volume1/netboot/rescriptum.log -``` - -The log line is the whole diagnostic story when a PXE install will not start, so this is -not optional. Once a rollout is routine, `RESCRIPTUM_LOG=problems` keeps the failures and -drops the successful answers, which are the only high-volume thing in there. Rotate the -file yourself; the server does not. +Run the task once by hand from the Task Scheduler rather than waiting for a reboot to find +out it does not work. Then open the port in the firewall by number, and rotate the log +yourself — the server does not, and nothing else will either. -## Replacing a running instance +### Replacing a running instance ```console $ ./deploy.sh admin@nas @@ -142,18 +348,20 @@ binary under a temporary name so a half-copied file is never executed, restarts confirms `/health` responds. Details in [deployment](./deployment.md#replacing-a-running-instance). -The Task Scheduler entry is still what starts it after a reboot — `deploy.sh` only -replaces what is running now. +The Task Scheduler entry is still what starts it after a reboot — `deploy.sh` only replaces +what is running now. On a packaged install, use Package Center instead. ## Shutdown -DSM's scheduler sends `SIGTERM` on shutdown, which the server handles: it stops accepting -and exits. There is no state to lose either way. +Both routes send `SIGTERM`, which the server handles: it stops accepting and exits. There +is no state to lose either way. ## What to expect from a DS416j -512 MB and an ARMv7 core is not much, and it does not need to be. A connection costs -kilobytes rather than a thread, the directory listing is cached and invalidated by mtime +512 MB and an ARMv7 core is not much, and it does not need to be. Measured on a DS416j +running the package, over the LAN: **3–4 ms to compose and serve an answer**, network round +trip included, for a machine claimed by a group and merged with its own file. A connection +costs kilobytes rather than a thread, the directory listing is cached and invalidated by mtime rather than walked per request, and a group with no per-machine overrides is rendered once at load and served afterwards as a prepared string. diff --git a/docs/guide/reference/cli.fr.md b/docs/guide/reference/cli.fr.md index c2398ce..0917b90 100644 --- a/docs/guide/reference/cli.fr.md +++ b/docs/guide/reference/cli.fr.md @@ -19,6 +19,11 @@ Sans argument, `rescriptum` lance le serveur. Tout le reste est une sous-command | `rescriptum check` | rendre tout le store configuré et signaler ce qui casse | | `rescriptum import ` | copier un répertoire de documents dans le store configuré | | `rescriptum export ` | écrire le store configuré comme un répertoire de documents | +| `rescriptum config` | afficher la configuration, et d'où vient chaque valeur | +| `rescriptum config --json` | la même chose, pour un panneau de réglages | +| `rescriptum config --value CLÉ` | une valeur, pour un script — jamais un identifiant | +| `rescriptum config set C=V …` | éditer le fichier que `RESCRIPTUM_ENV_FILE` nomme | +| `rescriptum config unset CLÉ …` | recommenter un réglage dedans | | `rescriptum --help` | usage et variables d'environnement | Toutes lisent les mêmes [variables d'environnement](./configuration.md), dont @@ -73,6 +78,53 @@ $ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum export / L'aller-retour est identique octet pour octet. Aucun des deux ne lance `check` pour vous — la sortie vous le dit. +## `config` + +La configuration, ce sont des variables d'environnement, et sur un déploiement qui les lit +depuis un fichier — une installation par paquet, surtout — voici comment les voir et les +changer sans ouvrir d'éditeur. C'est aussi ce que +l'[application DSM](../operations/synology.md#lapplication-de-bureau) exécute dessous. + +```console +$ rescriptum config +env file: /var/packages/rescriptum/etc/rescriptum.env + + RESCRIPTUM_STORE files default + RESCRIPTUM_ANSWERS_DIR /volume1/netboot/answers file + RESCRIPTUM_LISTEN_ADDR 0.0.0.0:9000 environment + RESCRIPTUM_ADMIN_TOKEN (set) file +``` + +La troisième colonne est l'essentiel. Le fichier fournit des **valeurs par défaut** et +l'environnement réel l'emporte : une valeur marquée `environment` ne peut donc pas être +changée en éditant le fichier — et `config set` le dit, plutôt que de vous laisser écrire +quelque chose que le serveur en cours continuera d'ignorer. + +**Un identifiant n'est jamais affiché**, sous aucune forme de cette commande. Un jeton +apparaît comme `(set)` ou `(not set)` ; `--value` refuse tout net. + +```console +$ rescriptum config set RESCRIPTUM_LOG=problems RESCRIPTUM_CAPTURE_DIR=/srv/captures +wrote /var/packages/rescriptum/etc/rescriptum.env +``` + +L'écriture laisse le fichier tel qu'il est par ailleurs : les commentaires restent, un +réglage est remplacé là où il se trouve, et un réglage commenté est **décommenté sur place** +plutôt qu'ajouté en dessous — ce qui compte quand le commentaire au-dessus est la seule +documentation qu'a le fichier. + +Deux refus sont délibérés : + +- **Une modification qui laisserait un serveur incapable de démarrer est refusée**, en bloc, + avant toute écriture. Activer l'API d'administration sans jeton, ou avec un jeton de moins + de 16 caractères, vous vaut la raison plutôt qu'un prochain démarrage cassé. +- **Une variable mal orthographiée est refusée.** Écrite, elle serait relue comme une + inconnue et signalée au démarrage suivant, quand plus personne ne fait le lien. + +Contrairement à toutes les autres sous-commandes, celle-ci fonctionne quand la configuration +est trop cassée pour démarrer un serveur — un fichier qui ne parse pas, un jeton d'un +caractère trop court. C'est l'état dont on se sert d'elle pour *sortir*. + ## Codes de sortie | Code | Signifie | @@ -80,5 +132,9 @@ sortie vous le dit. | `0` | succès | | `1` | la commande a échoué — rien ne s'est résolu, un document ne parse pas, le store n'a pas pu être ouvert | +`config` est la seule à avoir un second sens : **`0` dit que la configuration en est une sur +laquelle le serveur démarrerait**, `1` qu'elle ne l'est pas — ou qu'une écriture a été +refusée. Cela la rend utilisable depuis un script, comme `check`. + Le serveur lui-même sort en `0` sur `SIGTERM` ou Ctrl-C, et en `1` s'il ne peut pas binder ou ouvrir le store. diff --git a/docs/guide/reference/cli.md b/docs/guide/reference/cli.md index 4a56475..b204d6d 100644 --- a/docs/guide/reference/cli.md +++ b/docs/guide/reference/cli.md @@ -19,6 +19,11 @@ With no arguments, `rescriptum` runs the server. Everything else is a subcommand | `rescriptum check` | render everything in the configured store and report what breaks | | `rescriptum import ` | copy a directory of documents into the configured store | | `rescriptum export ` | write the configured store out as a directory of documents | +| `rescriptum config` | show the configuration, and where each value comes from | +| `rescriptum config --json` | the same, for a settings panel | +| `rescriptum config --value KEY` | one value, for a script — never a credential | +| `rescriptum config set K=V …` | edit the file `RESCRIPTUM_ENV_FILE` names | +| `rescriptum config unset KEY …` | comment a setting back out of it | | `rescriptum --help` | usage and the environment variables | All of them read the same [environment variables](./configuration.md), including @@ -73,6 +78,51 @@ $ RESCRIPTUM_STORE=sqlite RESCRIPTUM_DB_PATH=/srv/answers.db rescriptum export / reverse. The round trip is byte-identical. Neither runs `check` for you — the output says to. +## `config` + +The configuration is environment variables, and on a deployment that reads them from a +file — a packaged install, mainly — this is how to see and change them without opening an +editor. It is also what the [DSM application](../operations/synology.md#the-desktop-application) +runs underneath. + +```console +$ rescriptum config +env file: /var/packages/rescriptum/etc/rescriptum.env + + RESCRIPTUM_STORE files default + RESCRIPTUM_ANSWERS_DIR /volume1/netboot/answers file + RESCRIPTUM_LISTEN_ADDR 0.0.0.0:9000 environment + RESCRIPTUM_ADMIN_TOKEN (set) file +``` + +The third column is the point. The file supplies **defaults** and the real environment +wins, so a value marked `environment` cannot be changed by editing the file — and `config +set` says so rather than letting you write something the running server will ignore. + +**A credential is never printed**, by any form of this command. A token shows as `(set)` or +`(not set)`; `--value` refuses outright. + +```console +$ rescriptum config set RESCRIPTUM_LOG=problems RESCRIPTUM_CAPTURE_DIR=/srv/captures +wrote /var/packages/rescriptum/etc/rescriptum.env +``` + +Writing keeps the file as it is otherwise: comments stay, a setting is replaced where it +stands, and one that is commented out is **uncommented in place** rather than appended +below — which matters when the comment above it is the only documentation the file has. + +Two refusals are deliberate: + +- **A change that would leave a server unable to start is refused**, whole, before anything + is written. Turning on the admin API without a token, or with a token shorter than 16 + characters, gets the reason instead of a broken next boot. +- **A misspelled variable is refused.** Written, it would be read back as a stranger and + warned about at the next start, by which time nobody connects the two. + +Unlike every other subcommand, this one works when the configuration is too broken to start +a server — a file that will not parse, a token one character short. That is the state +people run it *to get out of*. + ## Exit statuses | Status | Means | @@ -80,5 +130,9 @@ to. | `0` | success | | `1` | the command failed — nothing resolved, a document would not parse, the store could not be opened | +`config` is the one with a second meaning: **`0` says the configuration is one the server +would start on**, `1` that it is not — or that a write was refused. That makes it usable +from a script the way `check` is. + The server itself exits `0` on `SIGTERM` or Ctrl-C, and `1` if it cannot bind or cannot open the store. diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index c8c953e..e7199e7 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -125,6 +125,17 @@ par défaut — mauvais répertoire de réponses, pas de jeton admin — sans un Les avertissements nomment les clés et les chemins, jamais les valeurs. +## Le lire et le modifier + +`rescriptum config` affiche chaque variable, sa valeur, et **qui du fichier ou de +l'environnement l'y a mise** — la distinction qui compte, puisque le fichier fournit des +valeurs par défaut et que l'environnement réel l'emporte. `config set` modifie le fichier +comme on voudrait qu'il le soit : commentaires conservés, réglage commenté décommenté sur +place plutôt que dupliqué, et refus avant toute écriture d'une modification qui laisserait +un serveur incapable de démarrer. C'est documenté dans la +[référence de la ligne de commande](./cli.md#config), et c'est ce que +l'[application DSM](../operations/synology.md#lapplication-de-bureau) pilote dessous. + ## Valeurs invalides | Cas | Ce qui se passe | diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index ff7b9e8..2e79e8e 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -120,6 +120,17 @@ answers directory, no admin token — without a word in the log. Warnings name keys and paths, never values. +## Reading and editing it + +`rescriptum config` prints every variable, its value, and **which of the file and the +environment put it there** — the distinction that matters, because the file supplies +defaults and the real environment wins. `config set` edits the file the way you would want +it edited: comments kept, a commented-out setting uncommented in place rather than +duplicated, and a change that would leave a server unable to start refused before anything +is written. It is documented in the [command line reference](./cli.md#config), and it is +what the [DSM application](../operations/synology.md#the-desktop-application) drives +underneath. + ## Invalid values | Case | What happens | diff --git a/packaging/dsm/INFO.in b/packaging/dsm/INFO.in new file mode 100644 index 0000000..7e5c307 --- /dev/null +++ b/packaging/dsm/INFO.in @@ -0,0 +1,41 @@ +# The SPK's metadata, before substitution. `make-spk.sh` fills @VERSION@, @ARCH@ and +# @EXTRACTSIZE@ in and drops every `#` line, so what ships is key="value" and nothing +# else — DSM's own parser is not something to try comments on. +# +# Six fields are required: package, version, os_min_ver, description, arch, maintainer. +# Most of the optional ones are deliberately absent: +# +# startable deprecated after DSM 6.1-14907, replaced by ctl_stop +# ctl_stop already defaults to "yes"; it exists to *forbid* stopping +# thirdparty DSM 4.0-4.3 only +# adminport sends Package Center's "Open" button at a URL on a port. This package has a +# desktop *application* instead, which `dsmappname` below already points that +# button at — and adminport would still drag in checkport, refusing to install +# over a collision on a port the user is free to change afterwards +# os_max_ver a ceiling written today would be wrong within a year; if a future DSM +# breaks the package, the answer is a new release +package="rescriptum" +version="@VERSION@" +# **7.1, and it is the two machines that decided it rather than a guide.** The desktop +# application is built on DSM's ExtJS framework, which is present on 7.1.1 and on 7.2.2 — +# both measured. DSM 7.2's *Vue* framework would have been the modern choice and was the +# first attempt, until the DS416j this project exists for turned out to be capped at 7.1.1, +# where `Vue` is undefined. 7.0 is not claimed because nothing has run there. +os_min_ver="7.1-42661" +displayname="rescriptum" +description="Serves unattended-installation answers, composed per machine, to whichever installer asks." +maintainer="z29k" +maintainer_url="https://github.com/z29k/rescriptum" +arch="@ARCH@" +helpurl="https://z29k.github.io/rescriptum/" +# The desktop application. `dsmuidir` is a path inside the payload, which DSM symlinks to +# /usr/syno/synoman/webman/3rdparty/rescriptum on install; `dsmappname` must match a class +# name declared in `ui/config`, and is what Package Center's "Open" button launches. A +# mismatch between the two breaks that button and nothing else complains, which is why +# check-spk.sh compares them. +dsmuidir="ui" +dsmappname="SYNO.SDS.App.Rescriptum.AppInstance" +# Kilobytes, measured from the unpacked payload. Left unset this does not mean "unknown": +# it means "the SPK's own byte size", which understates a compressed payload — a wrong +# number rather than a missing one. +extractsize="@EXTRACTSIZE@" diff --git a/packaging/dsm/PACKAGE_ICON.PNG b/packaging/dsm/PACKAGE_ICON.PNG new file mode 100644 index 0000000..ad899b1 Binary files /dev/null and b/packaging/dsm/PACKAGE_ICON.PNG differ diff --git a/packaging/dsm/PACKAGE_ICON_256.PNG b/packaging/dsm/PACKAGE_ICON_256.PNG new file mode 100644 index 0000000..26c3e65 Binary files /dev/null and b/packaging/dsm/PACKAGE_ICON_256.PNG differ diff --git a/packaging/dsm/README.md b/packaging/dsm/README.md new file mode 100644 index 0000000..84dec81 --- /dev/null +++ b/packaging/dsm/README.md @@ -0,0 +1,123 @@ +# The Synology DSM package + +Everything here assembles a `.spk` around an already-built binary. Nothing in `src/` knows +Synology exists, and nothing here compiles anything: **an `.spk` is a release format**, +exactly like the `.tar.gz` archives — the same artifact, wrapped for one platform's package +manager. If this ever seems to need a `#[cfg]` or a feature flag, the design has gone wrong. + +The two places DSM did push back on the program are answered in packaging rather than in +code: log rotation, by a `copytruncate` stanza, and the CLI's need to find its +configuration, by the three-line `payload/bin/rescriptum-cli` wrapper. + +```console +$ ./build.sh --spk x86_64-unknown-linux-musl # build, then wrap +$ packaging/dsm/make-spk.sh armv7 # wrap a build that already exists +$ packaging/dsm/check-spk.sh # structural check over dist/*.spk +``` + +`make-spk.sh` is deterministic — fixed mtimes, ownership `0:0`, `ustar`, `gzip -n`, a +pre-sorted file list — so the same inputs give a byte-identical `.spk` and the published +checksum means something. It runs on GNU tar and on bsdtar; the two do not agree with each +other byte for byte, and the release always runs on the same one. + +## What is in the archive + +| | | +|---|---| +| `INFO.in` | metadata; `make-spk.sh` fills in the version, the `arch` line and `extractsize`, and strips the comments | +| `conf/privilege` | DSM 7 requires the package to lower its privilege explicitly. `run-as: package`, `username: rescriptum` | +| `conf/resource` | the four resource workers: the shared folder, the firewall entry, the logrotate stanza, the `/usr/local/bin` symlink | +| `scripts/` | the lifecycle. `start-stop-status` is the service; the other six are guards | +| `WIZARD_UIFILES/install_uifile` | two questions, in JSON. (The Vue render-function format is a *second* way, introduced in DSM 7.2.2 — which the DS416j can never run) | +| `payload/` | what lands in `/var/packages/rescriptum/target`, beside the binary | +| `PACKAGE_ICON*.PNG` | 64×64 and 256×256, committed rather than generated, so the build needs no image toolchain and the archive stays byte-stable | + +## Decisions worth not re-litigating + +- **One name everywhere — `rescriptum`**: the package, the share and the user. A username + that does not match `data-share`'s permission list creates the share and grants it to + nobody, silently, which is why `conf/privilege` sets it explicitly instead of letting DSM + derive it. +- **The env file is the configuration interface, before and after install.** DSM has no + settings panel for a package: the only wizards are install, upgrade and uninstall. So + `/var/packages/rescriptum/etc/rescriptum.env` is where settings live, `postinst` writes it + complete on a fresh install, and stop/start from Package Center is the reconfiguration + gesture. +- **`postinst` writes that file only when it is absent**, because `postinst` runs on an + *upgrade* too. `preupgrade`/`postupgrade` carry it through `$SYNOPKG_TEMP_UPGRADE_FOLDER` + as well; both, deliberately. And `postinst` **consults that folder before deciding the + file is absent** — it runs *before* `postupgrade`, so on an upgrade where `etc/` did not + survive, writing defaults there would destroy the user's configuration before the restore + ever ran. That one was found by simulating exactly that case, not by reading the + sequence. +- **`postuninst` touches the package tree only** — never the share, never the database, + under any status. It runs during an upgrade as well as an uninstall, and when the store + is SQLite the database *is* the answers. +- **Every path is set explicitly.** `RESCRIPTUM_ANSWERS_DIR` defaults to `/srv/answers` and + `RESCRIPTUM_DB_PATH` to `/srv/answers.db`; neither exists on DSM, and an unopenable store + is a startup error rather than a warning. The database goes in the share beside the + answers, everything disposable in `var/`. +- **`arch` claims an ABI, not a platform.** It takes family names, so `x86_64` covers every + Intel platform including ones Synology has not shipped yet — the guide's own appendix is + already missing `r1000` and `epyc7002`. The family shorthand does *not* reach the Marvell + ARMv7 platforms, so the DS416j's `armada38x` is named. The rule for widening it: the + binary has to have run on the oldest-kernel member of that ABI first. +- **Assembled by hand rather than with `pkgscripts-ng`.** The toolkit exists to *compile* + inside a DSM environment; the binary is already built and linked. Doing it + ourselves keeps the release job hermetic and reviewable, which matters for something + people run as root. +- **No package source, so no update notifications.** Download the `.spk` from the GitHub + Release and install it by hand, for an upgrade as much as for an install. The + documentation says so out loud rather than letting it become a bug report. + +## How it is tested + +Three harnesses, and each proves what the others cannot. + +```console +$ packaging/dsm/check-spk.sh # the archive is what DSM expects +$ packaging/dsm/lifecycle-test.sh # what the scripts decide +$ packaging/dsm/vm/on-dsm.sh admin@localhost -p 2222 # what only DSM can answer +``` + +The first two run **in CI on every push**, so packaging breaks on the PR that breaks it +rather than at tag time. `lifecycle-test.sh` unpacks an `.spk` into a fake `/var/packages` +tree and drives the real scripts through it: install with a wizard and without one, a +hostile wizard value, start and `/health` on the port the wizard chose, the service still +alive seconds later, the exit codes Package Center reads (`3` for stopped, `1` for a stale +pidfile), a start that cannot succeed, an upgrade over a hand-edited env file and a canary +in the share — twice, once with `etc/` surviving and once with it wiped — and an uninstall +that must leave the answers alone. 33 checks, seconds, no DSM. + +It was watched failing: reverting the `postinst` upgrade guard, making `postuninst` delete +the share, returning `1` for a stopped package and refusing `prestart` turns it into 25 +green and 8 red. + +**The third has now run, green, on a DSM 7.2.2 machine** — 24 checks: installed, started and +still alive seconds later, `/health` answering on the wizard's port, the share created and +writable by the package user, the firewall entry acquired, `sudo -u rescriptum +rescriptum-cli check` passing, `logrotate -f` rotating without moving the inode, an upgrade +carrying a hand-edited env file and a canary through untouched, and an uninstall leaving the +share alone. **And it has now run on the DS416j itself** — installed from Package Center, wizard +rendered, service started, a real answer composed from a group and a machine file in 3–4 ms, +and an upgrade to `-2` that kept both the configuration and the answers. That run is what +found the two defects the VM could not: musl 1.2 cannot work on Synology's 3.10 kernels, and +an AppleDouble file dropped by a Mac over SMB hijacks a machine's answer. + +It needs a machine, and [`vm/README.md`](vm/README.md) is the rig — a QEMU launcher +for a DSM 7 VM, and one script that runs the on-machine checks against the VM while you +iterate and against the DS416j for the verdict. It covers the `data-share` worker and its +ACL, the `port-config` worker, the generated systemd unit, `logrotate -f` against a live +descriptor, `sudo -u rescriptum rescriptum-cli check`, and whether Package Center accepts +the archive at all. It is destructive on purpose, and nothing ships on VM evidence alone. + +Open, and answered by watching rather than by reading — `on-dsm.sh` prints all four: +whether `etc/` and `var/` survive an upgrade on their own, whether the `port-config` worker +acquires before or after `postinst` runs, what the generated unit says about `Restart=`, +and where DSM installed the logrotate stanza. Two more need a person: whether +`synopkghelper` needs root, and what Package Center says to an `.spk` built for the wrong +`arch`. + +When it fails, three places say why: `/var/log/packages/rescriptum.log` (our scripts' own +output), `/var/log/synopkg.log` (Package Center's view) and +`systemctl status pkgctl-rescriptum`. diff --git a/packaging/dsm/WIZARD_UIFILES/install_uifile b/packaging/dsm/WIZARD_UIFILES/install_uifile new file mode 100644 index 0000000..90174cd --- /dev/null +++ b/packaging/dsm/WIZARD_UIFILES/install_uifile @@ -0,0 +1,51 @@ +[ + { + "step_title": "rescriptum", + "items": [ + { + "type": "singleselect", + "desc": "Where do the answers live?", + "subitems": [ + { + "key": "pkgwizard_answers_share", + "desc": "In the shared folder this package creates (recommended). DSM creates a 'rescriptum' share, grants this package read/write access to it, and the answers go in its 'answers' subfolder.", + "defaultValue": true + }, + { + "key": "pkgwizard_answers_custom", + "desc": "Somewhere else. The package runs unprivileged, so it cannot grant itself access to a folder you name here: give the 'rescriptum' user read access yourself, or the server will start and answer nothing.", + "defaultValue": false, + "subitems": [ + { + "key": "pkgwizard_answers_path", + "desc": "Full path", + "defaultValue": "/volume1/netboot/answers", + "validator": { + "allowBlank": false + } + } + ] + } + ] + }, + { + "type": "textfield", + "desc": "Which port does it listen on? Registering the port makes rescriptum selectable by name in the DSM firewall — it does not open it. Changing it later means editing the env file (and one SSH command to move the firewall entry).", + "subitems": [ + { + "key": "pkgwizard_port", + "desc": "Port", + "defaultValue": "8000", + "validator": { + "allowBlank": false, + "regex": { + "expr": "/^[0-9]{1,5}$/", + "errorText": "A port number, 1024 or above — the package does not run as root." + } + } + } + ] + } + ] + } +] diff --git a/packaging/dsm/check-spk.sh b/packaging/dsm/check-spk.sh new file mode 100755 index 0000000..27a8ace --- /dev/null +++ b/packaging/dsm/check-spk.sh @@ -0,0 +1,357 @@ +#!/usr/bin/env bash +# Check an assembled .spk structurally. +# +# ./packaging/dsm/check-spk.sh dist/rescriptum-0.1.0-1-x86_64.spk +# ./packaging/dsm/check-spk.sh # everything in dist/ +# +# This cannot prove DSM will accept the package — nothing short of installing it can, which +# is what the milestones on a VM and on the real DS416j are for. What it does catch is the +# entire class of "the release job produced a 0-byte tarball", cheaply, on every push. + +set -euo pipefail + +REPO=$(cd "$(dirname "$0")/../.." && pwd) + +# Family names and platform names both belong in `arch`. The x86_64 row of the guide's +# appendix is already stale — it omits r1000 (DS723+, DS923+, DS1522+) and epyc7002 — which +# is exactly why we ship the family value and why this list is only a sanity check. +KNOWN_ARCH="noarch +x86_64 i686 armv7 armv5 armv8 +apollolake avoton braswell broadwell broadwellnk broadwellntb broadwellntbap bromolow +cedarview coffeelake denverton epyc7002 epyc7003 geminilake geminilakenk grantley kvmx64 +purley r1000 skylaked v1000 +evansport +alpine alpine4k armada370 armada375 armada38x armadaxp comcerto2k monaco +88f6281 88f6282 628x +armada37xx rtd1296 rtd1619 rtd1619b" + +REQUIRED_SCRIPTS="preinst postinst preuninst postuninst preupgrade postupgrade start-stop-status" + +fails=0 +bad() { echo " ✗ $*"; fails=$((fails + 1)); } +ok() { echo " ✓ $*"; } +skip() { echo " – $*"; } + +png_size() { + local hex + hex=$(od -An -tx1 -j16 -N8 "$1" | tr -d ' \n') + echo "$((16#${hex:0:8}))x$((16#${hex:8:8}))" +} + +info_value() { sed -n "s/^$1=\"\(.*\)\"$/\1/p" "$2"; } + +check_one() { + local spk="$1" work + echo "$(basename "$spk"):" + + # A .spk whose outer tar is gzipped is rejected with "invalid file format" and no + # further detail. Worth catching here rather than in Package Center. + local kind + kind=$(file -b "$spk") + case "$kind" in + *"tar archive"*) ok "outer archive is an uncompressed tar" ;; + *) bad "outer archive is not an uncompressed tar: $kind" ;; + esac + tar -tf "$spk" >/dev/null 2>&1 || bad "the outer archive does not list" + + # bsdtar's pax headers and macOS's AppleDouble members both travel invisibly and both + # break the package. + if tar -tf "$spk" | grep -qE '(^|/)(\._|PaxHeader)'; then + bad "the archive carries PaxHeader or ._ members" + else + ok "no PaxHeader or ._ members" + fi + + work=$(mktemp -d "${TMPDIR:-/tmp}/spk-check.XXXXXX") + tar -xf "$spk" -C "$work" + + # ── INFO ─────────────────────────────────────────────────────────────────── + if [ ! -f "$work/INFO" ]; then + bad "no INFO" + rm -rf "$work" + return + fi + local missing="" field + for field in package version os_min_ver description arch maintainer; do + [ -n "$(info_value "$field" "$work/INFO")" ] || missing="$missing $field" + done + if [ -n "$missing" ]; then bad "INFO is missing:$missing"; else ok "INFO has the six required fields"; fi + + local version + version=$(info_value version "$work/INFO") + case "$version" in + '' | *[!0-9.-]* | *--*) bad "version \"$version\" has a segment DSM cannot parse" ;; + *) ok "version $version is all-numeric segments" ;; + esac + + # The desktop application is built on DSM's ExtJS framework, which was measured on 7.1.1 + # and 7.2.2. 7.0 is not claimed because nothing has ever run there — and a package that + # installs on a DSM it has not been seen on gives that machine an icon and a gamble. + local osmin + osmin=$(info_value os_min_ver "$work/INFO") + case "$osmin" in + 7.0-*) bad "os_min_ver=$osmin, but nothing has been verified below DSM 7.1" ;; + 7.*) ok "os_min_ver=$osmin" ;; + *) bad "os_min_ver=\"$osmin\" is not a DSM 7 version" ;; + esac + + local arch bad_arch="" + for arch in $(info_value arch "$work/INFO"); do + grep -qw -- "$arch" <<<"$KNOWN_ARCH" || bad_arch="$bad_arch $arch" + done + if [ -n "$bad_arch" ]; then + bad "arch names nothing known:$bad_arch" + else + ok "arch=\"$(info_value arch "$work/INFO")\"" + fi + + # extractsize left unset does not mean "unknown": it means "the SPK's own byte size". + local extract + extract=$(info_value extractsize "$work/INFO") + case "$extract" in + '' | *[!0-9]*) bad "extractsize is not a number of kilobytes: \"$extract\"" ;; + *) ok "extractsize=$extract KB" ;; + esac + + # ── icons ────────────────────────────────────────────────────────────────── + local icon want + for icon in "PACKAGE_ICON.PNG 64x64" "PACKAGE_ICON_256.PNG 256x256"; do + set -- $icon + want="$2" + if [ ! -f "$work/$1" ]; then + bad "no $1" + elif [ "$(png_size "$work/$1")" != "$want" ]; then + bad "$1 is $(png_size "$work/$1"), not $want" + else + ok "$1 is $want" + fi + done + + # ── conf and wizard ──────────────────────────────────────────────────────── + local f + for f in conf/privilege conf/resource WIZARD_UIFILES/install_uifile; do + if [ ! -f "$work/$f" ]; then + bad "no $f" + elif command -v python3 >/dev/null 2>&1 && ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$work/$f" 2>/dev/null; then + bad "$f is not valid JSON" + else + ok "$f" + fi + done + # DSM 7 requires the package to lower its privilege explicitly, and a username that + # does not match data-share's permission list creates the share and grants it to + # nobody — silently. + if grep -q '"run-as"[[:space:]]*:[[:space:]]*"package"' "$work/conf/privilege" 2>/dev/null; then + ok "conf/privilege runs as the package user" + else + bad "conf/privilege does not set run-as: package" + fi + local user + user=$(sed -n 's/.*"username"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$work/conf/privilege" 2>/dev/null) + if [ -n "$user" ] && grep -q "\"$user\"" "$work/conf/resource" 2>/dev/null; then + ok "the package user ($user) is the one data-share grants" + else + bad "conf/privilege's username and conf/resource's share permission disagree" + fi + + # ── lifecycle scripts ────────────────────────────────────────────────────── + local script before="$fails" + for script in $REQUIRED_SCRIPTS; do + if [ ! -f "$work/scripts/$script" ]; then + bad "no scripts/$script" + continue + fi + [ -x "$work/scripts/$script" ] || bad "scripts/$script is not executable" + head -n 1 "$work/scripts/$script" | grep -q '^#!/bin/sh' || bad "scripts/$script does not start with #!/bin/sh" + # A CRLF in a lifecycle script fails with an error that names neither the file nor + # the reason. + if grep -q $'\r' "$work/scripts/$script"; then bad "scripts/$script has CRLF line endings"; fi + sh -n "$work/scripts/$script" || bad "scripts/$script does not parse" + done + [ "$fails" -eq "$before" ] && ok "the seven lifecycle scripts are present, executable and parse" + + if command -v shellcheck >/dev/null 2>&1; then + if shellcheck -s sh -S warning "$work"/scripts/*; then + ok "shellcheck is happy" + else + bad "shellcheck found something" + fi + else + skip "shellcheck not installed" + fi + + # ── the payload ──────────────────────────────────────────────────────────── + if [ ! -f "$work/package.tgz" ]; then + bad "no package.tgz" + rm -rf "$work" + return + fi + mkdir -p "$work/target" + if ! tar -xzf "$work/package.tgz" -C "$work/target"; then + bad "package.tgz does not unpack" + rm -rf "$work" + return + fi + for f in bin/rescriptum bin/rescriptum-cli port_conf/rescriptum.sc logrotate/rescriptum; do + [ -f "$work/target/$f" ] || bad "the payload has no $f" + done + [ -x "$work/target/bin/rescriptum" ] || bad "the payload's binary is not executable" + ok "the payload carries the binary, the wrapper, the .sc file and the logrotate stanza" + + # The stanza's whole point: log::init opens the file once and never reopens it, so a + # rotation without copytruncate silently ends logging. + grep -q '^[[:space:]]*copytruncate' "$work/target/logrotate/rescriptum" && + ok "the logrotate stanza uses copytruncate" || + bad "the logrotate stanza does not use copytruncate" + + # The firewall entry never appears if the file is not named after the package, and + # nothing says why. + grep -q '^\[rescriptum\]' "$work/target/port_conf/rescriptum.sc" && + ok "port_conf/rescriptum.sc declares [rescriptum]" || + bad "port_conf/rescriptum.sc does not declare [rescriptum]" + + # ── the desktop application ──────────────────────────────────────────────── + local uidir appname + uidir=$(info_value dsmuidir "$work/INFO") + appname=$(info_value dsmappname "$work/INFO") + if [ -z "$uidir" ] || [ -z "$appname" ]; then + bad "INFO is missing dsmuidir or dsmappname — there would be no desktop icon" + else + ok "INFO declares the application ($appname in $uidir/)" + fi + + if [ -n "$uidir" ] && [ ! -d "$work/target/$uidir" ]; then + bad "INFO says dsmuidir=\"$uidir\" and the payload has no such directory" + else + local ui="$work/target/$uidir" + for f in config style.css api.cgi texts/enu/strings texts/fre/strings; do + [ -f "$ui/$f" ] || bad "the application has no $f" + done + + # The JavaScript is named after the version — see make-spk.sh for why — so the name + # is read out of `ui/config` rather than assumed. That also checks the substitution + # happened at all: an unreplaced @JSFILE@ would name a file that is not there. + local jsfile="" + if command -v python3 >/dev/null 2>&1 && [ -f "$ui/config" ]; then + jsfile=$(python3 -c 'import json,sys; print(next(iter(json.load(open(sys.argv[1])))))' "$ui/config" 2>/dev/null) + fi + case "$jsfile" in + '') bad "$uidir/config names no JavaScript file" ;; + *@*) bad "$uidir/config still has a placeholder in it: $jsfile" ;; + *) if [ -f "$ui/$jsfile" ]; then + ok "$uidir/config names $jsfile, and it is there" + else + bad "$uidir/config names $jsfile, which the payload does not have" + fi + # A browser caches this for years — the fixed mtime makes it look ancient — so + # the name has to move with the release or an upgrade changes nothing on screen. + case "$jsfile" in + *"$version"*) ok "and the name carries the version, so a browser cannot serve a stale one" ;; + *) bad "$jsfile does not carry version $version — an upgraded package would keep the cached application" ;; + esac + grep -q '@VERSION@' "$ui/$jsfile" 2>/dev/null && + bad "$jsfile still contains @VERSION@" + ;; + esac + local size + for size in 16 32 64 128 256; do + [ -f "$ui/images/$size.png" ] || bad "the application has no images/$size.png" + done + + # **`dsmappname` has to name a class that ui/config declares.** When it does not, + # the icon still appears and Package Center's "Open" button silently does nothing + # — there is no error anywhere, which is exactly why this is asserted here. + if command -v python3 >/dev/null 2>&1 && [ -f "$ui/config" ]; then + if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$ui/config" 2>/dev/null; then + bad "$uidir/config is not valid JSON" + elif python3 - "$ui/config" "$appname" <<'PY' +import json, sys +config, appname = json.load(open(sys.argv[1])), sys.argv[2] +sys.exit(0 if any(appname in classes for classes in config.values()) else 1) +PY + then + ok "$uidir/config declares $appname" + else + bad "$uidir/config does not declare $appname — the Open button would do nothing" + fi + fi + + # The backend. It is served by DSM's own web server, it runs as root, and that + # path is *not* authenticated by DSM — measured on 7.2.2, not assumed. The call to + # authenticate.cgi is therefore the only thing standing in front of it, and losing + # it would be silent: everything would keep working, for everybody on the network. + if [ -f "$ui/api.cgi" ]; then + [ -x "$ui/api.cgi" ] || bad "$uidir/api.cgi is not executable — DSM would serve its source" + head -n 1 "$ui/api.cgi" | grep -q '^#!/bin/sh' || bad "$uidir/api.cgi does not start with #!/bin/sh" + grep -q $'\r' "$ui/api.cgi" && bad "$uidir/api.cgi has CRLF line endings" + sh -n "$ui/api.cgi" || bad "$uidir/api.cgi does not parse" + + # **Grep the code, not the file.** The comment above this script explains the + # authentication at length, so grepping the whole file for "authenticate.cgi" + # passes even when the call has been deleted — which is a test reporting + # coverage it does not have, on the one guard that matters most here. + local code + code=$(grep -v '^[[:space:]]*#' "$ui/api.cgi") + printf '%s\n' "$code" | grep -q 'authenticate\.cgi' && + ok "$uidir/api.cgi checks the DSM session" || + bad "$uidir/api.cgi does not call authenticate.cgi — it would be open to anyone" + # The membership test itself, not the word: "administrators" also appears in + # the sentence shown to somebody who fails it, so grepping for the word alone + # stays green when the check is gone. + if printf '%s\n' "$code" | grep -q 'id -nG' && + printf '%s\n' "$code" | grep -q 'administrators'; then + ok "$uidir/api.cgi requires an administrator" + else + bad "$uidir/api.cgi does not test administrators membership" + fi + fi + + # A key present in one language and not the other renders as an empty label, and + # only somebody running DSM in that language would ever see it. + if [ -f "$ui/texts/enu/strings" ] && [ -f "$ui/texts/fre/strings" ]; then + local keys_en keys_fr + keys_en=$(grep -oE '^[a-zA-Z_]+ =' "$ui/texts/enu/strings" | sort) + keys_fr=$(grep -oE '^[a-zA-Z_]+ =' "$ui/texts/fre/strings" | sort) + if [ "$keys_en" = "$keys_fr" ]; then + ok "the English and French strings carry the same keys" + else + bad "the English and French strings have drifted apart" + fi + fi + fi + + # Not a re-read of the same string: this runs the binary that is actually in the + # package. It only works where the ABI matches the host, which on CI is the x86_64 one. + local reported="" want_version + want_version=${version%-*} + if reported=$("$work/target/bin/rescriptum" --version 2>/dev/null); then + if [ "$reported" = "rescriptum $want_version" ]; then + ok "the packaged binary reports $reported" + else + bad "the packaged binary reports \"$reported\", INFO says $version" + fi + else + skip "the packaged binary does not run on this host (wrong ABI) — version not checked" + fi + + rm -rf "$work" +} + +targets=("$@") +if [ ${#targets[@]} -eq 0 ]; then + while IFS= read -r line; do targets+=("$line"); done < <(find "$REPO/dist" -name '*.spk' 2>/dev/null | sort) +fi +if [ ${#targets[@]} -eq 0 ]; then + echo "no .spk to check (build one with packaging/dsm/make-spk.sh)" >&2 + exit 2 +fi + +for spk in "${targets[@]}"; do check_one "$spk"; done + +if [ "$fails" -gt 0 ]; then + echo + echo "$fails problem(s)" + exit 1 +fi +echo +echo "all checks passed" diff --git a/packaging/dsm/conf/privilege b/packaging/dsm/conf/privilege new file mode 100644 index 0000000..5f6dc4d --- /dev/null +++ b/packaging/dsm/conf/privilege @@ -0,0 +1,4 @@ +{ + "defaults": { "run-as": "package" }, + "username": "rescriptum" +} diff --git a/packaging/dsm/conf/resource b/packaging/dsm/conf/resource new file mode 100644 index 0000000..747fe7c --- /dev/null +++ b/packaging/dsm/conf/resource @@ -0,0 +1,19 @@ +{ + "data-share": { + "shares": [ + { + "name": "rescriptum", + "permission": { "rw": ["rescriptum"] } + } + ] + }, + "port-config": { + "protocol-file": "port_conf/rescriptum.sc" + }, + "syslog-config": { + "logrotate-relpath": "logrotate/rescriptum" + }, + "usr-local-linker": { + "bin": ["bin/rescriptum-cli"] + } +} diff --git a/packaging/dsm/lifecycle-test.sh b/packaging/dsm/lifecycle-test.sh new file mode 100755 index 0000000..3215fb5 --- /dev/null +++ b/packaging/dsm/lifecycle-test.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash +# Drive the package's lifecycle scripts against a fake /var/packages tree, and assert every +# outcome. Runs anywhere the packaged binary runs — no NAS, no VM, no root. +# +# packaging/dsm/lifecycle-test.sh # the first .spk in dist/ that runs here +# packaging/dsm/lifecycle-test.sh dist/rescriptum-0.1.0-1-x86_64.spk +# +# What it covers is everything the *scripts* decide: the env file written once and only +# once, the wizard's values and their absence, the service actually starting and answering, +# the exit codes Package Center reads, an upgrade that must not touch a hand-edited +# configuration, and an uninstall that must not touch the answers. That is where the +# expensive mistakes live, and none of it needs DSM. +# +# What it cannot cover is DSM's own machinery: the data-share worker, the port-config +# worker, the generated systemd unit, and whether Package Center accepts the archive at +# all. Those are packaging/dsm/vm/on-dsm.sh, on a machine. +# +# The scripts under test come out of the .spk itself, not out of the working tree — the +# point is to test what would ship. + +set -uo pipefail + +REPO=$(cd "$(dirname "$0")/../.." && pwd) + +pass=0 +fails=0 +ok() { + echo " ✓ $*" + pass=$((pass + 1)) +} +bad() { + echo " ✗ $*" + fails=$((fails + 1)) +} +section() { echo; echo "$*"; } + +# ── the package under test ───────────────────────────────────────────────────── +SPK="${1:-}" +if [ -z "$SPK" ]; then + for candidate in "$REPO"/dist/*.spk; do + [ -f "$candidate" ] || continue + SPK="$candidate" + break + done +fi +if [ -z "$SPK" ] || [ ! -f "$SPK" ]; then + echo "no .spk to test — build one with packaging/dsm/make-spk.sh" >&2 + exit 2 +fi +echo "$(basename "$SPK"):" + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/spk-lifecycle.XXXXXX") +ROOT="$WORK/var/packages/rescriptum" +mkdir -p "$ROOT/target" "$ROOT/etc" "$ROOT/var" "$ROOT/shares/rescriptum" + +tar -xOf "$SPK" package.tgz | tar -xzf - -C "$ROOT/target" +mkdir -p "$ROOT/scripts" +tar -xOf "$SPK" scripts/preinst >"$ROOT/scripts/preinst" +for s in postinst preuninst postuninst preupgrade postupgrade start-stop-status; do + tar -xOf "$SPK" "scripts/$s" >"$ROOT/scripts/$s" +done +chmod 755 "$ROOT/scripts"/* + +BIN="$ROOT/target/bin/rescriptum" +if ! "$BIN" --version >/dev/null 2>&1; then + echo " – the packaged binary does not run on this host; give this harness an .spk for it" >&2 + rm -rf "$WORK" + exit 2 +fi + +cleanup() { + if [ -f "$ROOT/var/rescriptum.pid" ]; then + kill -KILL "$(cat "$ROOT/var/rescriptum.pid" 2>/dev/null)" 2>/dev/null + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +export SYNOPKG_PKGDEST="$ROOT/target" +# The scripts locate the package root at /var/packages/ — a real DSM resolves +# SYNOPKG_PKGDEST to /volume1/@appstore/, so deriving it from there is wrong. This is +# the seam that lets them be driven against a tree we can actually write to. +export RESCRIPTUM_PKG_ROOT="$ROOT" +ENV_FILE="$ROOT/etc/rescriptum.env" +SHARE="$ROOT/shares/rescriptum" +sss() { sh "$ROOT/scripts/start-stop-status" "$@"; } + +# The wizard's port has to be free, or "it did not answer" would mean nothing. +PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()' 2>/dev/null || echo 18123) + +value_of() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1; } + +# GNU first, and the order is the whole point: on BSD `stat -f` is the format flag, on GNU +# it means "filesystem status" — and it *succeeds*, printing a block of overlayfs trivia +# instead of failing over to the next branch. Asking GNU first fails cleanly on macOS. +file_mode() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null +} + +# ── 1. install ───────────────────────────────────────────────────────────────── +section "install" +out=$(SYNOPKG_PKG_STATUS=INSTALL pkgwizard_port="$PORT" sh "$ROOT/scripts/postinst" 2>&1) +[ -f "$ENV_FILE" ] && ok "postinst wrote the env file" || bad "postinst wrote no env file: $out" +mode=$(file_mode "$ENV_FILE") +[ "$mode" = "600" ] && ok "it is mode 600" || bad "it is mode $mode, not 600" +[ -f "$ROOT/target/etc/rescriptum.env.example" ] && ok "the example was written too" || bad "no example env file" +[ "$(value_of RESCRIPTUM_LISTEN_ADDR)" = "0.0.0.0:$PORT" ] && ok "the wizard's port reached the env file" || bad "listen addr is $(value_of RESCRIPTUM_LISTEN_ADDR)" +[ "$(value_of RESCRIPTUM_ANSWERS_DIR)" = "$SHARE/answers" ] && ok "the answers default to the share" || bad "answers dir is $(value_of RESCRIPTUM_ANSWERS_DIR)" +grep -q "^RESCRIPTUM_DB_PATH=$SHARE/answers.db\$" "$ENV_FILE" && ok "the database path is pre-set in the share" || bad "RESCRIPTUM_DB_PATH is not pre-set — switching stores would be a fatal start" +grep -q "dst.ports=\"$PORT/tcp\"" "$ROOT/target/port_conf/rescriptum.sc" && ok "the .sc file carries the chosen port" || bad ".sc file: $(tail -1 "$ROOT/target/port_conf/rescriptum.sc")" + +section "install without a wizard (silent_install, or a reinstall that shows none)" +saved=$(cat "$ENV_FILE") +rm -f "$ENV_FILE" +SYNOPKG_PKG_STATUS=INSTALL sh "$ROOT/scripts/postinst" >/dev/null 2>&1 +[ -f "$ENV_FILE" ] && ok "postinst still writes a complete file" || bad "postinst produced nothing without wizard values" +[ "$(value_of RESCRIPTUM_LISTEN_ADDR)" = "0.0.0.0:8000" ] && ok "and falls back to port 8000" || bad "fell back to $(value_of RESCRIPTUM_LISTEN_ADDR)" + +section "a wizard port the package could not use" +rm -f "$ENV_FILE" +SYNOPKG_PKG_STATUS=INSTALL pkgwizard_port=80 sh "$ROOT/scripts/postinst" >/dev/null 2>&1 +[ "$(value_of RESCRIPTUM_LISTEN_ADDR)" = "0.0.0.0:8000" ] && ok "a privileged port falls back rather than failing to bind later" || bad "kept $(value_of RESCRIPTUM_LISTEN_ADDR) — the package does not run as root" +rm -f "$ENV_FILE" +SYNOPKG_PKG_STATUS=INSTALL pkgwizard_port='8000; rm -rf /' sh "$ROOT/scripts/postinst" >/dev/null 2>&1 +[ "$(value_of RESCRIPTUM_LISTEN_ADDR)" = "0.0.0.0:8000" ] && ok "a hostile wizard value is refused, not interpolated" || bad "listen addr became $(value_of RESCRIPTUM_LISTEN_ADDR)" + +section "the custom answers path" +rm -f "$ENV_FILE" +out=$(SYNOPKG_PKG_STATUS=INSTALL pkgwizard_answers_custom=true pkgwizard_answers_path=/volume1/netboot/answers sh "$ROOT/scripts/postinst" 2>&1) +[ "$(value_of RESCRIPTUM_ANSWERS_DIR)" = "/volume1/netboot/answers" ] && ok "the path reaches the env file" || bad "answers dir is $(value_of RESCRIPTUM_ANSWERS_DIR)" +case "$out" in +*"read access"*) ok "and the package says it cannot permission it for you" ;; +*) bad "nothing warned that the package cannot grant itself access" ;; +esac + +printf '%s\n' "$saved" >"$ENV_FILE" +chmod 600 "$ENV_FILE" + +# ── 2. the service ───────────────────────────────────────────────────────────── +section "start, and stay started" +sss status >/dev/null 2>&1 +[ $? -eq 3 ] && ok "status is 3 (not running) before the first start" || bad "status before start was $?, not 3" + +out=$(sss start 2>&1) +rc=$? +[ $rc -eq 0 ] && ok "start returns 0" || bad "start returned $rc: $out" +[ -d "$SHARE/answers" ] && ok "start created the answers directory inside the share" || bad "no answers directory — DSM creates the share, not this" + +answered=no +for _ in 1 2 3 4 5 6 7 8 9 10; do + if [ "$(curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null)" = "OK" ]; then + answered=yes + break + fi + sleep 0.5 +done +[ "$answered" = yes ] && ok "/health answers on the port the wizard chose" || bad "/health never answered on $PORT" + +sleep 2 +sss status >/dev/null 2>&1 +[ $? -eq 0 ] && ok "it is still alive seconds later (not reaped when start returned)" || bad "the service did not survive its own start script" + +section "the exit codes Package Center reads" +sss stop >/dev/null 2>&1 +[ $? -eq 0 ] && ok "stop returns 0" || bad "stop returned non-zero" +sss status >/dev/null 2>&1 +[ $? -eq 3 ] && ok "a cleanly stopped package is 3" || bad "stopped status was $?, and 1 would tell Package Center it crashed" +echo 999999 >"$ROOT/var/rescriptum.pid" +sss status >/dev/null 2>&1 +[ $? -eq 1 ] && ok "a dead process with a pidfile left behind is 1" || bad "stale pidfile reported $?" +sss stop >/dev/null 2>&1 +[ $? -eq 0 ] && ok "stop over a stale pidfile succeeds and clears it" || bad "stop over a stale pidfile failed" +sss prestart >/dev/null 2>&1 +[ $? -eq 0 ] && ok "prestart says yes — it runs at boot, and a no is permanent" || bad "prestart refused" +sss wibble >/dev/null 2>&1 +[ $? -eq 0 ] && ok "an unrecognised verb exits 0 rather than blocking boot" || bad "an unknown verb exited non-zero" + +section "a start that cannot succeed" +cp "$ENV_FILE" "$WORK/env.good" +printf 'RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8199\nRESCRIPTUM_ADMIN_TOKEN=short\nRESCRIPTUM_STORE=sqlite\n' >>"$ENV_FILE" +out=$(sss start 2>&1) +rc=$? +[ $rc -eq 1 ] && ok "start reports failure instead of a success that is already gone" || bad "start returned $rc for a configuration the server refuses" +case "$out" in +*"RESCRIPTUM_ADMIN_TOKEN"*) ok "and prints the reason, from the log the server actually used" ;; +*) bad "the reason was not shown: $out" ;; +esac +cp "$WORK/env.good" "$ENV_FILE" + +# ── 3. the upgrade, adversarially ────────────────────────────────────────────── +section "an upgrade must not touch a hand-edited configuration" +printf 'RESCRIPTUM_LOG=problems\nRESCRIPTUM_ANSWER_TOKEN=a-token-nobody-should-lose\n' >>"$ENV_FILE" +cp "$ENV_FILE" "$WORK/env.handedited" +echo "do not delete me" >"$SHARE/answers/canary.toml" +UPG="$WORK/upgrade" +export SYNOPKG_TEMP_UPGRADE_FOLDER="$UPG" + +upgrade() { # + rm -rf "$UPG" + mkdir -p "$UPG" + # The documented order: preupgrade (new) → preuninst/postuninst (old) → preinst, + # postinst (new) → postupgrade. + SYNOPKG_PKG_STATUS=UPGRADE sh "$ROOT/scripts/preupgrade" >/dev/null 2>&1 + SYNOPKG_PKG_STATUS=UPGRADE sh "$ROOT/scripts/preuninst" >/dev/null 2>&1 + SYNOPKG_PKG_STATUS=UPGRADE sh "$ROOT/scripts/postuninst" >/dev/null 2>&1 + [ "$1" = yes ] && rm -f "$ENV_FILE" + SYNOPKG_PKG_STATUS=UPGRADE sh "$ROOT/scripts/preinst" >/dev/null 2>&1 + SYNOPKG_PKG_STATUS=UPGRADE pkgwizard_port=9999 sh "$ROOT/scripts/postinst" >/dev/null 2>&1 + SYNOPKG_PKG_STATUS=UPGRADE sh "$ROOT/scripts/postupgrade" >/dev/null 2>&1 +} + +upgrade no +diff -q "$WORK/env.handedited" "$ENV_FILE" >/dev/null && ok "etc/ survives: the file is untouched, wizard values and all" || bad "the upgrade rewrote the user's env file" +[ -f "$SHARE/answers/canary.toml" ] && ok "the canary in the share survived" || bad "the upgrade destroyed a file in the share" + +upgrade yes +diff -q "$WORK/env.handedited" "$ENV_FILE" >/dev/null && ok "etc/ wiped: postinst restored it from the upgrade folder rather than writing defaults" || bad "the user's configuration was replaced by defaults — postinst runs BEFORE postupgrade" +mode=$(file_mode "$ENV_FILE") +[ "$mode" = "600" ] && ok "and it came back mode 600" || bad "the restored file is mode $mode" + +section "a fresh install must not resurrect a removed installation" +# The temp folder outlives the upgrade that created it. A later fresh install that found a +# copy of the old configuration there used to restore it — tokens and all — which is how a +# removed-and-reinstalled package came back up with somebody's old answer token. +rm -f "$ENV_FILE" +SYNOPKG_PKG_STATUS=INSTALL pkgwizard_port="$PORT" sh "$ROOT/scripts/postinst" >/dev/null 2>&1 +if grep -q 'a-token-nobody-should-lose' "$ENV_FILE"; then + bad "a fresh install restored the previous installation's configuration" +else + ok "a fresh install writes defaults even with a stale upgrade folder in place" +fi +cp "$WORK/env.handedited" "$ENV_FILE" + +section "postupgrade on its own is still a backstop" +rm -rf "$UPG" +mkdir -p "$UPG" +SYNOPKG_PKG_STATUS=UPGRADE sh "$ROOT/scripts/preupgrade" >/dev/null 2>&1 +rm -f "$ENV_FILE" +SYNOPKG_PKG_STATUS=UPGRADE sh "$ROOT/scripts/postupgrade" >/dev/null 2>&1 +diff -q "$WORK/env.handedited" "$ENV_FILE" >/dev/null && ok "restored" || bad "postupgrade did not restore the file" + +# ── 4. the desktop application's backend ─────────────────────────────────────── +# The CGI DSM serves at /webman/3rdparty/rescriptum/api.cgi. Two things measured on a DSM +# 7.2.2 machine make this section matter more than it looks: a CGI there runs as **root**, +# and that path is **not authenticated by DSM** — an unauthenticated request reaches the +# script and gets 200. So the checks inside it are the only door, and this is where they +# are proved to be shut. +section "the DSM application's CGI" + +CGI="$ROOT/target/ui/api.cgi" +AUTH_STUB="$WORK/authenticate.cgi" +MY_GROUP=$(id -gn) + +# DSM's own authenticator prints the logged-in user's name, and prints nothing at all when +# there is no session. Both halves are what the real script keys on. +signed_in_as() { + printf '#!/bin/sh\nprintf %%s "%s"\n' "$1" >"$AUTH_STUB" + chmod 755 "$AUTH_STUB" +} + +# One request. Everything a client could actually influence — the method, the query, the +# body, a header — goes in as CGI variables; everything else is the seam the harness needs. +cgi() { # [admin-group] + REQUEST_METHOD="$1" QUERY_STRING="$2" CONTENT_LENGTH="${#3}" \ + HTTP_X_RESCRIPTUM="$4" \ + RESCRIPTUM_PKG_ROOT="$ROOT" RESCRIPTUM_AUTH_CGI="$AUTH_STUB" \ + RESCRIPTUM_ADMIN_GROUP="${5:-$MY_GROUP}" \ + sh "$CGI" </dev/null 2>&1 +out=$(cgi GET "action=config" "" "") +grep -q 'n0tf0rth3br0ws3r' <<<"$out" && bad "a token reached the application" || ok "no token reaches the application" + +# Compared against the whole file rather than one value: an earlier section leaves its own +# settings behind, and "it is still not X" proves nothing when it was never X. +before=$(cat "$ENV_FILE") +out=$(cgi POST "action=save" "RESCRIPTUM_MAX_CONNECTIONS=4096" "") +[ "$(http_status "$out")" = "403" ] && ok "a write without X-Rescriptum is refused" || bad "the CSRF guard let a write through with $(http_status "$out")" +[ "$(cat "$ENV_FILE")" = "$before" ] && ok "and changed nothing" || bad "the refused write was applied anyway" + +out=$(cgi POST "action=save" "RESCRIPTUM_MAX_CONNECTIONS=4096" "1") +[ "$(http_status "$out")" = "200" ] && ok "a write from the application is applied" || bad "the write got $(http_status "$out"): $out" +[ "$(value_of RESCRIPTUM_MAX_CONNECTIONS)" = "4096" ] && ok "and reached the env file" || bad "the env file says $(value_of RESCRIPTUM_MAX_CONNECTIONS)" + +# The refusal that matters: the panel is reached over the service this would stop. +before=$(cat "$ENV_FILE") +out=$(cgi POST "action=save" "RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001" "1") +[ "$(http_status "$out")" = "409" ] && ok "a write that would stop the server starting is refused" || bad "an unauthenticated admin API was accepted with $(http_status "$out")" +[ "$(cat "$ENV_FILE")" = "$before" ] && ok "and the file is untouched" || bad "the refused write still changed the file" + +out=$(cgi GET "action=nonsense" "" "") +[ "$(http_status "$out")" = "400" ] && ok "an unknown action is refused" || bad "an unknown action got $(http_status "$out")" + +out=$(cgi GET "action=status" "" "") +grep -q '^version: rescriptum' <<<"$out" && ok "status reports the version" || bad "status: $out" +# It answers, rather than hanging: the first version of this asked `su` to become the +# service's user, which read the CGI's stdin and waited on it forever. The CGI already +# *is* that user, so a plain test is both possible and correct. +grep -q '^answers_readable: yes' <<<"$out" && ok "and can tell that the answers folder is readable" || bad "status says the answers folder is unreadable: $out" + +# ── 5. uninstall ─────────────────────────────────────────────────────────────── +section "uninstall must leave the answers alone" +unset SYNOPKG_TEMP_UPGRADE_FOLDER +SYNOPKG_PKG_STATUS=UNINSTALL sh "$ROOT/scripts/preuninst" >/dev/null 2>&1 +SYNOPKG_PKG_STATUS=UNINSTALL sh "$ROOT/scripts/postuninst" >/dev/null 2>&1 +[ -f "$SHARE/answers/canary.toml" ] && ok "the share and everything in it survived the uninstall" || bad "the uninstall took the user's answers with it" +[ -d "$SHARE" ] && ok "the shared folder itself is still there" || bad "the shared folder was removed" + +echo +if [ "$fails" -gt 0 ]; then + echo "$pass passed, $fails failed" + exit 1 +fi +echo "$pass checks passed" diff --git a/packaging/dsm/make-spk.sh b/packaging/dsm/make-spk.sh new file mode 100755 index 0000000..c33c627 --- /dev/null +++ b/packaging/dsm/make-spk.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# Assemble a Synology DSM 7 package around an already-built rescriptum binary. +# +# ./packaging/dsm/make-spk.sh x86_64 +# ./packaging/dsm/make-spk.sh armv7 --bin dist/armv7/rescriptum --spk-build 2 +# +# There is nothing to compile here: the binary is statically linked against musl and comes +# out of cargo-zigbuild. An `.spk` is two nested tar archives and a handful of text files, +# which is what the official toolkit's payload ends up being anyway — and doing it this +# way keeps the release job hermetic, reviewable, and free of a per-platform chroot. +# +# The mechanics are easy to get wrong and cheap to write down: +# +# * the outer archive is an *uncompressed* tar. A gzipped one is rejected with "invalid +# file format" and no further detail; +# * --format=ustar explicitly, or bsdtar writes PaxHeader entries and GNU tar writes its +# own format. Neither is worth discovering inside Package Center's error message; +# * COPYFILE_DISABLE=1, or macOS puts ._INFO-style AppleDouble members in the archive; +# * a pre-sorted file list rather than --sort=name, which is GNU-only; +# * fixed ownership and mtimes, and `gzip -n` so the inner tarball carries no timestamp. +# Same inputs and the same tar give a byte-identical .spk, which is what makes the +# published checksum worth something. (GNU tar and bsdtar do not agree with each +# other byte for byte; the release always runs on the same one.) +# * scripts executable, #!/bin/sh, LF endings. A CRLF in a lifecycle script fails with +# an error that names neither the file nor the reason. + +set -euo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +REPO=$(cd "$HERE/../.." && pwd) + +usage() { + sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +# What a build claims, and it is never the platform name. `arch` is a space-separated list +# and accepts *family* names — the guide's own example is arch="x86_64 alpine" — so an +# Intel package covers every Intel platform, including the ones Synology has not shipped +# yet. That is on purpose: the appendix's enumeration is already missing r1000 (DS723+, +# DS923+, DS1522+) and epyc7002, and enumerating it would exclude some of the most common +# models sold today until someone edited a table. +# +# The family shorthand does **not** reach the Marvell ARMv7 platforms: `armv7` as a family +# covers only alpine and alpine4k, so the DS416j's armada38x has to be named. The others +# (armada375, monaco, alpine, alpine4k) join at M6, each once it is confirmed DSM 7-capable +# *and* the binary has been run on it — armada370, armadaxp and comcerto2k never got DSM 7 +# at all, and os_min_ver="7.0-40000" would make claiming them a lie. +abi_arch() { + case "$1" in + x86_64) echo "x86_64" ;; + armv7) echo "armada38x" ;; + aarch64) echo "armv8" ;; + *) return 1 ;; + esac +} + +abi_target() { + case "$1" in + x86_64) echo "x86_64-unknown-linux-musl" ;; + armv7) echo "armv7-unknown-linux-gnueabihf" ;; + aarch64) echo "aarch64-unknown-linux-musl" ;; + *) return 1 ;; + esac +} + +ABI="" +BIN="" +VERSION="" +SPK_BUILD=1 +OUT="$REPO/dist" + +while [ $# -gt 0 ]; do + case "$1" in + -h | --help) usage 0 ;; + --bin) BIN="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --spk-build) SPK_BUILD="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + -*) echo "unknown option: $1" >&2; usage 2 ;; + *) ABI="$1"; shift ;; + esac +done + +[ -n "$ABI" ] || usage 2 +ARCH=$(abi_arch "$ABI") || { echo "unknown ABI: $ABI (x86_64, armv7, aarch64)" >&2; exit 2; } +TARGET=$(abi_target "$ABI") +[ -n "$BIN" ] || BIN="$REPO/target/$TARGET/release/rescriptum" + +if [ ! -f "$BIN" ]; then + echo "no binary at $BIN — build it first (./build.sh $TARGET)" >&2 + [ "$ABI" = armv7 ] && echo "(armv7 is glibc, not musl: Synology's 3.10 kernels break musl's time64 fallback)" >&2 + exit 1 +fi + +[ -n "$VERSION" ] || VERSION=$(grep -m1 '^version = ' "$REPO/Cargo.toml" | cut -d'"' -f2) +VERSION=${VERSION#v} + +# Every segment of an SPK version must be numeric, each within 0…2^31-1. 0.1.0-1 is fine; +# 0.2.0-rc1 is not — a prerelease simply does not produce an .spk, the .tar.gz archives are +# the prerelease channel. The trailing segment is a package build number, for a packaging +# fix that has to ship without a code change. +case "$VERSION" in +*[!0-9.]*) echo "version $VERSION has a non-numeric segment — a prerelease does not produce an .spk" >&2; exit 1 ;; +esac +case "$SPK_BUILD" in +'' | *[!0-9]*) echo "--spk-build must be a number" >&2; exit 2 ;; +esac +FULL_VERSION="$VERSION-$SPK_BUILD" + +STAGE=$(mktemp -d "${TMPDIR:-/tmp}/rescriptum-spk.XXXXXX") +trap 'rm -rf "$STAGE"' EXIT +PAYLOAD="$STAGE/payload" +SPKDIR="$STAGE/spk" +mkdir -p "$PAYLOAD" "$SPKDIR" + +# ── the payload, unpacked into /var/packages/rescriptum/target ────────────────── +mkdir -p "$PAYLOAD/bin" +cp "$BIN" "$PAYLOAD/bin/rescriptum" +chmod 755 "$PAYLOAD/bin/rescriptum" +cp -R "$HERE/payload/." "$PAYLOAD/" +chmod 755 "$PAYLOAD/bin/rescriptum-cli" +# The desktop application's backend. Said explicitly rather than trusted to survive a +# checkout, an archive and a copy: a CGI that arrives without its execute bit is served to +# the browser as source — which would mean publishing this script's contents rather than +# running it. +chmod 755 "$PAYLOAD/ui/api.cgi" + +# **The desktop application's JavaScript is named after the version, and that is a cache +# fix rather than a nicety.** Every file in this payload gets a fixed mtime below, so that +# the same inputs produce a byte-identical `.spk` — and nginx serves that to a browser as +# `Last-Modified: 2019` with no `Cache-Control`. A browser's heuristic freshness is a tenth +# of the file's apparent age, which is years: an upgraded package would go on running the +# *old* application in the administrator's browser, against the new backend, until they +# thought to clear their cache. A URL that changes with the version is what actually fixes +# it. `ui/config` names the file, so the two are substituted together and check-spk.sh +# asserts they still agree. +JSFILE="rescriptum-$FULL_VERSION.js" +mv "$PAYLOAD/ui/rescriptum.js" "$PAYLOAD/ui/$JSFILE" +sed "s|@VERSION@|$FULL_VERSION|g" "$PAYLOAD/ui/$JSFILE" >"$PAYLOAD/ui/$JSFILE.new" && + mv "$PAYLOAD/ui/$JSFILE.new" "$PAYLOAD/ui/$JSFILE" +sed "s|@JSFILE@|$JSFILE|g" "$PAYLOAD/ui/config" >"$PAYLOAD/ui/config.new" && + mv "$PAYLOAD/ui/config.new" "$PAYLOAD/ui/config" + +find "$PAYLOAD" -name '.DS_Store' -delete + +# Kilobytes of unpacked payload. Left unset, extractsize does not mean "unknown": it means +# "the SPK's own byte size", which understates a compressed payload. +EXTRACTSIZE=$(du -sk "$PAYLOAD" | awk '{ print $1 }') + +# ── the outer archive's members ──────────────────────────────────────────────── +sed "s|@VERSION@|$FULL_VERSION|; s|@ARCH@|$ARCH|; s|@EXTRACTSIZE@|$EXTRACTSIZE|" "$HERE/INFO.in" | + grep -v '^#' | grep -v '^$' >"$SPKDIR/INFO" + +cp "$HERE/PACKAGE_ICON.PNG" "$HERE/PACKAGE_ICON_256.PNG" "$SPKDIR/" +cp "$REPO/LICENSE" "$SPKDIR/LICENSE" +cp -R "$HERE/conf" "$HERE/scripts" "$HERE/WIZARD_UIFILES" "$SPKDIR/" +chmod 755 "$SPKDIR"/scripts/* +chmod 644 "$SPKDIR"/conf/* "$SPKDIR"/WIZARD_UIFILES/* "$SPKDIR"/INFO "$SPKDIR"/LICENSE "$SPKDIR"/*.PNG + +# Package Center shows this, and it is the only version history a DSM user ever sees. +{ + echo "$FULL_VERSION" + if git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1; then + prev=$(git -C "$REPO" tag --list 'v*' --sort=-v:refname | grep -vx "v$VERSION" | head -n 1 || true) + if [ -n "$prev" ]; then + git -C "$REPO" log --no-merges --pretty=format:'- %s' "$prev..HEAD" | head -n 50 + echo + else + git -C "$REPO" log --no-merges --pretty=format:'- %s' -n 20 + echo + fi + fi +} >"$SPKDIR/CHANGELOG" + +# ── the two archives ─────────────────────────────────────────────────────────── +export COPYFILE_DISABLE=1 +MTIME=${SPK_MTIME:-202001010000.00} + +flavour=unknown +if tar --version 2>/dev/null | head -n 1 | grep -qi bsdtar; then + flavour=bsd +elif tar --version 2>/dev/null | head -n 1 | grep -qi 'gnu tar'; then + flavour=gnu +fi + +# Directory entries come out before their contents under LC_ALL=C, which is what a tar +# reading the archive back wants. +listing() { (cd "$1" && find . -mindepth 1 | LC_ALL=C sort | sed 's|^\./||'); } + +archive() { # — uncompressed, ustar, owned by 0:0, no recursion + local dir="$1" out="$2" list + list=$(mktemp "${TMPDIR:-/tmp}/spk-list.XXXXXX") + listing "$dir" >"$list" + find "$dir" -exec touch -t "$MTIME" {} + + case "$flavour" in + bsd) tar --format ustar --uid 0 --gid 0 --uname '' --gname '' --numeric-owner -n -C "$dir" -T "$list" -cf "$out" ;; + *) tar --format=ustar --owner=0 --group=0 --numeric-owner --no-recursion -C "$dir" -T "$list" -cf "$out" ;; + esac + rm -f "$list" +} + +archive "$PAYLOAD" "$STAGE/package.tar" +gzip -n -9 -c "$STAGE/package.tar" >"$SPKDIR/package.tgz" +rm -f "$STAGE/package.tar" + +mkdir -p "$OUT" +SPK="$OUT/rescriptum-$FULL_VERSION-$ABI.spk" +archive "$SPKDIR" "$SPK" + +# sha256sum is coreutils and is everywhere on Linux; shasum is a Perl script that a +# minimal image does not have, and macOS has only the second. Ubuntu runners happen to +# carry both, which is exactly how a script like this ships broken. +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" + else + shasum -a 256 "$1" + fi +} +( cd "$OUT" && sha256_of "$(basename "$SPK")" >"$(basename "$SPK").sha256" ) + +printf ' %-34s %8s KB installed arch=%s\n' "$(basename "$SPK")" "$EXTRACTSIZE" "$ARCH" +echo " $SPK" diff --git a/packaging/dsm/payload/bin/rescriptum-cli b/packaging/dsm/payload/bin/rescriptum-cli new file mode 100755 index 0000000..ce7aeed --- /dev/null +++ b/packaging/dsm/payload/bin/rescriptum-cli @@ -0,0 +1,25 @@ +#!/bin/sh +# `rescriptum` for a packaged install, linked into /usr/local/bin by usr-local-linker. +# +# `check` and `render` read their configuration from the environment, and on DSM that +# configuration lives in the package's env file — so a bare `rescriptum check` over SSH +# would validate /srv/answers, find nothing, and report on a directory that has nothing to +# do with this machine. Naming the file is the whole job. +# +# Two details that are not decoration: ${VAR:-default} leaves someone free to point this +# at another env file, and `exec "$@"` preserves the stdout/stderr split that makes +# `rescriptum render … > answer.toml` a contract. +# +# And the diagnostic that actually proves the share's permissions is +# sudo -u rescriptum rescriptum-cli check +# Run as root it succeeds whatever the ACL says, which makes success meaningless. +# +# The package root is the same seam the lifecycle scripts and the application's CGI use, +# with the same real default — it is what lets a harness drive all three against a tree it +# can write to. It is never derived from $0 or $SYNOPKG_PKGDEST: /var/packages/rescriptum/ +# target is a symlink into /volume1/@appstore, so following it lands beside the binary and +# nowhere near etc/. +RESCRIPTUM_PKG_ROOT="${RESCRIPTUM_PKG_ROOT:-/var/packages/rescriptum}" +RESCRIPTUM_ENV_FILE="${RESCRIPTUM_ENV_FILE:-$RESCRIPTUM_PKG_ROOT/etc/rescriptum.env}" +export RESCRIPTUM_ENV_FILE +exec "$RESCRIPTUM_PKG_ROOT/target/bin/rescriptum" "$@" diff --git a/packaging/dsm/payload/logrotate/rescriptum b/packaging/dsm/payload/logrotate/rescriptum new file mode 100644 index 0000000..570da6b --- /dev/null +++ b/packaging/dsm/payload/logrotate/rescriptum @@ -0,0 +1,24 @@ +# DSM rotates a package's log only if the package asks, and the stanza is ours to write — +# which means the mode is ours to get right. `copytruncate` is not a preference here: +# log::init opens the file once, in append mode, and holds the descriptor for the life of +# the process. There is no SIGHUP handler and no reopen, so a plain rotation would move +# the inode out from under the server, which would carry on writing — invisibly — to a +# file with no name. The log is the entire diagnostic story when a PXE install will not +# start. +# +# The cost is the writes that land between the copy and the truncate. For a request log +# that is an acceptable trade; the alternative is the binary carrying a feature for one +# deployment target. +# +# startup.log is where the service's own stdout and stderr go: anything said before +# log::init knows where the log lives — a configuration error, a malformed env file — and +# it is held open for exactly as long, so it rotates the same way. +/var/packages/rescriptum/var/rescriptum.log +/var/packages/rescriptum/var/startup.log { + weekly + rotate 8 + compress + missingok + notifempty + copytruncate +} diff --git a/packaging/dsm/payload/port_conf/rescriptum.sc b/packaging/dsm/payload/port_conf/rescriptum.sc new file mode 100644 index 0000000..775e91c --- /dev/null +++ b/packaging/dsm/payload/port_conf/rescriptum.sc @@ -0,0 +1,5 @@ +[rescriptum] +title="rescriptum" +desc="Unattended-installation answer server" +port_forward="no" +dst.ports="8000/tcp" diff --git a/packaging/dsm/payload/ui/api.cgi b/packaging/dsm/payload/ui/api.cgi new file mode 100755 index 0000000..3d617d2 --- /dev/null +++ b/packaging/dsm/payload/ui/api.cgi @@ -0,0 +1,229 @@ +#!/bin/sh +# The DSM application's backend. +# +# DSM links this package's `ui` directory into /usr/syno/synoman/webman/3rdparty/rescriptum, +# so this script is served by DSM's own web server, on DSM's own port, under DSM's own +# certificate. That is what makes the application possible at all: the desktop is normally +# HTTPS, and a page there cannot call a plain-HTTP port — mixed content is a hard block, not +# a warning. Same origin removes that, and CORS, and any second password. +# +# **Two facts about this path were measured on a DSM 7.2.2 machine, and both are +# load-bearing. Neither is in the developer guide.** +# +# 1. A CGI here runs as **the owner of the script**. The package tree is chowned to the +# package user, so this runs as `rescriptum` — the same identity that owns the 0600 env +# file and the log, which is the whole reason the configuration can be read and written +# while the server itself is stopped. It is *not* root: a script left owned by root +# does run as root here, which is worth knowing and worth never doing. +# 2. This path is **not authenticated by DSM**. An unauthenticated request reaches this +# script and gets 200. DSM protects its own pages, not ours. +# +# Put together: the only thing standing between the open network and this machine's +# provisioning configuration is the check in `authorize` below. It is not a formality, it is +# the door. authenticate.cgi prints the logged-in user's name and prints nothing at all when +# there is no session; `administrators` membership is then checked separately, because +# "logged in" is not "may set the root password of every machine this NAS installs". +# +# Nothing here can start or stop the package — it has no privilege to. Restarting is the +# application's job, through DSM's own SYNO.Core.Package.Control, with the administrator's +# own session: DSM does its own bookkeeping that way, and a process this script started +# would land outside the package's cgroup where DSM could no longer stop it. +# +# The third guard is CSRF. A browser will not send a cross-site request carrying a header it +# invented without a preflight first, and this script answers no preflight — so requiring +# X-Rescriptum on writes means a page on another origin cannot make the browser do this on +# an administrator's behalf. DSM's own SynoToken is honoured too when the desktop supplies +# it, which is what makes the app keep working with DSM's CSRF protection switched on. + +set -u + +PKG="rescriptum" +# The same seams the lifecycle scripts use, and safe for the same reason they are: a CGI's +# environment is the web server's, and everything a *client* can influence arrives named +# HTTP_* — there is no request that can set RESCRIPTUM_PKG_ROOT. They exist so +# lifecycle-test.sh can drive this script against a tree it is allowed to write to, with a +# stub for DSM's authenticator and a group the test user is actually in. Every default +# below is the real one. +ROOT="${RESCRIPTUM_PKG_ROOT:-/var/packages/$PKG}" +CLI="$ROOT/target/bin/$PKG-cli" +VAR="$ROOT/var" +AUTH_CGI="${RESCRIPTUM_AUTH_CGI:-/usr/syno/synoman/webman/modules/authenticate.cgi}" +ADMIN_GROUP="${RESCRIPTUM_ADMIN_GROUP:-administrators}" + +# The log can be long and this is a browser, not a terminal. +MAX_LINES=2000 +DEFAULT_LINES=200 + +reply() { # + echo "Status: $1" + echo "Content-Type: $2" + # This is configuration, and a stale copy of it is a misleading one. + echo "Cache-Control: no-store" + echo "X-Content-Type-Options: nosniff" + echo "" +} + +fail() { # + reply "$1" "text/plain; charset=utf-8" + echo "$2" + exit 0 +} + +# One value out of the query string, by name, without ever building a path or a command +# from it. Percent-decoding is deliberately not done here: every parameter this script +# accepts is matched against a fixed list of literals or checked to be digits. +param() { # + printf '%s' "${QUERY_STRING:-}" | + tr '&' '\n' | + sed -n "s/^$1=//p" | + head -n 1 +} + +# **The door.** Nothing above this line touches the filesystem or runs a command. +authorize() { + user=$("$AUTH_CGI" 2>/dev/null | tr -d '\r\n') + if [ -z "$user" ]; then + fail 403 "Sign in to DSM first." + fi + # A DSM account is not an administrator account. `id -nG` is in /usr/bin, which a + # non-login PATH does have — synogroup is not. + case " $(id -nG "$user" 2>/dev/null) " in + *" $ADMIN_GROUP "*) ;; + *) fail 403 "This application is for DSM administrators." ;; + esac +} + +# A write has to prove it was made by our own page rather than by a page somewhere else +# that happens to be open in the same browser. +require_write_intent() { + [ "${REQUEST_METHOD:-GET}" = "POST" ] || fail 405 "This action is a POST." + [ -n "${HTTP_X_RESCRIPTUM:-}" ] || fail 403 "Missing X-Rescriptum." +} + +# The request body, bounded. An unbounded read here would let anyone who got past the door +# hand the machine a gigabyte to hold in memory. +body() { + len=${CONTENT_LENGTH:-0} + case "$len" in + '' | *[!0-9]*) return 0 ;; + esac + [ "$len" -gt 65536 ] && fail 413 "That is too much configuration." + [ "$len" -eq 0 ] && return 0 + dd bs=1 count="$len" 2>/dev/null +} + +authorize + +case "$(param action)" in +config) + # `config --json` already emits JSON, and emits no token in it. Passing it through + # rather than rebuilding it here means the panel and the command line can never + # disagree about what is configured. + out=$("$CLI" config --json 2>/dev/null) + if [ -z "$out" ]; then + fail 500 "rescriptum-cli produced nothing — is the package installed?" + fi + reply 200 "application/json; charset=utf-8" + echo "$out" + ;; + +save) + require_write_intent + # KEY=VALUE per line. Each line is passed to the CLI **as a single argument**, so a + # value containing a space, a semicolon or a quote is data and never a command. The + # CLI is what decides whether the key is one this program reads and whether the value + # can be represented — those rules live in Rust, where they are tested, rather than + # being written a second time here in shell. + set -- + while IFS= read -r line; do + case "$line" in + '' | '#'*) continue ;; + *=*) set -- "$@" "$line" ;; + *) fail 400 "Expected KEY=VALUE, got: $line" ;; + esac + done <&1); then + # The CLI refuses a write that would leave a server unable to start, and says why. + # That sentence is the most useful thing the panel can show, so it is passed on. + reply 409 "text/plain; charset=utf-8" + echo "$out" + exit 0 + fi + out=$("$CLI" config --json 2>/dev/null) + reply 200 "application/json; charset=utf-8" + echo "$out" + ;; + +status) + # `key: value` lines rather than JSON: everything here is either a literal this script + # chose or a path, and hand-rolling JSON escaping in shell for the sake of a shape is + # how a stray quote becomes a panel that renders nothing. + reply 200 "text/plain; charset=utf-8" + echo "version: $("$CLI" --version 2>/dev/null || echo unknown)" + + # **Not `synopkg status`.** It answers with a page of JSON — and on a machine where the + # service is plainly running it still said "package is stopped, failed to get unit + # status". The package's own start-stop-status is both truthful and a contract: 0 is + # running, 3 is stopped, 1 means it died and left its pidfile behind. + sh "$ROOT/scripts/start-stop-status" status >/dev/null 2>&1 + case $? in + 0) echo "package: running" ;; + 3) echo "package: stopped" ;; + 1) echo "package: crashed" ;; + *) echo "package: unknown" ;; + esac + + # Asked of the filesystem, as the user the service actually runs as. Root can read + # anything, so checking as root would answer a question nobody asked — and the + # permissions of the answers directory are the first thing a packaged install gets + # wrong. + answers=$("$CLI" config --value RESCRIPTUM_ANSWERS_DIR 2>/dev/null) + echo "answers: ${answers:-unknown}" + # No `su` here, and none needed: this script already *is* the user the service runs + # as, so a plain test answers exactly the question that matters. The first version did + # try to su, which cost an afternoon — it hung the request outright (su read the CGI's + # stdin and waited on it forever) and then, once that was fixed, failed with + # "Permission denied", because a non-root process cannot become anybody. + if [ -n "$answers" ] && [ -r "$answers" ] && [ -x "$answers" ]; then + echo "answers_readable: yes" + else + echo "answers_readable: no" + fi + ;; + +check) + # The same command the documentation tells people to run, and the same exit code. + reply 200 "text/plain; charset=utf-8" + out=$("$CLI" check 2>&1) + code=$? + echo "$out" + echo "--- exit $code" + ;; + +log) + lines=$(param lines) + case "$lines" in + '' | *[!0-9]*) lines=$DEFAULT_LINES ;; + esac + [ "$lines" -gt "$MAX_LINES" ] && lines=$MAX_LINES + + # Two files, because which one holds the answer depends on how far the server got: + # anything said before it knows where its log lives goes to startup.log. + reply 200 "text/plain; charset=utf-8" + for f in "$VAR/$PKG.log" "$VAR/startup.log"; do + [ -f "$f" ] || continue + echo "=== $f" + tail -n "$lines" "$f" 2>/dev/null + echo "" + done + ;; + +*) + fail 400 "Unknown action." + ;; +esac diff --git a/packaging/dsm/payload/ui/config b/packaging/dsm/payload/ui/config new file mode 100644 index 0000000..14f3e77 --- /dev/null +++ b/packaging/dsm/payload/ui/config @@ -0,0 +1,21 @@ +{ + "@JSFILE@": { + "SYNO.SDS.App.Rescriptum.AppInstance": { + "type": "app", + "title": "rescriptum", + "desc": "Serves unattended-installation answers, composed per machine.", + "icon": "images/{0}.png", + "allowMultiInstance": false, + "allUsers": false, + "hidden": false, + "appWindow": "SYNO.SDS.App.Rescriptum.AppWindow", + "depend": ["SYNO.SDS.App.Rescriptum.AppWindow"] + }, + "SYNO.SDS.App.Rescriptum.AppWindow": { + "type": "lib", + "title": "rescriptum", + "icon": "images/{0}.png", + "texts": "texts" + } + } +} diff --git a/packaging/dsm/payload/ui/images/128.png b/packaging/dsm/payload/ui/images/128.png new file mode 100644 index 0000000..5dc56fa Binary files /dev/null and b/packaging/dsm/payload/ui/images/128.png differ diff --git a/packaging/dsm/payload/ui/images/16.png b/packaging/dsm/payload/ui/images/16.png new file mode 100644 index 0000000..d35bd78 Binary files /dev/null and b/packaging/dsm/payload/ui/images/16.png differ diff --git a/packaging/dsm/payload/ui/images/256.png b/packaging/dsm/payload/ui/images/256.png new file mode 100644 index 0000000..eb37c63 Binary files /dev/null and b/packaging/dsm/payload/ui/images/256.png differ diff --git a/packaging/dsm/payload/ui/images/32.png b/packaging/dsm/payload/ui/images/32.png new file mode 100644 index 0000000..ad608f9 Binary files /dev/null and b/packaging/dsm/payload/ui/images/32.png differ diff --git a/packaging/dsm/payload/ui/images/64.png b/packaging/dsm/payload/ui/images/64.png new file mode 100644 index 0000000..3bfde28 Binary files /dev/null and b/packaging/dsm/payload/ui/images/64.png differ diff --git a/packaging/dsm/payload/ui/rescriptum.js b/packaging/dsm/payload/ui/rescriptum.js new file mode 100644 index 0000000..cdcbe0a --- /dev/null +++ b/packaging/dsm/payload/ui/rescriptum.js @@ -0,0 +1,516 @@ +/* + * rescriptum — the DSM desktop application. + * + * DSM's own UI framework, not a page of ours in a frame. `SYNO.SDS.AppInstance`, + * `SYNO.SDS.AppWindow` and the `syno_*` widgets are what the desktop provides, so the + * window is a real DSM window, in the DSM theme, in the DSM language. + * + * **ExtJS rather than Vue, and that is the machine's doing, not a preference.** DSM 7.2 + * ships a Vue framework and Synology's current guide documents only that one — but the + * DS416j this project exists for is capped at DSM 7.1.1, where `Vue` is simply undefined. + * ExtJS is on both (measured, 7.1.1 and 7.2.2), so one application covers every DSM this + * package supports rather than two covering one each. + * + * The API is documented, in the ExtJS reference Synology generated for DSM — mirrored at + * https://github.com/DigitalBox98/SimpleExtJSApp as `docs/synoextjsdocs.tar.gz`, with a + * worked "Basic application how to". + * + * **That guide's own example does not run.** It declares classes with `Ext.define` and + * chains with `callParent`, and against `SYNO.SDS.AppInstance` on DSM 7.2.2 that throws + * `Cannot read properties of null (reading 'apply')` before the window ever appears: this + * is **ExtJS 3.4.1** (`Ext.version` says so), the SYNO classes are built with `Ext.extend`, + * and the `Ext.define` shim cannot find a parent constructor to chain to. `Ext.extend` + * plus `superclass.constructor.call` is what works, and it is what DSM's own code uses. + * + * Written by hand and shipped as it is read — no bundler, nothing from `node_modules` in + * the release. Everything it shows comes from `api.cgi`, which is where the authentication + * is, and the field list comes from `rescriptum config --json`: adding a variable to the + * server does not mean editing this file. + */ +Ext.ns('SYNO.SDS.App.Rescriptum'); + +(function () { + 'use strict'; + + /* Substituted by make-spk.sh, and it is not cosmetic — see the cache note below. */ + var VERSION = '@VERSION@'; + var BASE = '/webman/3rdparty/rescriptum/'; + var API = BASE + 'api.cgi'; + + /* The two variables with a fixed set of answers. Everything else is free text, which + * is what the server itself says about them — a menu here is a nicety, not a rule, and + * a variable this file has never heard of still gets a field. */ + var CHOICES = { + RESCRIPTUM_STORE: ['files', 'sqlite'], + RESCRIPTUM_LOG: ['all', 'problems', 'off'] + }; + + /* **DSM does not load a hand-written package's texts, and this is where that was + * found.** `ui/config` declares `"texts": "texts"`, the files are served — a request + * for `texts/fre/strings` answers 200 — and DSM's own `_T('ui', 'settings')` still + * comes back empty. Presumably the desktop loads them for packages built with + * Synology's toolchain, which registers rather more than a config file. + * + * So the application loads them itself. What stays Synology's is everything that + * matters: the file format, the `[section]` layout, and the locale directory names, + * which come straight from `_S('lang')` — `fre` on a French DSM. Nothing here is a + * string table in JavaScript; a translation is still a file. + * + * The same goes for `ui/config`, whose `title` and `desc` are literals rather than + * `section:key` references: an unresolved reference shows up as the literal text + * `app:description` under the icon. */ + function parseStrings(text) { + var strings = {}; + var section = ''; + Ext.each(String(text || '').split('\n'), function (raw) { + var line = raw.replace(/^\s+|\s+$/g, ''); + if (!line || line.charAt(0) === '#') { return; } + var header = /^\[(.+)\]$/.exec(line); + if (header) { section = header[1]; return; } + var at = line.indexOf('='); + if (at < 0) { return; } + var key = line.slice(0, at).replace(/^\s+|\s+$/g, ''); + var value = line.slice(at + 1).replace(/^\s+|\s+$/g, '').replace(/^"([\s\S]*)"$/, '$1'); + strings[section + ':' + key] = value; + }); + return strings; + } + + /* `Ext.define` for the declaration — DSM's launcher finds the class that way, and it + * sets `superclass` correctly — but **never `callParent`**, which is where the guide's + * example falls over. See the note at the top. */ + Ext.define('SYNO.SDS.App.Rescriptum.AppInstance', { + extend: 'SYNO.SDS.AppInstance', + appWindowName: 'SYNO.SDS.App.Rescriptum.AppWindow' + }); + + Ext.define('SYNO.SDS.App.Rescriptum.AppWindow', { + extend: 'SYNO.SDS.AppWindow', + + constructor: function (config) { + var self = this; + + this.strings = {}; + this.settings = []; + this.writable = true; + + /* Components are built and kept, rather than looked up by itemId afterwards. + * `getComponent` and card layouts disagree about what an itemId is often + * enough that holding the reference is simply cheaper than being right. */ + this.settingsPanel = new SYNO.ux.FormPanel({ + border: false, + autoScroll: true, + labelWidth: 220, + bodyStyle: 'padding: 12px 16px', + items: [] + }); + /* **A form panel, not a plain one.** `syno_displayfield`'s `fieldLabel` is + * drawn by the *form* layout; in a plain `Ext.Panel` the labels silently do + * not render and the status page comes out as a bare column of values with + * nothing saying what they are. Found on the DS416j, where the settings tab + * looked right — it was a form already — and this one did not. */ + this.statusPanel = new SYNO.ux.FormPanel({ + border: false, + autoScroll: true, + labelWidth: 220, + bodyStyle: 'padding: 12px 16px', + items: [] + }); + this.logPanel = new Ext.Panel({ + border: false, + autoScroll: true, + bodyStyle: 'padding: 12px 16px', + html: '' + }); + + this.tabButtons = { + settings: new SYNO.ux.Button({ text: 'Settings', toggleGroup: 'rescriptum-tabs', allowDepress: false, pressed: true, handler: function () { self.showView('settings'); } }), + status: new SYNO.ux.Button({ text: 'Status', toggleGroup: 'rescriptum-tabs', allowDepress: false, handler: function () { self.showView('status'); } }), + log: new SYNO.ux.Button({ text: 'Log', toggleGroup: 'rescriptum-tabs', allowDepress: false, handler: function () { self.showView('log'); } }) + }; + this.saveButton = new SYNO.ux.Button({ text: 'Save', handler: function () { self.save(); } }); + this.reloadButton = new SYNO.ux.Button({ text: 'Reload', handler: function () { self.reload(); } }); + this.closeButton = new SYNO.ux.Button({ text: 'Close', handler: function () { self.close(); } }); + + /* **The stack of views is a panel inside the window, not the window's own + * layout.** `SYNO.SDS.AppWindow` arranges its own chrome, and overriding + * `layout` on it gives a window that opens, gets a taskbar entry and a preview + * — and renders empty. One `fit` child that owns the card layout leaves the + * window's own arrangement alone. */ + this.deck = new Ext.Panel({ + border: false, + layout: 'card', + activeItem: 0, + items: [this.settingsPanel, this.statusPanel, this.logPanel] + }); + + config = Ext.apply({ + /* **DSM's taskbar calls `getWindowTitle()` on the window.** Without a + * title it throws `t.button.getWindowTitle is not a function` from inside + * the taskbar bundle, and the app then fails to open at all — with the + * error pointing at DSM's code rather than at ours. */ + title: 'rescriptum', + width: 880, + height: 660, + resizable: true, + maximizable: true, + minimizable: true, + layout: 'fit', + tbar: [this.tabButtons.settings, this.tabButtons.status, this.tabButtons.log], + items: [this.deck], + buttons: [this.saveButton, this.reloadButton, this.closeButton] + }, config); + + SYNO.SDS.App.Rescriptum.AppWindow.superclass.constructor.call(this, config); + + /* Strings first, so nothing is ever painted with a raw key in it, then the + * configuration the form is made of. */ + this.loadStrings(function () { self.loadConfig(); }); + }, + + /* Belt to the `title` config's braces: the taskbar wants this method, and a + * window that has been retitled at runtime should still answer. */ + getWindowTitle: function () { + return this.title || 'rescriptum'; + }, + + // ---- text ------------------------------------------------------------ + + t: function (key) { return this.strings['ui:' + key] || key; }, + label: function (key) { return this.strings['label:' + key] || key; }, + help: function (key) { return this.strings['help:' + key] || ''; }, + + loadStrings: function (done) { + var self = this; + var lang = 'enu'; + try { lang = (window._S && _S('lang')) || 'enu'; } catch (e) { /* enu it is */ } + + var read = function (which, then) { + Ext.Ajax.request({ + /* The version is on the URL for the same reason it is in this file's + * name: every packaged file has a fixed mtime so builds are + * reproducible, nginx serves that as `Last-Modified: 2019`, and a + * browser's heuristic freshness is then years. */ + url: BASE + 'texts/' + which + '/strings?v=' + encodeURIComponent(VERSION), + method: 'GET', + success: function (response) { then(parseStrings(response.responseText)); }, + failure: function () { then({}); } + }); + }; + + /* English underneath, always: a key translated in one file and not the other + * falls back to a sentence rather than to a blank label. */ + read('enu', function (english) { + if (lang === 'enu') { + self.strings = english; + self.relabel(); + done(); + return; + } + read(lang, function (translated) { + Ext.iterate(translated, function (k, v) { if (v) { english[k] = v; } }); + self.strings = english; + self.relabel(); + done(); + }); + }); + }, + + /* The chrome exists before the strings arrive, so it is labelled once they do. */ + relabel: function () { + this.tabButtons.settings.setText(this.t('settings')); + this.tabButtons.status.setText(this.t('status')); + this.tabButtons.log.setText(this.t('log')); + this.saveButton.setText(this.t('save')); + this.reloadButton.setText(this.t('reload')); + this.closeButton.setText(this.t('close')); + }, + + // ---- talking to api.cgi ---------------------------------------------- + + /* A write must prove it was made by our own application rather than by a page + * somewhere else that happens to be open in the same browser: a browser will not + * send an invented header cross-origin without a preflight first, and `api.cgi` + * answers no preflight. DSM's own SynoToken goes along too, which is what keeps + * this working with DSM's cross-site request forgery protection turned on. */ + call: function (action, options) { + options = options || {}; + var self = this; + var headers = { 'X-Rescriptum': '1' }; + try { + var token = window._S && _S('SynoToken'); + if (token) { headers['X-SYNO-TOKEN'] = token; } + } catch (e) { /* api.cgi has its own guard */ } + + var request = { + url: API + '?action=' + encodeURIComponent(action) + (options.query || ''), + method: options.body === undefined ? 'GET' : 'POST', + headers: headers, + timeout: 60000, + success: function (response) { options.success(response.responseText); }, + failure: function (response) { + /* api.cgi answers a refused write with the server's own sentence — + * "this would leave a server that cannot start …" — which is the most + * useful thing this window can put in front of somebody. */ + var text = ((response && response.responseText) || '').replace(/^\s+|\s+$/g, ''); + self.banner(text || ('HTTP ' + (response && response.status))); + } + }; + /* `jsonData` given a string is how this Ext sends a body verbatim; `params` + * would form-encode it, and the body here is KEY=VALUE lines that api.cgi + * hands to the CLI one argument at a time. The Content-Type it sets is not + * CORS-safelisted either, which only helps. */ + if (options.body !== undefined) { request.jsonData = options.body; } + + Ext.Ajax.request(request); + }, + + // ---- settings --------------------------------------------------------- + + loadConfig: function () { + var self = this; + this.call('config', { + success: function (text) { self.applyConfig(Ext.decode(text)); } + }); + }, + + applyConfig: function (payload) { + var self = this; + var form = this.settingsPanel; + + this.settings = payload.settings || []; + this.envFile = payload.env_file || ''; + this.writable = payload.writable !== false; + + form.removeAll(true); + + this.bannerField = new SYNO.ux.DisplayField({ hidden: true, hideLabel: true, htmlEncode: true, cls: 'rescriptum-banner' }); + this.restartButton = new SYNO.ux.Button({ hidden: true, text: this.t('restart'), handler: function () { self.restart(); } }); + form.add(this.bannerField); + form.add(this.restartButton); + + form.add(new SYNO.ux.DisplayField({ + hideLabel: true, htmlEncode: true, cls: 'rescriptum-envfile', + value: this.t('env_file') + ' ' + (this.envFile || this.t('env_file_none')) + })); + + if (!this.writable) { + form.add(new SYNO.ux.DisplayField({ hideLabel: true, htmlEncode: true, cls: 'rescriptum-banner', value: this.t('read_only') })); + } + if (payload.starts === false && payload.error) { + form.add(new SYNO.ux.DisplayField({ hideLabel: true, htmlEncode: true, cls: 'rescriptum-banner', value: this.t('would_not_start') + ' ' + payload.error })); + } + Ext.each(payload.warnings || [], function (warning) { + form.add(new SYNO.ux.DisplayField({ hideLabel: true, htmlEncode: true, cls: 'rescriptum-banner', value: warning })); + }); + + this.fields = {}; + Ext.each(this.settings, function (setting) { + var field = self.fieldFor(setting); + self.fields[setting.key] = field; + form.add(field); + + var help = self.help(setting.key) || setting.help; + if (help) { + form.add(new SYNO.ux.DisplayField({ hideLabel: true, htmlEncode: true, cls: 'rescriptum-help', value: help })); + } + if (setting.source === 'environment') { + form.add(new SYNO.ux.DisplayField({ hideLabel: true, htmlEncode: true, cls: 'rescriptum-help', value: self.t('from_environment') })); + } + }); + + form.doLayout(); + this.saveButton.setDisabled(!this.writable); + }, + + fieldFor: function (setting) { + /* A value the environment sets cannot be changed by editing the file, so the + * field is shown and disabled rather than hidden: pretending the file is the + * whole story is how somebody edits a value for an hour and then wonders why + * the server ignores it. */ + var common = { + name: setting.key, + fieldLabel: this.label(setting.key), + disabled: setting.source === 'environment', + width: 340 + }; + + if (CHOICES[setting.key]) { + var rows = []; + Ext.each(CHOICES[setting.key], function (value) { rows.push([value]); }); + return new SYNO.ux.ComboBox(Ext.apply({ + mode: 'local', + triggerAction: 'all', + editable: false, + forceSelection: true, + valueField: 'value', + displayField: 'value', + store: new Ext.data.ArrayStore({ fields: ['value'], data: rows }), + value: setting.value || '' + }, common)); + } + + /* A secret is never sent down, so its box is always empty — and an empty box + * therefore means "leave it alone", never "clear it". Clearing a token is + * deliberate enough to be worth a shell. */ + if (setting.secret) { + return new SYNO.ux.TextField(Ext.apply({ + inputType: 'password', + value: '', + emptyText: setting.set ? this.t('secret_set') : this.t('secret_unset') + }, common)); + } + + return new SYNO.ux.TextField(Ext.apply({ + value: setting.value === null ? '' : setting.value, + emptyText: setting['default'] || '' + }, common)); + }, + + save: function () { + var self = this; + var lines = []; + + Ext.each(this.settings, function (setting) { + if (setting.source === 'environment') { return; } + var field = self.fields[setting.key]; + if (!field) { return; } + var now = field.getValue(); + now = (now === undefined || now === null) ? '' : String(now); + + if (setting.secret) { + if (now) { lines.push(setting.key + '=' + now); } + return; + } + var was = setting.value === null ? '' : String(setting.value); + if (now !== was) { lines.push(setting.key + '=' + now); } + }); + + if (!lines.length) { + this.banner(this.t('nothing_changed')); + return; + } + + this.call('save', { + body: lines.join('\n') + '\n', + success: function (text) { + self.applyConfig(Ext.decode(text)); + self.banner(self.t('restart_needed') + ' ' + self.t('restart_closes')); + self.restartButton.show(); + self.settingsPanel.doLayout(); + } + }); + }, + + banner: function (message) { + if (!this.bannerField) { return; } + this.bannerField.setValue(message); + this.bannerField.show(); + this.settingsPanel.doLayout(); + }, + + /* Restarting is DSM's job, not the package's. `api.cgi` runs as the package user + * and has no privilege to start or stop anything — and a process it started would + * land outside the package's cgroup, where DSM could no longer stop it. + * SYNO.Core.Package.Control does it properly, with the signed-in administrator's + * own session, and `getBaseURL` is what puts DSM's own credentials on the + * request. */ + restart: function () { + var self = this; + var step = function (method, then) { + Ext.Ajax.request({ + url: self.getBaseURL({ api: 'SYNO.Core.Package.Control', method: method, version: 1 }), + method: 'POST', + params: { id: 'rescriptum' }, + timeout: 120000, + success: function (response) { + var payload = Ext.decode(response.responseText, true); + if (!payload || payload.success !== true) { + self.banner('DSM refused to ' + method + ' the package.'); + return; + } + then(); + }, + failure: function () { self.banner('DSM refused to ' + method + ' the package.'); } + }); + }; + step('stop', function () { + step('start', function () { + self.banner(self.t('restarted')); + self.loadStatus(); + }); + }); + }, + + // ---- status and log --------------------------------------------------- + + loadStatus: function () { + var self = this; + var panel = this.statusPanel; + this.call('status', { + success: function (text) { + panel.removeAll(true); + /* `key: value` lines from a shell script, given their labels here. + * Both halves are looked up and both fall back to what was sent, so a + * line the CGI grows before this file hears about it still shows — + * untranslated rather than missing. */ + Ext.each(String(text).split('\n'), function (line) { + if (!line) { return; } + var at = line.indexOf(': '); + var name = at < 0 ? line : line.slice(0, at); + var value = at < 0 ? '' : line.slice(at + 2); + panel.add(new SYNO.ux.DisplayField({ + htmlEncode: true, + fieldLabel: self.strings['status:' + name] || name, + value: self.strings['value:' + value] || value + })); + }); + panel.doLayout(); + + self.call('check', { + success: function (report) { + panel.add(new Ext.Panel({ + border: false, + html: '
' + Ext.util.Format.htmlEncode(report) + '
' + })); + panel.doLayout(); + } + }); + } + }); + }, + + loadLog: function () { + var self = this; + this.call('log', { + query: '&lines=200', + success: function (text) { + self.logPanel.update('
' + Ext.util.Format.htmlEncode(text) + '
'); + } + }); + }, + + // ---- the three views --------------------------------------------------- + + /* **Not `show`.** `Ext.Window.prototype.show()` is what DSM calls to display the + * window, and defining a method of that name here silently overrode it: the window + * was built, laid out and even rendered its taskbar preview, and then never + * appeared — because "showing" it ran this tab-switcher instead. Nothing threw, on + * either DSM version. Anything added to this prototype shares a namespace with + * every method of `Ext.Window`, and that is a large namespace. */ + showView: function (which) { + var panel = which === 'status' ? this.statusPanel : (which === 'log' ? this.logPanel : this.settingsPanel); + this.deck.getLayout().setActiveItem(panel); + this.active = which; + this.saveButton.setDisabled(which !== 'settings' || !this.writable); + if (which === 'status') { this.loadStatus(); } + if (which === 'log') { this.loadLog(); } + }, + + reload: function () { + if (this.active === 'status') { this.loadStatus(); return; } + if (this.active === 'log') { this.loadLog(); return; } + this.loadConfig(); + } + }); +})(); diff --git a/packaging/dsm/payload/ui/style.css b/packaging/dsm/payload/ui/style.css new file mode 100644 index 0000000..b6cfabc --- /dev/null +++ b/packaging/dsm/payload/ui/style.css @@ -0,0 +1,51 @@ +/* + * Layout only. + * + * ExtJS already paints everything in the DSM theme — the window, the fields, the buttons — + * so there is very little for this file to do, and that is the point of using the DSM + * framework rather than a page of our own. What is left is the spacing around the two + * kinds of text ExtJS has no opinion about: an explanatory line under a field, and a block + * of log output. + * + * **Nothing here names a colour.** DSM ships a light theme and a dark one and switches + * between them without telling a package, so the banner tints `currentColor` — whatever + * the theme has already decided text should be. A palette of our own would look right in + * exactly one of the two. + */ +.rescriptum-banner { + display: block; + margin: 0 0 10px; + padding: 8px 10px; + border-radius: 4px; + border-left: 3px solid currentColor; + background: rgba(127, 127, 127, 0.12); +} + +.rescriptum-envfile { + display: block; + padding-bottom: 12px; + opacity: 0.7; + word-break: break-all; +} + +/* Sits under the field it explains, indented past the label column. */ +.rescriptum-help { + display: block; + padding: 0 0 12px 224px; + font-size: 12px; + opacity: 0.65; +} + +/* A log is fixed-width text and stays fixed-width: wrapping it hides the column that says + * which machine asked. */ +.rescriptum-pre { + margin: 12px 0 0; + padding: 10px; + overflow: auto; + font-family: Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.45; + white-space: pre; + border-radius: 4px; + background: rgba(127, 127, 127, 0.10); +} diff --git a/packaging/dsm/payload/ui/texts/enu/strings b/packaging/dsm/payload/ui/texts/enu/strings new file mode 100644 index 0000000..4ee457a --- /dev/null +++ b/packaging/dsm/payload/ui/texts/enu/strings @@ -0,0 +1,76 @@ +# DSM's own internationalisation. The desktop picks the directory matching the user's +# language and hands these to `$i18n(section, key)`; there is no string table inside the +# JavaScript, so a translation is a file rather than a code change. +# +# The English here is the source and the French beside it is the translation, which is the +# same rule the documentation follows. Note the variable labels and help are translated +# *here* rather than taken from the server's own `--json` output: the server's strings are +# English by project rule, and a half-French window would be worse than either. + +[app] +description = "Serve unattended-installation answers, composed per machine." + +[ui] +settings = "Settings" +status = "Status" +log = "Log" +save = "Save" +reload = "Reload" +close = "Close" +restart = "Restart now" +restart_needed = "Saved. The server reads its configuration once, at startup, so these take effect when the package restarts." +restarted = "The package was restarted." +restart_closes = "DSM closes this window while the package stops. Open it again to see the new state." +saved = "Saved." +nothing_changed = "Nothing was changed." +would_not_start = "The server would not start:" +read_only = "This configuration file cannot be written, so nothing here can be saved." +env_file = "Configuration file:" +env_file_none = "none" +from_environment = "Set in the environment, which overrides this file — editing it here would change nothing." +secret_set = "unchanged — type to replace it" +secret_unset = "not set" + +[label] +RESCRIPTUM_STORE = "Answer store" +RESCRIPTUM_ANSWERS_DIR = "Answers folder" +RESCRIPTUM_DB_PATH = "Database file" +RESCRIPTUM_LISTEN_ADDR = "Listen address" +RESCRIPTUM_WORKERS = "Worker threads" +RESCRIPTUM_MAX_CONNECTIONS = "Maximum connections" +RESCRIPTUM_TIMEOUT_SECS = "Timeout (seconds)" +RESCRIPTUM_LOG = "Log level" +RESCRIPTUM_LOG_FILE = "Log file" +RESCRIPTUM_CAPTURE_DIR = "Capture folder" +RESCRIPTUM_ANSWER_TOKEN = "Installer token" +RESCRIPTUM_ADMIN_ADDR = "Write API address" +RESCRIPTUM_ADMIN_TOKEN = "Write API token" + +[help] +RESCRIPTUM_STORE = "Where answers come from: a folder of documents, or a database." +RESCRIPTUM_ANSWERS_DIR = "The folder of answer documents, when the store is files." +RESCRIPTUM_DB_PATH = "The SQLite database, when the store is sqlite." +RESCRIPTUM_LISTEN_ADDR = "Where installers reach the answer endpoint." +RESCRIPTUM_WORKERS = "Runtime threads. Not a connection limit; the default is the CPU count." +RESCRIPTUM_MAX_CONNECTIONS = "Connections in flight before a burst is refused rather than queued." +RESCRIPTUM_TIMEOUT_SECS = "How long a slow client may hold a connection." +RESCRIPTUM_LOG = "all keeps every request; problems drops the ones that worked." +RESCRIPTUM_LOG_FILE = "A file to append to, or stdout or stderr." +RESCRIPTUM_CAPTURE_DIR = "Record what installers actually send, for when nothing is answered." +RESCRIPTUM_ANSWER_TOKEN = "Required of installers that have one to offer. Off by default." +RESCRIPTUM_ADMIN_ADDR = "The write API's own listener. Keep it on loopback and reach it over SSH." +RESCRIPTUM_ADMIN_TOKEN = "At least 16 characters, and required whenever the write API is on." + +[status] +version = "Version" +package = "Package" +answers = "Answers folder" +answers_readable = "Readable by the service" + +[value] +running = "running" +stopped = "stopped" +crashed = "crashed, and it left its pidfile behind" +unknown = "unknown" +yes = "yes" +no = "no" diff --git a/packaging/dsm/payload/ui/texts/fre/strings b/packaging/dsm/payload/ui/texts/fre/strings new file mode 100644 index 0000000..3838dbd --- /dev/null +++ b/packaging/dsm/payload/ui/texts/fre/strings @@ -0,0 +1,70 @@ +# La traduction française des chaînes de « enu/strings ». L'anglais est la source : une +# clé ajoutée là-bas et absente ici s'affiche vide, alors que l'inverse ne se voit pas. + +[app] +description = "Sert à chaque machine sa réponse d'installation, composée pour elle." + +[ui] +settings = "Réglages" +status = "État" +log = "Journal" +save = "Enregistrer" +reload = "Recharger" +close = "Fermer" +restart = "Redémarrer maintenant" +restart_needed = "Enregistré. Le serveur lit sa configuration une seule fois, au démarrage : ces valeurs prendront effet au redémarrage du paquet." +restarted = "Le paquet a été redémarré." +restart_closes = "DSM ferme cette fenêtre pendant l'arrêt du paquet. Rouvrez-la pour voir le nouvel état." +saved = "Enregistré." +nothing_changed = "Aucune modification." +would_not_start = "Le serveur ne démarrerait pas :" +read_only = "Ce fichier de configuration n'est pas accessible en écriture : rien ne peut être enregistré ici." +env_file = "Fichier de configuration :" +env_file_none = "aucun" +from_environment = "Défini dans l'environnement, qui l'emporte sur ce fichier — le modifier ici ne changerait rien." +secret_set = "inchangé — saisir pour le remplacer" +secret_unset = "non défini" + +[label] +RESCRIPTUM_STORE = "Source des réponses" +RESCRIPTUM_ANSWERS_DIR = "Dossier des réponses" +RESCRIPTUM_DB_PATH = "Fichier de base de données" +RESCRIPTUM_LISTEN_ADDR = "Adresse d'écoute" +RESCRIPTUM_WORKERS = "Fils d'exécution" +RESCRIPTUM_MAX_CONNECTIONS = "Connexions maximales" +RESCRIPTUM_TIMEOUT_SECS = "Délai d'attente (secondes)" +RESCRIPTUM_LOG = "Niveau de journalisation" +RESCRIPTUM_LOG_FILE = "Fichier de journal" +RESCRIPTUM_CAPTURE_DIR = "Dossier de capture" +RESCRIPTUM_ANSWER_TOKEN = "Jeton des installateurs" +RESCRIPTUM_ADMIN_ADDR = "Adresse de l'API d'écriture" +RESCRIPTUM_ADMIN_TOKEN = "Jeton de l'API d'écriture" + +[help] +RESCRIPTUM_STORE = "D'où viennent les réponses : un dossier de documents, ou une base de données." +RESCRIPTUM_ANSWERS_DIR = "Le dossier des documents de réponse, quand la source est « files »." +RESCRIPTUM_DB_PATH = "La base SQLite, quand la source est « sqlite »." +RESCRIPTUM_LISTEN_ADDR = "Là où les installateurs joignent le point d'accès des réponses." +RESCRIPTUM_WORKERS = "Fils du runtime. Ce n'est pas une limite de connexions ; par défaut, le nombre de cœurs." +RESCRIPTUM_MAX_CONNECTIONS = "Connexions en cours avant qu'une rafale soit refusée plutôt que mise en file." +RESCRIPTUM_TIMEOUT_SECS = "Combien de temps un client lent peut retenir une connexion." +RESCRIPTUM_LOG = "« all » garde chaque requête ; « problems » écarte celles qui ont fonctionné." +RESCRIPTUM_LOG_FILE = "Un fichier où écrire à la suite, ou « stdout » ou « stderr »." +RESCRIPTUM_CAPTURE_DIR = "Enregistre ce que les installateurs envoient vraiment, pour quand rien n'est servi." +RESCRIPTUM_ANSWER_TOKEN = "Exigé des installateurs qui en présentent un. Désactivé par défaut." +RESCRIPTUM_ADMIN_ADDR = "L'écouteur propre à l'API d'écriture. À garder en loopback, joignable par SSH." +RESCRIPTUM_ADMIN_TOKEN = "Au moins 16 caractères, et obligatoire dès que l'API d'écriture est active." + +[status] +version = "Version" +package = "Paquet" +answers = "Dossier des réponses" +answers_readable = "Lisible par le service" + +[value] +running = "en cours d'exécution" +stopped = "arrêté" +crashed = "planté, en laissant son fichier de pid" +unknown = "inconnu" +yes = "oui" +no = "non" diff --git a/packaging/dsm/scripts/postinst b/packaging/dsm/scripts/postinst new file mode 100755 index 0000000..31776f7 --- /dev/null +++ b/packaging/dsm/scripts/postinst @@ -0,0 +1,159 @@ +#!/bin/sh +# rescriptum — after installation, and after every upgrade. +# +# This is the most dangerous script in the package. `postinst` runs on an *upgrade* as +# well as on an install (it is step 8 of the documented upgrade sequence), so a version +# that wrote the env file unconditionally would replace the user's port, tokens and store +# choice with defaults on every upgrade — and restart the service straight afterwards, so +# the first symptom would be a fleet installing against the wrong answers. +# +# Hence: the live env file is written **only when it is absent**. The guard is the file, +# not SYNOPKG_PKG_STATUS, because "absent" is the condition that actually matters — a +# reinstall over a preserved etc/ is neither an install nor an upgrade from the file's +# point of view. preupgrade/postupgrade carry the file across as well; both, deliberately. +# +# Everything here runs as the package user, so the file it writes is owned by rescriptum +# and a 0600 file the service can read is the natural outcome rather than something to +# arrange. + +# The pkgwizard_* variables below are set by DSM from the wizard's component keys, so +# nothing in this file assigns them and every one is read with a default. +# shellcheck disable=SC2154 + +set -u + +PKG="rescriptum" +# **ROOT is not derived from SYNOPKG_PKGDEST.** On a real DSM that variable resolves to +# /volume1/@appstore/ — /var/packages//target is only a symlink to it — so +# `dirname "$SYNOPKG_PKGDEST"` is /volume1/@appstore, and everything hung off it (etc/, var/, +# shares/) lands somewhere nobody reads. The package root is a fixed path; only DEST is not. +ROOT="${RESCRIPTUM_PKG_ROOT:-/var/packages/${SYNOPKG_PKGNAME:-$PKG}}" +DEST="${SYNOPKG_PKGDEST:-$ROOT/target}" +ETC="$ROOT/etc" +VAR="${SYNOPKG_PKGVAR:-$ROOT/var}" + +ENV_FILE="$ETC/$PKG.env" +EXAMPLE="$DEST/etc/$PKG.env.example" +SC_FILE="$DEST/port_conf/$PKG.sc" + +SHARE_ANSWERS="$ROOT/shares/$PKG/answers" +DEFAULT_PORT=8000 + +say() { echo "$PKG: $*"; } + +# Wizard values arrive as environment variables named after the component keys — and every +# one of them has to be read as "possibly absent, with a default". silent_install exists, +# and a reinstall may present no wizard at all; a package that only works when someone +# answered one is a package that breaks the first time DSM installs it without one. +port="${pkgwizard_port:-$DEFAULT_PORT}" +case "$port" in +'' | *[!0-9]*) port="$DEFAULT_PORT" ;; +esac +if [ "$port" -lt 1024 ] || [ "$port" -gt 65535 ]; then + say "port $port is not usable by an unprivileged package — using $DEFAULT_PORT" + port="$DEFAULT_PORT" +fi + +answers="$SHARE_ANSWERS" +if [ "${pkgwizard_answers_custom:-false}" = "true" ] && [ -n "${pkgwizard_answers_path:-}" ]; then + answers="$pkgwizard_answers_path" + # A promise the package cannot keep, said out loud rather than discovered later: the + # scripts run unprivileged, so this cannot be chowned or given an ACL from here. + say "answers set to $answers — grant the '$PKG' user read access to it yourself" +fi + +# The file, in full, for both the live copy and the example. Every path is explicit: +# RESCRIPTUM_ANSWERS_DIR defaults to /srv/answers and RESCRIPTUM_DB_PATH to +# /srv/answers.db, neither of which exists on DSM nor could be created by this user. +env_body() { + cat <"$EXAMPLE" + +SAVED="${SYNOPKG_TEMP_UPGRADE_FOLDER:-}/etc/$PKG.env" +if [ -f "$ENV_FILE" ]; then + say "keeping the existing $ENV_FILE" +elif [ "${SYNOPKG_PKG_STATUS:-}" = "UPGRADE" ] && [ -n "${SYNOPKG_TEMP_UPGRADE_FOLDER:-}" ] && [ -f "$SAVED" ]; then + # This script runs *before* postupgrade, so "absent" is not the same question as + # "never existed": on an upgrade where etc/ did not survive, preupgrade's copy is the + # user's configuration and writing defaults here would destroy it before postupgrade + # ever got the chance to put it back. Checked by simulating exactly that. + # + # **The status guard is not decoration.** The temp folder outlives the upgrade that + # created it, so a later *fresh* install finds a copy of a configuration that belongs + # to an installation the user removed — and silently resurrects it, tokens and all. + # Found on a real DSM, by a fresh install coming back up with the previous run's + # answer token in it. + mkdir -p "$ETC" + cp -p "$SAVED" "$ENV_FILE" + chmod 600 "$ENV_FILE" + say "restored your configuration into $ENV_FILE" +else + mkdir -p "$ETC" + env_body "$answers" "$port" >"$ENV_FILE" + chmod 600 "$ENV_FILE" + say "wrote $ENV_FILE" +fi + +# The .sc file is static and the port is not, so write the chosen one in. Whether the +# port-config worker acquires this before or after this script runs decides whether it +# reaches the firewall entry on a fresh install; either way, changing the port later means +# sudo /usr/syno/sbin/synopkghelper update rescriptum port-config +# because Acquire skips a file that already exists in /usr/local/etc/service.d/. +if [ -f "$SC_FILE" ]; then + sed "s|^dst.ports=.*|dst.ports=\"$port/tcp\"|" "$SC_FILE" >"$SC_FILE.new" && + mv "$SC_FILE.new" "$SC_FILE" +fi + +exit 0 diff --git a/packaging/dsm/scripts/postuninst b/packaging/dsm/scripts/postuninst new file mode 100755 index 0000000..20be867 --- /dev/null +++ b/packaging/dsm/scripts/postuninst @@ -0,0 +1,27 @@ +#!/bin/sh +# rescriptum — after removal. +# +# This script touches the package tree and nothing else. Two reasons, and both of them +# have cost other packages a very bad day: +# +# * It runs on an upgrade as well as on an uninstall. The documented upgrade order runs +# the *old* version's preuninst/postuninst between preupgrade and preinst, so anything +# that deletes configuration or answers here deletes them on every upgrade. +# * The answers are the user's data. DSM deliberately does not remove a package's shared +# folder on uninstall "since it might delete the user's personal data as well" — and +# when RESCRIPTUM_STORE=sqlite the database *is* the answers. We do not do DSM's +# restraint for it. +# +# So: never the share, never the database, under any status. DSM removes /var/packages/ +# rescriptum itself, which is all the cleanup this package needs. + +set -u + +PKG="rescriptum" + +if [ "${SYNOPKG_PKG_STATUS:-}" = "UNINSTALL" ]; then + echo "$PKG: removed. The '$PKG' shared folder and everything in it — your answers, and" + echo "$PKG: the database if you used one — were left alone on purpose." +fi + +exit 0 diff --git a/packaging/dsm/scripts/postupgrade b/packaging/dsm/scripts/postupgrade new file mode 100755 index 0000000..3a168ee --- /dev/null +++ b/packaging/dsm/scripts/postupgrade @@ -0,0 +1,26 @@ +#!/bin/sh +# rescriptum — after an upgrade has installed the new version. +# +# Puts the env file back if it did not survive. If it did, it is left exactly as it was: +# the user's file wins over the copy, always. + +set -u + +PKG="rescriptum" +# See start-stop-status: the package root is a fixed path, not dirname of SYNOPKG_PKGDEST. +ROOT="${RESCRIPTUM_PKG_ROOT:-/var/packages/${SYNOPKG_PKGNAME:-$PKG}}" +ETC="$ROOT/etc" +ENV_FILE="$ETC/$PKG.env" +SAVED="${SYNOPKG_TEMP_UPGRADE_FOLDER:-}/etc/$PKG.env" + +if [ -f "$ENV_FILE" ]; then + exit 0 +fi + +if [ -n "${SYNOPKG_TEMP_UPGRADE_FOLDER:-}" ] && [ -f "$SAVED" ]; then + mkdir -p "$ETC" + cp -p "$SAVED" "$ENV_FILE" && chmod 600 "$ENV_FILE" && + echo "$PKG: restored your configuration" +fi + +exit 0 diff --git a/packaging/dsm/scripts/preinst b/packaging/dsm/scripts/preinst new file mode 100755 index 0000000..88e9293 --- /dev/null +++ b/packaging/dsm/scripts/preinst @@ -0,0 +1,13 @@ +#!/bin/sh +# rescriptum — before the payload is unpacked. +# +# There is deliberately nothing to do here. The wizard's values are read in postinst, the +# share does not exist yet (data-share runs in the FROM_ENABLE_TO_POSTUNINST window, at +# package start), and anything that could fail here would fail an installation for a +# reason DSM would report as "installation failed" and nothing more. +# +# It exists because a script that is missing when someone expects it is a worse surprise +# than one that says why it is empty. + +set -u +exit 0 diff --git a/packaging/dsm/scripts/preuninst b/packaging/dsm/scripts/preuninst new file mode 100755 index 0000000..c4af67d --- /dev/null +++ b/packaging/dsm/scripts/preuninst @@ -0,0 +1,11 @@ +#!/bin/sh +# rescriptum — before removal. +# +# DSM stops the package before this runs, so there is nothing to stop. And this script +# runs during an *upgrade* too — the documented order removes the old version before +# installing the new one — so anything destructive here would run on every upgrade. +# +# SYNOPKG_PKG_STATUS distinguishes them (UNINSTALL vs UPGRADE) if that is ever needed. + +set -u +exit 0 diff --git a/packaging/dsm/scripts/preupgrade b/packaging/dsm/scripts/preupgrade new file mode 100755 index 0000000..a101f9d --- /dev/null +++ b/packaging/dsm/scripts/preupgrade @@ -0,0 +1,22 @@ +#!/bin/sh +# rescriptum — before an upgrade replaces this version. +# +# Carries the live env file across in $SYNOPKG_TEMP_UPGRADE_FOLDER, which is the +# framework's own documented mechanism for exactly this. It is the belt to postinst's +# braces: postinst already writes the file only when it is absent, and this makes the +# outcome the same whether or not etc/ survives the upgrade. + +set -u + +PKG="rescriptum" +# See start-stop-status: the package root is a fixed path, not dirname of SYNOPKG_PKGDEST. +ROOT="${RESCRIPTUM_PKG_ROOT:-/var/packages/${SYNOPKG_PKGNAME:-$PKG}}" +ENV_FILE="$ROOT/etc/$PKG.env" + +if [ -n "${SYNOPKG_TEMP_UPGRADE_FOLDER:-}" ] && [ -f "$ENV_FILE" ]; then + mkdir -p "$SYNOPKG_TEMP_UPGRADE_FOLDER/etc" + cp -p "$ENV_FILE" "$SYNOPKG_TEMP_UPGRADE_FOLDER/etc/$PKG.env" && + echo "$PKG: kept your configuration for the new version" +fi + +exit 0 diff --git a/packaging/dsm/scripts/start-stop-status b/packaging/dsm/scripts/start-stop-status new file mode 100755 index 0000000..adf781e --- /dev/null +++ b/packaging/dsm/scripts/start-stop-status @@ -0,0 +1,242 @@ +#!/bin/sh +# rescriptum — DSM 7 service control. +# +# DSM drives this through systemd, as pkgctl-rescriptum.service, and calls it with more +# verbs than start/stop/status. `prestart` decides at boot whether the package may start +# at all — precheckstartstop defaults to "yes", so it is called whether or not we thought +# about it — which is why the case at the bottom answers every verb it is given and never +# falls through to a failure. An unrecognised verb exiting non-zero would stop the package +# from ever starting after a reboot, with a symptom ("works when I start it by hand, never +# after a reboot") that looks like anything but a missing case arm. +# +# Everything here runs as the package user: conf/privilege sets run-as "package", and that +# governs the scripts, not only the service. + +set -u + +PKG="rescriptum" +# **ROOT is not derived from SYNOPKG_PKGDEST.** On a real DSM that variable resolves to +# /volume1/@appstore/ — /var/packages//target is only a symlink to it — so +# `dirname "$SYNOPKG_PKGDEST"` is /volume1/@appstore, and everything hung off it (etc/, var/, +# shares/) lands somewhere nobody reads. The package root is a fixed path; only DEST is not. +# +# RESCRIPTUM_PKG_ROOT is a seam for lifecycle-test.sh, which drives these scripts against a +# fake tree it can write to — the same trick as Config::from_lookup on the Rust side, and +# the reason this bug survived local testing until a real DSM found it. +ROOT="${RESCRIPTUM_PKG_ROOT:-/var/packages/${SYNOPKG_PKGNAME:-$PKG}}" +DEST="${SYNOPKG_PKGDEST:-$ROOT/target}" +VAR="${SYNOPKG_PKGVAR:-$ROOT/var}" +ETC="$ROOT/etc" + +BIN="$DEST/bin/$PKG" +ENV_FILE="$ETC/$PKG.env" +PID_FILE="$VAR/$PKG.pid" +# Where the service's own stdout and stderr go. Not the request log — that is +# RESCRIPTUM_LOG_FILE, which the server opens itself — but the things said before the +# server knows where its log lives: a configuration error, a malformed env file, a panic. +OUT_FILE="$VAR/startup.log" + +# How long `stop` stays polite. A stop that waits forever on a wedged process hangs the +# Package Center UI, and DSM's own timeout is not a thing to rely on. +STOP_WAIT=20 + +say() { echo "$PKG: $*"; } + +alive() { + case "${1:-}" in + '' | *[!0-9]*) return 1 ;; + esac + kill -0 "$1" 2>/dev/null +} + +# Read one key out of the env file. The file is not a shell and must not be sourced; this +# is the only value the script itself needs. +value_of() { + [ -f "$ENV_FILE" ] || return 0 + q="'" + sed -n "s/^[[:space:]]*\(export[[:space:]][[:space:]]*\)\{0,1\}${1}[[:space:]]*=[[:space:]]*//p" "$ENV_FILE" | + tail -n 1 | + sed "s/[[:space:]]*$//; s/^\"\(.*\)\"\$/\1/; s/^${q}\(.*\)${q}\$/\1/" +} + +# ps flavours differ between busybox and procps; all of them print the command line, and +# the binary's path is absolute and unique to this package. +find_pid() { + { ps -eo pid=,args= 2>/dev/null || ps ax 2>/dev/null || ps 2>/dev/null; } | + grep -F "$BIN" | grep -v grep | awk '{ print $1 }' | head -n 1 +} + +start() { + if [ -f "$PID_FILE" ] && alive "$(cat "$PID_FILE" 2>/dev/null)"; then + say "already running (pid $(cat "$PID_FILE"))" + return 0 + fi + # A pidfile whose process is gone is yesterday's, not a failure. + rm -f "$PID_FILE" + + if [ ! -x "$BIN" ]; then + say "missing $BIN — reinstall the package" + return 1 + fi + + mkdir -p "$VAR" 2>/dev/null + + # data-share runs in the FROM_ENABLE_TO_POSTUNINST window: the share exists by the + # time this runs, and did not exist when postinst did. DSM creates the *share*; the + # answers directory inside it is ours, and the server deliberately never creates its + # own. Nothing here may abort the start — an encrypted shared folder that has not been + # unlocked yet is a transient condition, and the server's own startup warning already + # names the directory. + answers=$(value_of RESCRIPTUM_ANSWERS_DIR) + if [ -n "$answers" ]; then + if [ ! -d "$answers" ] && ! mkdir -p "$answers" 2>/dev/null; then + say "could not create $answers — if its volume is encrypted, unlock it and restart the package" + elif [ ! -r "$answers" ]; then + say "$answers is not readable by $(id -u -n 2>/dev/null || echo "$PKG") — check the shared folder's permissions" + fi + fi + + RESCRIPTUM_ENV_FILE="$ENV_FILE" + export RESCRIPTUM_ENV_FILE + + # The script runs inside the unit's cgroup, so a plain `&` can be reaped when the + # script exits. setsid gives the server a session of its own; nohup is the fallback + # where setsid is not installed. + if command -v setsid >/dev/null 2>&1; then + setsid "$BIN" >>"$OUT_FILE" 2>&1 & + else + nohup "$BIN" >>"$OUT_FILE" 2>&1 & + fi + pid=$! + echo "$pid" >"$PID_FILE" + + # Everything fatal — an unreadable env file, an admin token under 16 characters, a + # store that cannot be opened — happens in the first moments and exits. Reporting + # success for a process that is already gone is what would make that hard to see. + sleep 1 + if ! alive "$pid"; then + # setsid forks when it is already a process-group leader, in which case the pid we + # recorded was setsid's rather than the server's. Look for the real one before + # concluding anything. + found=$(find_pid) + if [ -n "$found" ]; then + pid="$found" + echo "$pid" >"$PID_FILE" + else + rm -f "$PID_FILE" + # Two files, because which one holds the reason depends on how far it got: a + # bad env file is said before log::init knows where the log lives and lands in + # startup.log, while a refused configuration — an admin token under 16 + # characters, a store that cannot be opened — is said after, and lands in the + # log the env file named. + say "started and exited immediately. The reason is in one of these:" + for f in "$OUT_FILE" "$(value_of RESCRIPTUM_LOG_FILE)"; do + [ -n "$f" ] && [ -f "$f" ] || continue + echo "--- $f" + tail -n 5 "$f" 2>/dev/null + done + return 1 + fi + fi + + say "started (pid $pid)" + return 0 +} + +stop() { + pid="" + if [ -f "$PID_FILE" ]; then + pid=$(cat "$PID_FILE" 2>/dev/null) + else + # No pidfile is not the same as no server. An upgrade replaces target/ under a + # running process, and anything that loses var/ loses the pidfile with it — then + # `stop` says "not running", `start` meets a port that is still held, and the + # package fails to start for a reason that names neither. Look for it. + pid=$(find_pid) + [ -n "$pid" ] && say "no pidfile, but $BIN is running as $pid" + fi + if [ -z "$pid" ]; then + say "not running" + return 0 + fi + if ! alive "$pid"; then + rm -f "$PID_FILE" + say "not running (stale pidfile removed)" + return 0 + fi + + # SIGTERM is what the server handles: it stops accepting and exits. There is no state + # to lose either way. + kill -TERM "$pid" 2>/dev/null + waited=0 + while [ "$waited" -lt "$STOP_WAIT" ]; do + alive "$pid" || break + sleep 1 + waited=$((waited + 1)) + done + + if alive "$pid"; then + say "still alive after ${STOP_WAIT}s — killing it" + kill -KILL "$pid" 2>/dev/null + sleep 1 + fi + + rm -f "$PID_FILE" + say "stopped" + return 0 +} + +# The exit codes are a contract with Package Center, and 1 does not mean "stopped": it +# means "dead, and there is a pidfile it did not clean up", which shows up in the UI as a +# service that crashed. A cleanly stopped package is 3. +# +# 0 running 1 dead, pidfile present 2 dead, lock file present +# 3 not running 4 unknown 150 broken, should be reinstalled +status() { + if [ -f "$PID_FILE" ]; then + pid=$(cat "$PID_FILE" 2>/dev/null) + if alive "$pid"; then + say "running (pid $pid)" + return 0 + fi + say "not running, and $PID_FILE was left behind" + return 1 + fi + say "not running" + return 3 +} + +case "${1:-}" in +start) + start + exit $? + ;; +stop) + stop + exit $? + ;; +restart) + stop + start + exit $? + ;; +status) + status + exit $? + ;; +prestart | prestop) + # Nothing here may fail transiently. prestart runs at boot and a non-zero exit means + # the package does not start: a disk still spinning up, a share not yet unlocked or a + # network not yet configured are not reasons to refuse. + exit 0 + ;; +log) + # What DSM shows behind the package's log link. + echo "$VAR/$PKG.log" + exit 0 + ;; +*) + say "unhandled action: ${1:-none}" + exit 0 + ;; +esac diff --git a/packaging/dsm/vm/.gitignore b/packaging/dsm/vm/.gitignore new file mode 100644 index 0000000..b1621c6 --- /dev/null +++ b/packaging/dsm/vm/.gitignore @@ -0,0 +1,7 @@ +storage/ +storage.clean/ +*.qcow2 + +# The rig VM is rebuilt often and its host key changes with it; RIG_SSH_OPTS points +# on-dsm.sh at this file precisely so those keys stay out of anybody else here. +known_hosts diff --git a/packaging/dsm/vm/README.md b/packaging/dsm/vm/README.md new file mode 100644 index 0000000..0d1151d --- /dev/null +++ b/packaging/dsm/vm/README.md @@ -0,0 +1,225 @@ +# The test rig + +Three places run the same checks, and each one proves something the others cannot. + +| Where | What it proves | Cost | +|---|---|---| +| **A fake package tree**, anywhere — `../lifecycle-test.sh` | everything the *scripts* decide: the env file written once, the wizard's values and their absence, the service starting and answering, the exit codes Package Center reads, an upgrade that must not touch a hand-edited configuration, an uninstall that must not touch the answers | seconds, no DSM, **runs in CI on every push** | +| **A DSM 7 VM** — `run-vm.sh`, then `on-dsm.sh` | DSM's own machinery: the `data-share` worker and its ACL, the `port-config` worker, the generated systemd unit, logrotate against a live descriptor, and whether Package Center accepts the archive at all | minutes per cycle, and a snapshot to roll back to | +| **The DS416j** — the same `on-dsm.sh` | that all of the above is true on ARMv7, on the machine this project exists for | slow, and it is somebody's NAS | + +**Nothing ships on VM evidence alone.** The VM is x86_64: it tests the *packaging*, and +tells you nothing about the ARMv7 binary — which is the one thing already covered, since +the cross-build and its statically-linked assertion are CI gates. The DS416j is the +verdict. + +## Getting a machine: Docker, on a Linux host with KVM + +[`docker-compose.yml`](docker-compose.yml) is the short route. The `vdsm/virtual-dsm` image +downloads **Synology's own Virtual DSM release**, so there is no loader to find and nothing +to patch, and it installs **DSM 7.2** by default — close enough to the DS416j's own 7.2.1 +that the rig resembles the target. + +```console +$ docker compose -f packaging/dsm/vm/docker-compose.yml up -d +$ open http://:5000 # DSM's setup wizard, once +``` + +Then, in DSM: **Control Panel → Terminal & SNMP → Enable SSH service**. The rig drives the +machine over SSH — the compose file publishes it on 2222 — and copies two `.spk` files and +`remote-check.sh` to it. + +Three things about the host, and only the last one is a wall: + +- **KVM makes it fast; it is not what makes it possible.** With `/dev/kvm` (a Linux x86_64 + host — on Proxmox, set the guest's CPU type to `host`) the machine runs at near-native + speed. Without it, QEMU emulates and the image says so itself: *"about 10 times slower"*. + On an ARM host it works that out on its own — `init.sh` compares the host architecture + against the guest's and disables acceleration rather than refusing — so a Mac runs + [`docker-compose.emulated.yml`](docker-compose.emulated.yml), which binds no device. A + long first boot, not a wall. +- **14 GiB free where the storage lives**, hardcoded in the image (`minSpace` in its + `install.sh`) and *not* derived from `DISK_SIZE`, so a smaller disk does not lower it. + This is the one that actually stops a machine. +- **Synology's EULA for Virtual DSM does not permit installation on non-Synology + hardware.** That is the operator's call, not this repository's. + +Two host ports collide often enough to be worth naming: **5000 is AirPlay Receiver on +macOS**, and 8000 is whatever you already run there. `DSM_WEB_PORT`, `DSM_SSH_PORT` and +`DSM_APP_PORT` move them; `DSM_STORAGE` moves the disk. + +There is no snapshot command: the machine's whole state is the `storage/` directory beside +the compose file, so `docker compose down && cp -a storage storage.clean` is the snapshot, +and copying it back is the restore. Take one as soon as the wizard is done, because the +next thing the rig does is try to break the package on purpose. + +### Or a loader image, by hand + +`run-vm.sh` boots a DSM image you supply under plain QEMU — the fallback when Docker with +KVM is not available, or when the machine has to be something other than Virtual DSM. It +takes the loader image and gives it the hardware, the disks and the port forwards that +matter; **finding that image is not something this repository automates.** + +Whatever you boot, ask it what it thinks it is rather than assuming — `on-dsm.sh` starts by +printing exactly that: + +```console +$ ssh admin@nas cat /etc.defaults/VERSION +$ ssh admin@nas synogetkeyvalue /etc.defaults/synoinfo.conf unique +``` + +**`kvmx64` is not what a QEMU-booted DSM reports.** That is Synology's own VirtualDSM +platform, which runs under Virtual Machine Manager on a NAS — something the DS416j cannot +host. A loader-booted DSM presents whatever model it emulates. + +## What running it actually taught us + +Everything below was found by pointing this at a DSM 7.2.2 machine, not by reading the +developer guide — and several of them contradict it. + +| | | +|---|---| +| `SYNOPKG_PKGDEST` is **`/volume1/@appstore/`** | `/var/packages//target` is only a symlink to it, so `dirname "$SYNOPKG_PKGDEST"` is *not* the package root. Deriving it that way put the env file somewhere nobody reads and the service never started | +| **`etc/` and `var/` outlive an uninstall** | they are symlinks into `/volume1/@appconf/` and `/volume1/@appdata/`, which DSM leaves behind. The env file — tokens included — stays on the volume | +| `$SYNOPKG_TEMP_UPGRADE_FOLDER` **outlives its upgrade** | a later *fresh* install found the removed installation's configuration there and restored it. The restore now requires `SYNOPKG_PKG_STATUS = UPGRADE` | +| The firewall directory is **`/usr/local/etc/services.d/`** | plural. The guide says `service.d`, which does not exist on the machine | +| `port-config` acquires **after `postinst`** | so the wizard's port does reach the firewall entry on a fresh install — this answers a question the plan left open | +| The generated unit has **no `Restart=`** | `Type=oneshot`, `RemainAfterExit=yes`, `TimeoutStartSec=3600`. DSM does not restart the process if it dies | +| A non-login shell has **`PATH=/usr/bin:/bin:/usr/sbin:/sbin`** | `synopkg`, `synogetkeyvalue` and `synopkghelper` are all outside it | +| **SFTP is off**, so `scp` fails | `subsystem request failed on channel 0`. The rig copies with `ssh 'cat >'` | +| DSM's logrotate compresses with **xz** | the rotated file is `rescriptum.log.1.xz`, and `find` will not see it through the `var` symlink without `-L` | +| **Auto Block locks the rig out** | a few failed connections during a reboot are enough; every login is then refused, the web API with `"code":407`. `bootstrap.sh` turns it off | +| **Never name the rig's admin after the package user** | DSM's package user shadows a DSM account of the same name and takes it with it on uninstall | + +## The loop + +```console +$ ./build.sh x86_64-unknown-linux-musl # what the VM runs +$ export RIG_SSH_OPTS="-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$PWD/packaging/dsm/vm/known_hosts" +$ packaging/dsm/vm/on-dsm.sh rescriptum@127.0.0.1 -p 2222 -i ~/.ssh/rescriptum-rig +``` + +`RIG_SSH_OPTS` exists because a VM you rebuild gets a new host key every time, and ssh is +right to refuse it — the default has to suit the *real* NAS. It keeps the rig's keys in a +file of their own rather than in yours. Against the DS416j, drop it: + +```console +$ ./build.sh armv7-unknown-linux-gnueabihf +$ packaging/dsm/vm/on-dsm.sh admin@nas +``` + +`on-dsm.sh` builds both `.spk` files it needs (build 1 and build 2 of the same version — +the upgrade is the most valuable test here), copies them and `remote-check.sh` to the +machine, and runs it as root. + +Against the real thing, only the target changes: + +```console +$ ./build.sh armv7-unknown-linux-gnueabihf +$ packaging/dsm/vm/on-dsm.sh admin@nas +``` + +## Changing the package? This is the procedure + +Anything under `packaging/dsm/` — a lifecycle script, `conf/resource`, the wizard, the env +file's contents — is **not proven by the local harness alone**. `lifecycle-test.sh` drives +the scripts against a tree it built itself, and that is exactly why it cannot see the class +of bug that matters here: two of the three real defects found so far were invisible to it +(a package root derived from a symlink target, and a stale upgrade folder resurrecting a +removed configuration). Run the machine. + +1. **Cheap gates first** — they take seconds and catch most mistakes: + + ```console + $ ./packaging/dsm/make-spk.sh x86_64 --bin target/release/rescriptum --out /tmp/spk + $ ./packaging/dsm/lifecycle-test.sh /tmp/spk/rescriptum-*-x86_64.spk + $ ./packaging/dsm/check-spk.sh /tmp/spk/rescriptum-*-x86_64.spk + ``` + + The `--bin` is not optional on a machine that is not x86_64 Linux: the harness runs the + packaged binary, so give it one this host can execute. + +2. **Restore the machine**, so the run starts from a known state rather than from whatever + the last one left: + + ```console + $ packaging/dsm/vm/snapshot.sh restore clean + $ docker compose -f packaging/dsm/vm/docker-compose.emulated.yml up -d + ``` + + Then wait for it — under emulation a boot is minutes, and `ssh … 'sudo -n true'` + succeeding is the signal. + +3. **Run the machine checks**, which build both `.spk` files, install, start, ask for a real + answer, upgrade and uninstall: + + ```console + $ ./build.sh x86_64-unknown-linux-musl + $ export RIG_SSH_OPTS="-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$PWD/packaging/dsm/vm/known_hosts" + $ packaging/dsm/vm/on-dsm.sh rigadmin@127.0.0.1 -p 2222 -i ~/.ssh/rescriptum-rig + ``` + +4. **Open the application, with your eyes.** No script can assert that a window renders. + Sign in to DSM, open the main menu, and click **rescriptum**: + + - the icon is there, and the window opens; + - the labels are words, not `ui:settings` — that is the sign the text files loaded; + - **Réglages** shows this machine's real configuration, **État** says the package is + running and the answers folder is readable, **Journal** shows actual log lines; + - change one value, *Enregistrer*, then *Redémarrer maintenant* — DSM closes the window + while it restarts, which is expected. Reopen it and the value is still there; + - after an **upgrade**, check the labels again. If the window looks like the previous + version's, the JavaScript is being served from the browser's cache and the versioned + filename has stopped working. + + `SYNO.SDS.AppLaunch('SYNO.SDS.App.Rescriptum.Instance', {}, false, null, null)` in the + browser console opens it without the menu, which is quicker when iterating. + +5. **Read the `·` lines, not only the `✓`.** They are where the machine tells you things no + assertion covers: which directory the firewall entry landed in, what the generated unit + says, whether a worker ran before or after `postinst`. + +6. **A failure is a question, not a verdict.** Three of the six things that have gone red + here were the rig's own fault — checking a resource worker before its window opened, a + canary file that was a valid answer document, `find` not following a symlink. Read + `/var/log/packages/rescriptum.log` and `startup.log` before changing the package. + +7. **Green on the VM is not green.** It is x86_64 and says nothing about the ARMv7 binary. + `on-dsm.sh admin@nas` against the DS416j is the verdict, and nothing is released without + it. + +## It is destructive on purpose + +`remote-check.sh` hand-edits the env file, drops a canary in the shared folder, upgrades +over both, and then uninstalls. That is not incidental — those two guards are the most +expensive things in this package to get wrong, the first published `.spk` is the one whose +uninstall scripts will run during everybody's first upgrade, and a guard that was never +watched failing proves nothing. + +So: **point it at a machine whose answers nobody cares about until it has passed once.** +It leaves the shared folder behind deliberately, and says so — removing it is the one +thing this must never do. + +## What it writes down + +Several of the plan's open questions are answered by watching rather than by reading, so +the script prints them instead of asserting them: + +- whether the **`port-config` worker acquires before or after `postinst`** — it compares + what `postinst` wrote with what landed in `/usr/local/etc/service.d/`, which decides + whether the wizard's port ever reaches the firewall entry on a fresh install; +- what `systemctl cat pkgctl-rescriptum` says about **`Type=`, `KillMode=` and `Restart=`** + — the last one decides whether DSM restarts the process if it dies, which is worth + documenting either way; +- whether **`etc/` and `var/` survive an upgrade** on their own; +- where DSM installed the **logrotate stanza**. + +## When it fails + +Three places say why, and knowing they exist is most of the debugging: + +```console +$ cat /var/log/packages/rescriptum.log # the package scripts' own output +$ cat /var/log/synopkg.log # Package Center's view — why an archive was refused +$ systemctl status pkgctl-rescriptum +``` diff --git a/packaging/dsm/vm/bootstrap.sh b/packaging/dsm/vm/bootstrap.sh new file mode 100755 index 0000000..d89b08b --- /dev/null +++ b/packaging/dsm/vm/bootstrap.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Make a freshly installed DSM machine drivable by the rig: SSH on, a key installed, and +# sudo that does not stop to ask. +# +# packaging/dsm/vm/bootstrap.sh --user rigadmin --password '…' +# packaging/dsm/vm/bootstrap.sh --host 192.168.1.50 --web-port 5000 --ssh-port 22 \ +# --user admin --password '…' --key ~/.ssh/rescriptum-rig +# +# **The rig's account must not be named after the package user.** `conf/privilege` makes DSM +# create a `rescriptum` user at install time; a DSM administrator of the same name is +# shadowed by it and then *deleted with it* on uninstall — so the rig loses its own access +# halfway through the first run, with every login refused and nothing saying why. Use a name +# nothing else claims: `rigadmin` here. +# +# **The one thing it cannot do is create the account.** DSM's first-run wizard has no API +# and the image exposes no variable for it, so somebody opens http://:5000 once and +# fills in three fields. Everything after that is here, because everything after that turned +# out to be scriptable through DSM's own web API — which is worth knowing, since the manual +# route through Control Panel is a dozen clicks on a machine that renders slowly. +# +# What it does, and why each one is needed: +# +# * **enables SSH** (SYNO.Core.Terminal) — the rig drives the machine over it; +# * **enables user home directories** (SYNO.Core.User.Home, which needs `location`, not +# just `enable`) — without them there is no ~/.ssh to put a key in, and ssh-copy-id +# fails with "Could not chdir to home directory"; +# * **installs the public key**, through expect, since DSM has no other way to accept one; +# * **grants passwordless sudo** — remote-check.sh runs as root, and a CI runner has no +# terminal to type a password into; +# * **turns off Auto Block** — DSM blocks a source address after a few failed connections, +# and a VM that reboots mid-connection produces those by the handful. +# +# It is idempotent: run it again after rebuilding the machine. + +set -euo pipefail + +HOST=127.0.0.1 +WEB_PORT=5050 +SSH_PORT=2222 +USER_NAME=rigadmin +PASSWORD="" +KEY="$HOME/.ssh/rescriptum-rig" + +while [ $# -gt 0 ]; do + case "$1" in + --host) HOST="$2"; shift 2 ;; + --web-port) WEB_PORT="$2"; shift 2 ;; + --ssh-port) SSH_PORT="$2"; shift 2 ;; + --user) USER_NAME="$2"; shift 2 ;; + --password) PASSWORD="$2"; shift 2 ;; + --key) KEY="$2"; shift 2 ;; + -h | --help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +if [ -z "$PASSWORD" ]; then + printf 'password for %s on %s: ' "$USER_NAME" "$HOST" >&2 + read -rs PASSWORD + echo >&2 +fi +command -v expect >/dev/null || { echo "expect is not installed (brew install expect / apt install expect)" >&2; exit 1; } + +API="http://$HOST:$WEB_PORT/webapi/entry.cgi" +COOKIES=$(mktemp "${TMPDIR:-/tmp}/dsm-cookies.XXXXXX") +trap 'rm -f "$COOKIES"' EXIT + +api() { # [extra curl --data-urlencode args...] + local name="$1" version="$2" method="$3" + shift 3 + curl -fsS -m 60 -b "$COOKIES" -X POST "$API" \ + --data-urlencode "api=$name" --data-urlencode "version=$version" \ + --data-urlencode "method=$method" "$@" +} + +echo "==> logging in to $HOST:$WEB_PORT as $USER_NAME" +curl -fsS -m 30 -c "$COOKIES" -X POST "$API" \ + --data-urlencode 'api=SYNO.API.Auth' --data-urlencode 'version=6' \ + --data-urlencode 'method=login' --data-urlencode "account=$USER_NAME" \ + --data-urlencode "passwd=$PASSWORD" --data-urlencode 'session=Core' \ + --data-urlencode 'format=cookie' | + grep -q '"success":true' || { echo "login refused — is the wizard finished?" >&2; exit 1; } + +echo "==> enabling SSH" +api SYNO.Core.Terminal 3 set --data-urlencode 'enable_ssh=true' \ + --data-urlencode 'ssh_port=22' --data-urlencode 'enable_telnet=false' >/dev/null + +echo "==> enabling user home directories" +# `enable=true` alone is refused with error 3103: it wants to be told which volume. +api SYNO.Core.User.Home 1 set --data-urlencode 'enable=true' \ + --data-urlencode 'location=/volume1' >/dev/null + +if [ ! -f "$KEY" ]; then + echo "==> creating $KEY" + ssh-keygen -t ed25519 -N '' -C 'rescriptum-dsm-rig' -f "$KEY" >/dev/null +fi + +echo "==> installing the key" +KNOWN="$(cd "$(dirname "$0")" && pwd)/known_hosts" +expect -c " +set timeout 90 +spawn ssh-copy-id -i [file normalize $KEY.pub] -o StrictHostKeyChecking=accept-new \ + -o UserKnownHostsFile=$KNOWN -p $SSH_PORT $USER_NAME@$HOST +expect { + -re \"assword:\" { send {$PASSWORD}; send \"\r\"; exp_continue } + eof +} +" >/dev/null + +SSH_ARGS=(-i "$KEY" -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$KNOWN" -o LogLevel=ERROR -p "$SSH_PORT") + +echo "==> granting passwordless sudo" +printf '%s\n' "$PASSWORD" | ssh "${SSH_ARGS[@]}" "$USER_NAME@$HOST" \ + "sudo -S -p '' sh -c 'echo \"$USER_NAME ALL=(ALL) NOPASSWD: ALL\" > /etc/sudoers.d/rescriptum-rig && chmod 440 /etc/sudoers.d/rescriptum-rig'" >/dev/null + +# A rig locks itself out otherwise, and the failure is unrecognisable: DSM's Auto Block +# counts failed connections per source address, and a VM that reboots mid-connection +# produces them by the handful. The symptom is every login refused at once — ssh keys, +# passwords and the web API alike, the last one with `"error":{"code":407}` — on an account +# that is perfectly fine. Learned by locking myself out of this exact machine. +echo "==> disabling Auto Block (a rig that locks itself out is not a rig)" +ssh "${SSH_ARGS[@]}" "$USER_NAME@$HOST" \ + 'sudo -n /usr/syno/bin/synosetkeyvalue /etc/synoinfo.conf autoblock_enable no; + sudo -n sqlite3 /etc/synoautoblock.db "delete from AutoBlockIP;" 2>/dev/null; + true' + +echo "==> checking" +ssh "${SSH_ARGS[@]}" "$USER_NAME@$HOST" 'sudo -n true' || { echo "passwordless sudo did not take" >&2; exit 1; } +ssh "${SSH_ARGS[@]}" "$USER_NAME@$HOST" 'echo " $(cat /etc.defaults/VERSION | tr "\n" " ")"; echo " arch: $(uname -m)"' + +cat <:5000 → work through DSM's setup wizard, create the admin account. +# On macOS that port is AirPlay Receiver's, so use DSM_WEB_PORT=5050 (or turn AirPlay +# off in System Settings → General → AirDrop & Handoff); +# 2. Control Panel → Terminal & SNMP → **Enable SSH service** (the rig drives the machine +# over SSH, on 2222 below); +# 3. take a snapshot of ./storage — a broken package then costs a restore, not an +# afternoon: docker compose down && cp -a storage storage.clean +# +# Then, from a checkout: +# ./build.sh x86_64-unknown-linux-musl +# packaging/dsm/vm/on-dsm.sh admin@ -p 2222 +services: + dsm: + container_name: rescriptum-dsm + image: vdsm/virtual-dsm + environment: + DISK_SIZE: "16G" + RAM_SIZE: "2G" + CPU_CORES: "2" + devices: + - /dev/kvm + cap_add: + - NET_ADMIN + ports: + # DSM's web UI — the first-run wizard lives here. + - ${DSM_WEB_PORT:-5000}:5000 + # SSH. Not in the upstream example, and the rig cannot work without it: on-dsm.sh + # copies two .spk files and remote-check.sh to the machine and runs them as root. + - ${DSM_SSH_PORT:-2222}:22 + # rescriptum itself, so /health can be checked from outside the box too. + - ${DSM_APP_PORT:-8000}:8000 + volumes: + - ${DSM_STORAGE:-./storage}:/storage + restart: "no" + stop_grace_period: 2m diff --git a/packaging/dsm/vm/on-dsm.sh b/packaging/dsm/vm/on-dsm.sh new file mode 100755 index 0000000..564b528 --- /dev/null +++ b/packaging/dsm/vm/on-dsm.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Run the on-machine checks against a DSM 7 box over SSH — the VM while iterating, the +# DS416j for the verdict. Same script both times; that is the point. +# +# packaging/dsm/vm/on-dsm.sh admin@localhost -p 2222 # the VM from run-vm.sh +# packaging/dsm/vm/on-dsm.sh admin@nas # the real thing +# +# It needs two .spk files for the same version — build 1 and build 2 — because the most +# valuable test here is the *upgrade*, and an upgrade needs something to upgrade to. It +# builds them if they are not in dist/ already, from a binary you must have cross-compiled +# for that machine's ABI: +# +# ./build.sh x86_64-unknown-linux-musl # the VM +# ./build.sh armv7-unknown-linux-gnueabihf # the DS416j +# +# **Supervise the first run.** Every remote step echoes the command it runs, because DSM's +# own CLI differs between builds and the failure that matters is the one you can read. +# It is destructive on purpose — it upgrades over a hand-edited configuration and then +# uninstalls — so point it at a machine whose answers directory nobody cares about until +# it has passed once. + +set -euo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +REPO=$(cd "$HERE/../../.." && pwd) + +HOST="" +ABI="" +PORT="" +KEY="" +while [ $# -gt 0 ]; do + case "$1" in + -p) PORT="$2"; shift 2 ;; + -i) KEY="$2"; shift 2 ;; + --abi) ABI="$2"; shift 2 ;; + -h | --help) sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) HOST="$1"; shift ;; + esac +done +[ -n "$HOST" ] || { echo "usage: on-dsm.sh [-p port] [-i key] [--abi armv7|x86_64] user@host" >&2; exit 2; } + +# Options for ssh; there is no scp here on purpose — see push() below. +SSH_OPTS=() +if [ -n "$PORT" ]; then SSH_OPTS+=(-p "$PORT"); fi +if [ -n "$KEY" ]; then SSH_OPTS+=(-i "$KEY"); fi + +# A disposable VM gets a new host key every time it is rebuilt, and ssh refuses to talk to +# it — correctly, since the default here has to be the one that suits the *real* NAS. So +# the rig passes its own options rather than this script relaxing anything by itself: +# +# RIG_SSH_OPTS="-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$PWD/packaging/dsm/vm/known_hosts" +# +# which keeps the VM's keys out of your own known_hosts as well. +if [ -n "${RIG_SSH_OPTS:-}" ]; then + # shellcheck disable=SC2206 # word splitting is what turns the string into options + extra=($RIG_SSH_OPTS) + SSH_OPTS+=("${extra[@]}") +fi + +sshx() { ssh "${SSH_OPTS[@]}" "$HOST" "$@"; } + +# Not scp: **DSM does not enable the SFTP subsystem by default**, and modern scp speaks +# SFTP, so it fails with "subsystem request failed on channel 0". `scp -O` falls back to +# the legacy protocol and does work, but it needs OpenSSH 8.6+ on this side; piping through +# ssh needs nothing on either. Found by trying it against a real DSM. +push() { # + sshx "cat > '$2'" <"$1" +} + +# Ask the machine what it is rather than assuming: the whole point of the rig is that the +# VM is x86_64 and the machine this project exists for is not. +if [ -z "$ABI" ]; then + case "$(sshx uname -m 2>/dev/null)" in + x86_64) ABI=x86_64 ;; + armv7l | armv7*) ABI=armv7 ;; + aarch64) ABI=aarch64 ;; + *) echo "could not tell what $HOST is — pass --abi" >&2; exit 1 ;; + esac +fi +VERSION=$(grep -m1 '^version = ' "$REPO/Cargo.toml" | cut -d'"' -f2) +echo "==> $HOST is $ABI, testing rescriptum $VERSION" + +# Always rebuild, never reuse what is lying in dist/. Packaging takes under a second, and +# a stale .spk is not a theoretical worry: one built earlier from a *macOS* binary installed +# perfectly here and then died with "cannot execute binary file", which cost a full run to +# diagnose. +for build in 1 2; do + echo "==> building rescriptum-$VERSION-$build-$ABI.spk" + "$REPO/packaging/dsm/make-spk.sh" "$ABI" --spk-build "$build" >/dev/null +done + +echo "==> copying the rig" +sshx "rm -rf /tmp/rescriptum-rig && mkdir -p /tmp/rescriptum-rig" +push "$REPO/dist/rescriptum-$VERSION-1-$ABI.spk" /tmp/rescriptum-rig/build1.spk +push "$REPO/dist/rescriptum-$VERSION-2-$ABI.spk" /tmp/rescriptum-rig/build2.spk +push "$HERE/remote-check.sh" /tmp/rescriptum-rig/remote-check.sh + +echo "==> running it as root" +REMOTE="sh /tmp/rescriptum-rig/remote-check.sh" +if [ "$(sshx id -u 2>/dev/null)" = 0 ]; then + sshx "$REMOTE" +elif [ -t 1 ]; then + # DSM's sudo prompts on the tty; -t gives it one. A password on a command line would + # be readable through ps on the machine, which is the thing this project refuses to do + # anywhere else. + sshx -t "sudo $REMOTE" +else + # No terminal — a CI runner. The rig's account needs passwordless sudo, or be root. + sshx "sudo -n $REMOTE" +fi diff --git a/packaging/dsm/vm/remote-check.sh b/packaging/dsm/vm/remote-check.sh new file mode 100755 index 0000000..34d3b6a --- /dev/null +++ b/packaging/dsm/vm/remote-check.sh @@ -0,0 +1,392 @@ +#!/bin/sh +# Runs **on a DSM 7 machine, as root** — a VM for iteration, the DS416j for the verdict. +# +# on-dsm.sh copies this here with two .spk files (build 1 and build 2 of the same version) +# and runs it. Everything the lifecycle harness cannot reach is here: the data-share +# worker, the port-config worker, the generated systemd unit, logrotate, and whether +# Package Center accepts the archive at all. +# +# Two rules it is written to: +# +# * **assert on effects, not on exit codes.** DSM's own CLI differs between builds, so +# every step says what it ran and then checks what changed on the machine. +# * **destroy things on purpose.** The canary file and the hand-edited env line exist to +# be attacked by the upgrade and the uninstall. A guard that was never watched failing +# proves nothing. +# +# It leaves the machine clean: the package is uninstalled at the end, and the share it +# created is reported rather than removed — removing it is the one thing this must never do. + +set -u + +# A non-login shell on DSM gets PATH=/usr/bin:/bin:/usr/sbin:/sbin, and every tool this +# script needs — synopkg, synogetkeyvalue, synopkghelper — lives outside it. Found by +# running this over ssh rather than by reading anything. +PATH="$PATH:/usr/syno/bin:/usr/syno/sbin:/usr/local/bin:/usr/local/sbin" +export PATH + +PKG=rescriptum +ROOT=/var/packages/$PKG +SHARE=$ROOT/shares/$PKG +RIG=$(cd "$(dirname "$0")" && pwd) +SPK1="$RIG/build1.spk" +SPK2="$RIG/build2.spk" +PORT=8000 + +pass=0 +fails=0 +ok() { echo " ✓ $*"; pass=$((pass + 1)); } +bad() { echo " ✗ $*"; fails=$((fails + 1)); } +note() { echo " · $*"; } +section() { echo; echo "== $*"; } +run() { echo " \$ $*"; "$@" 2>&1 | sed 's/^/ /'; } + +[ "$(id -u)" = 0 ] || { echo "run this as root"; exit 2; } +[ -f "$SPK1" ] || { echo "no $SPK1 — on-dsm.sh copies it here"; exit 2; } + +# ── 0. what machine is this ──────────────────────────────────────────────────── +section "the machine" +note "$(cat /etc.defaults/VERSION 2>/dev/null | tr '\n' ' ')" +note "platform: $(synogetkeyvalue /etc.defaults/synoinfo.conf unique 2>/dev/null)" +note "arch: $(uname -m), kernel $(uname -r)" +case "$(sed -n 's/^majorversion="\(.*\)"$/\1/p' /etc.defaults/VERSION 2>/dev/null)" in +7 | 8 | 9) ok "DSM 7 or newer" ;; +*) bad "this is not DSM 7; nothing below applies" ; exit 1 ;; +esac +# The desktop application is built on DSM's ExtJS framework, present on 7.1.1 and 7.2.2. +# Below 7.1 the package refuses to install at all and the checks that follow would be +# misleading. **This gate said 7.2 for a while** — written when the application was still +# built on DSM 7.2's Vue framework, and not moved when it went back to ExtJS. It would have +# failed the DS416j, which is the one machine this rig exists to satisfy. +case "$(sed -n 's/^productversion="\(.*\)"$/\1/p' /etc.defaults/VERSION 2>/dev/null)" in +7.0*) bad "DSM $(sed -n 's/^productversion="\(.*\)"$/\1/p' /etc.defaults/VERSION) is below the package's os_min_ver of 7.1" ;; +*) ok "at or above the 7.1 the desktop application needs" ;; +esac + +# ── 1. install ───────────────────────────────────────────────────────────────── +# Our own leftovers from a previous run, and only those: this script must never remove +# anything else from the share. Without it a stale canary or test answer makes the next +# run fail for a reason that has nothing to do with the package. +rm -rf /volume1/*/answers/canary.txt /volume1/*/answers/canary.toml \ + /volume1/*/answers/default.toml /volume1/*/answers/98-fa-9b-50-d8-10.toml \ + /volume1/*/answers/groups 2>/dev/null + +# **etc/ and var/ survive an uninstall.** /var/packages//etc and /var/packages//var +# are symlinks into /volume1/@appconf/ and /volume1/@appdata/, and DSM leaves both +# behind — so the env file, tokens included, outlives the package. A first install on a +# fresh NAS has neither, and a rig that keeps them is not testing a first install: it spent +# three runs failing on an env file poisoned by an earlier one. +rm -rf /volume1/@appconf/$PKG /volume1/@appdata/$PKG 2>/dev/null + +section "install" +synopkg uninstall "$PKG" >/dev/null 2>&1 +run synopkg install "$SPK1" +if [ -d "$ROOT/target" ]; then + ok "the package installed" +else + bad "no $ROOT/target — Package Center refused it. /var/log/synopkg.log says why:" + tail -n 20 /var/log/synopkg.log 2>&1 | sed 's/^/ /' + exit 1 +fi + +id "$PKG" >/dev/null 2>&1 && ok "the '$PKG' user exists ($(id "$PKG"))" || bad "no '$PKG' user — conf/privilege did not take" +[ -f "$ROOT/etc/$PKG.env" ] && ok "postinst wrote the env file" || bad "no env file" +[ "$(stat -c '%a' "$ROOT/etc/$PKG.env" 2>/dev/null)" = 600 ] && ok "it is mode 600" || note "mode is $(stat -c '%a%U' "$ROOT/etc/$PKG.env" 2>/dev/null)" +[ "$(stat -c '%U' "$ROOT/etc/$PKG.env" 2>/dev/null)" = "$PKG" ] && ok "and owned by the package user, for free" || note "owner is $(stat -c '%U' "$ROOT/etc/$PKG.env" 2>/dev/null)" +[ -f "$ROOT/target/etc/$PKG.env.example" ] && ok "the example env file is there too" || bad "no example env file" + +note "port-config and usr-local-linker are checked after start: their windows open when" +note "the package is *enabled*, not when postinst runs — checked here they are always absent" + +# ── 2. the share, which is the part that silently 404s when it is wrong ───────── +section "the shared folder" +if [ -d "$SHARE" ]; then + ok "data-share created it, and the symlink is at $SHARE" + note "→ $(readlink -f "$SHARE")" +else + bad "no $SHARE — data-share runs at package *start*, so start it and look again" +fi + +# ── 3. start ─────────────────────────────────────────────────────────────────── +section "start" +run synopkg start "$PKG" +sleep 3 +PORT=$(sed -n 's/^RESCRIPTUM_LISTEN_ADDR=.*:\([0-9]*\)$/\1/p' "$ROOT/etc/$PKG.env" | tail -n 1) +[ -n "$PORT" ] || PORT=8000 + +[ -d "$SHARE/answers" ] && ok "start created the answers directory inside the share" || bad "no $SHARE/answers" +if sudo -u "$PKG" test -w "$SHARE/answers" 2>/dev/null; then + ok "the package user can write it — the ACL landed on the right name" +else + bad "the package user cannot write $SHARE/answers: data-share's permission list and conf/privilege's username disagree" +fi + +PID=$(cat "$ROOT/var/$PKG.pid" 2>/dev/null) +if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then + ok "it survived its own start script (pid $PID)" +else + bad "the service was reaped when start returned — look at KillMode below" + tail -n 10 "$ROOT/var/startup.log" 2>/dev/null | sed 's/^/ /' +fi + +[ "$(curl -fsS "http://127.0.0.1:$PORT/health" 2>/dev/null)" = OK ] && ok "/health answers on $PORT" || bad "/health did not answer on $PORT" + +section "the resource workers, now that the package is enabled" +# The developer guide says the port-config worker copies the file to +# /usr/local/etc/service.d/. On DSM 7.2.2 the directory is **/usr/local/etc/services.d/**, +# plural — SMBService.sc and ScsiTarget.sc live there — and service.d does not exist at all. +# Look in both rather than trusting either. +SC="" +for d in /usr/local/etc/services.d /usr/local/etc/service.d; do + [ -f "$d/$PKG.sc" ] && SC="$d/$PKG.sc" && break +done +if [ -n "$SC" ]; then + ok "the firewall entry was acquired: $SC" + note "it says: $(grep dst.ports "$SC")" + note "postinst wrote: $(grep dst.ports "$ROOT/target/port_conf/$PKG.sc")" + if grep -q "$(grep dst.ports "$ROOT/target/port_conf/$PKG.sc")" "$SC"; then + note "→ the worker acquired AFTER postinst: the wizard's port reaches the firewall" + else + note "→ the worker acquired BEFORE postinst: the .sc ships 8000, and only" + note " 'synopkghelper update $PKG port-config' moves it afterwards" + fi +else + bad "no $PKG.sc in /usr/local/etc/services.d or service.d — the firewall entry will never appear" + note "what is there: $(ls /usr/local/etc/services.d 2>/dev/null | tr '\n' ' ')" +fi + +if [ -e /usr/local/bin/$PKG-cli ]; then + ok "rescriptum-cli is on PATH" +else + bad "usr-local-linker did not link the CLI into /usr/local/bin" +fi + +# ── the whole point: does it actually answer a machine? ──────────────────────── +section "answering a machine, which is what the package exists to do" +# /health proves the process is up. It does not prove that a machine asking for its +# configuration gets one — selection, group membership, merging and the format/endpoint +# binding all sit between the two, and all of them read files from the share as the package +# user. This is the assertion that covers the actual product on the actual machine. +mkdir -p "$SHARE/answers/groups" +cat >"$SHARE/answers/groups/rack.toml" <<'GROUP' +members = ["98:fa:9b:50:d8:10"] + +[global] +keyboard = "fr" +GROUP +cat >"$SHARE/answers/98-fa-9b-50-d8-10.toml" <<'MACHINE' +[global] +fqdn = "rig-machine.example.com" +MACHINE +cat >"$SHARE/answers/default.toml" <<'DEFAULT' +[global] +fqdn = "should-not-be-served.example.com" +DEFAULT +chown -R "$PKG" "$SHARE/answers" 2>/dev/null +chmod -R u+rwX "$SHARE/answers" 2>/dev/null + +# The way a Proxmox installer asks since PVE 8.2: POST, hardware in the body, answer in the +# response. The MAC is only in the body — nothing in the URL says which machine this is. +BODY='{"dmi":{"system":{"serial":"RIG-0001"}},"network_interfaces":[{"mac":"98:FA:9B:50:D8:10","link":true}]}' +ANSWER=$(curl -fsS -m 20 -X POST --data "$BODY" "http://127.0.0.1:$PORT/answer" 2>/dev/null) +if [ -z "$ANSWER" ]; then + bad "a POST with a machine's hardware got nothing back" +else + echo "$ANSWER" | sed 's/^/ | /' + case "$ANSWER" in + *rig-machine.example.com*) ok "the machine's own file was chosen over default.toml" ;; + *) bad "the answer is not this machine's — selection did not work" ;; + esac + case "$ANSWER" in + *'keyboard = "fr"'* | *"keyboard = 'fr'"*) ok "and the group it belongs to was merged in" ;; + *) bad "the group's value is missing — members/merge did not work" ;; + esac + case "$ANSWER" in + *should-not-be-served*) bad "default.toml leaked into a machine's answer" ;; + *members*) bad "the control key 'members' was served to the installer" ;; + *) ok "no default fallback and no control keys in what the installer receives" ;; + esac +fi + +# The other half of the protocol: everything that is not Proxmox GETs, with the machine's +# identity in the query string, because iPXE substitutes it into the URL. +GET=$(curl -fsS -m 20 "http://127.0.0.1:$PORT/answer?mac=98-fa-9b-50-d8-10" 2>/dev/null) +case "$GET" in +*rig-machine.example.com*) ok "a GET with ?mac= gets the same machine's answer" ;; +*) bad "the query-string route did not resolve the machine" ;; +esac + +# An identity nobody claims must fall back to default.toml, not to nothing. +UNKNOWN=$(curl -fsS -m 20 "http://127.0.0.1:$PORT/answer?mac=00-00-00-00-00-01" 2>/dev/null) +case "$UNKNOWN" in +*should-not-be-served*) ok "an unknown machine falls back to default.toml" ;; +*) bad "an unknown machine got no default" ;; +esac + +section "what DSM generated for us (open questions, answered by looking)" +run systemctl cat pkgctl-$PKG +note "Restart= above decides whether DSM restarts the process if it dies." +run sh -c "$ROOT/scripts/start-stop-status status; echo \" exit=\$?\"" + +# ── the desktop application ──────────────────────────────────────────────────── +# Only a machine can answer these: whether DSM made the symlink, whether it serves the +# files, and — the one that matters — whether the backend's door is actually shut. That +# path is **not** authenticated by DSM, measured here on 7.2.2, so an open CGI would be an +# unauthenticated root-adjacent configuration editor on the network. +section "the desktop application" + +LINK=/usr/syno/synoman/webman/3rdparty/rescriptum +if [ -L "$LINK" ] && [ -d "$LINK" ]; then + ok "DSM linked $LINK to the package's ui/" +else + bad "no $LINK — DSM did not pick up dsmuidir" +fi + +JSFILE=$(sed -n 's/^[[:space:]]*"\([^"]*\.js\)"[[:space:]]*:.*/\1/p' "$LINK/config" 2>/dev/null | head -n 1) +if [ -n "$JSFILE" ] && [ -f "$LINK/$JSFILE" ]; then + ok "the application is $JSFILE" +else + bad "ui/config names no JavaScript that is there" +fi + +# The CGI runs as the *owner of the script*, which for a package tree is the package user — +# the same identity that owns the 0600 env file. If DSM ever changes that, everything the +# application does stops working, and this is where it would show. +if [ "$(stat -c '%U' "$LINK/api.cgi" 2>/dev/null)" = "$PKG" ]; then + ok "the backend is owned by $PKG, so it runs as $PKG" +else + bad "api.cgi is owned by $(stat -c '%U' "$LINK/api.cgi" 2>/dev/null), not $PKG" +fi + +# **The door.** No session, no answer. +for path in "api.cgi?action=config" "api.cgi?action=status"; do + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "http://127.0.0.1:5000/webman/3rdparty/rescriptum/$path" 2>/dev/null) + if [ "$code" = "403" ]; then + ok "unauthenticated $path is refused ($code)" + else + bad "unauthenticated $path answered $code — that is an open configuration editor" + fi +done + +# And the JavaScript itself is public by design; it must carry no secret and no placeholder. +if grep -q '@VERSION@\|@JSFILE@' "$LINK/$JSFILE" "$LINK/config" 2>/dev/null; then + bad "the application still has a build placeholder in it" +else + ok "no build placeholders left in the application" +fi + +# ── 4. the CLI, as the package user ──────────────────────────────────────────── +section "the CLI on PATH" +echo 'global.keyboard = "fr"' >"$SHARE/answers/default.toml" +chown "$PKG" "$SHARE/answers/default.toml" 2>/dev/null +run sudo -u "$PKG" /usr/local/bin/$PKG-cli check +# Run as root it succeeds whatever the ACL says, which is what makes the sudo -u form the +# real test. +if sudo -u "$PKG" /usr/local/bin/$PKG-cli check >/dev/null 2>&1; then + ok "sudo -u $PKG rescriptum-cli check passes — the permissions are real" +else + bad "the package user cannot check its own answers" +fi + +# ── 5. logrotate, and the descriptor that must not move ──────────────────────── +section "logrotate" +STANZA=$(find /usr/local/etc/logrotate.d /etc/logrotate.d /usr/syno/etc/logrotate.d -name "*$PKG*" 2>/dev/null | head -n 1) +if [ -z "$STANZA" ]; then + bad "no logrotate stanza installed — syslog-config did not take" +else + ok "installed at $STANZA" + curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 + before=$(stat -c '%i' "$ROOT/var/$PKG.log" 2>/dev/null) + run logrotate -v -f "$STANZA" + after=$(stat -c '%i' "$ROOT/var/$PKG.log" 2>/dev/null) + # `find` without -L stops at /var/packages//var, which DSM makes a symlink — the + # rotated file is there, and this reported "nothing was rotated" for two runs. + # DSM's logrotate compresses with xz, so do not look for .gz either. + if ls "$ROOT/var/$PKG".log.* >/dev/null 2>&1; then + ok "the log was rotated ($(ls "$ROOT/var/$PKG".log.* | head -n 1 | xargs basename))" + else + bad "nothing was rotated" + fi + # The assertion that matters: the first would pass even under a broken configuration. + if [ "$before" = "$after" ] && [ -n "$after" ]; then + ok "the file kept its inode, so the server's open descriptor still points at it" + else + bad "the inode changed ($before → $after) — the server is now writing to a file with no name" + fi + curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 + sleep 1 + grep -q health "$ROOT/var/$PKG.log" 2>/dev/null && ok "and new requests still land in it" || bad "requests stopped reaching the log after a rotation" +fi + +# ── 6. the upgrade, adversarially ────────────────────────────────────────────── +section "an upgrade must not destroy configuration or answers" +# Create the canary rather than assume start already made the directory: if the package +# failed to start, asserting that a file we never wrote "did not survive" reports a +# destroyed answer set where there was none. A test that fails for the wrong reason is +# worse than one that does not run. +mkdir -p "$SHARE/answers" 2>/dev/null +# **Not canary.toml.** A .toml file holding "do not delete me" is a *candidate answer*, and +# `rescriptum-cli check` rightly fails on it — which poisoned the next run, since the share +# is deliberately never cleaned. `txt` is not on the format allowlist, so this file can +# never be mistaken for an answer, which is exactly the property being relied on. +if echo "do not delete me" >"$SHARE/answers/canary.txt" 2>/dev/null; then + CANARY=yes +else + CANARY=no + bad "could not write a canary into $SHARE/answers — the share is not usable" +fi +if [ -f "$ROOT/etc/$PKG.env" ]; then + # Once, not once per run: a duplicate key is a startup error, by design. + grep -q '^RESCRIPTUM_ANSWER_TOKEN=' "$ROOT/etc/$PKG.env" || + printf 'RESCRIPTUM_ANSWER_TOKEN=a-token-nobody-should-lose\n' >>"$ROOT/etc/$PKG.env" + cp "$ROOT/etc/$PKG.env" "$RIG/env.before" +fi + +if [ -f "$SPK2" ]; then + run synopkg install "$SPK2" + sleep 3 + if [ ! -f "$RIG/env.before" ]; then + bad "there was no env file to carry through the upgrade" + elif diff -q "$RIG/env.before" "$ROOT/etc/$PKG.env" >/dev/null 2>&1; then + ok "the hand-edited env file came through the upgrade untouched" + else + bad "the upgrade rewrote the user's configuration:" + diff "$RIG/env.before" "$ROOT/etc/$PKG.env" | sed 's/^/ /' + fi + if [ "$CANARY" = yes ]; then + [ -f "$SHARE/answers/canary.txt" ] && ok "the canary in the share survived the upgrade" || bad "the upgrade destroyed a file in the share" + fi + note "installed version is now $(synopkg version "$PKG" 2>/dev/null)" + note "etc/ and var/ live in /volume1/@appconf and /volume1/@appdata and outlive both" + note "an upgrade and an uninstall — the env file, tokens included, stays on the volume" + if ! curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then + bad "the service does not answer after the upgrade" + tail -n 8 "$ROOT/var/startup.log" 2>/dev/null | sed 's/^/ /' + tail -n 8 "$ROOT/var/$PKG.log" 2>/dev/null | sed 's/^/ /' + else + ok "and the service came back up on its own" + fi +else + note "no build2.spk — skipping the upgrade (this is the most valuable test here)" +fi + +# ── 7. uninstall ─────────────────────────────────────────────────────────────── +section "uninstall must leave the answers alone" +KEEP=$(readlink -f "$SHARE" 2>/dev/null) +run synopkg uninstall "$PKG" +[ -d "$ROOT/target" ] && bad "the package tree is still there" || ok "the package was removed" +if [ "$CANARY" != yes ]; then + note "no canary was ever written, so the uninstall proves nothing about the answers" +elif [ -n "$KEEP" ] && [ -f "$KEEP/answers/canary.txt" ]; then + ok "the shared folder and the canary in it are untouched: $KEEP" +else + bad "the answers did not survive the uninstall — this is the worst outcome available here" +fi +note "the '$PKG' shared folder is left behind on purpose; remove it by hand when you are done" + +echo +if [ "$fails" -gt 0 ]; then + echo "$pass passed, $fails failed" + exit 1 +fi +echo "$pass checks passed" diff --git a/packaging/dsm/vm/run-vm.sh b/packaging/dsm/vm/run-vm.sh new file mode 100755 index 0000000..a5dfda0 --- /dev/null +++ b/packaging/dsm/vm/run-vm.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Boot a DSM 7 virtual machine to test the package against. +# +# packaging/dsm/vm/run-vm.sh --loader ~/dsm/loader.img +# packaging/dsm/vm/run-vm.sh --loader ~/dsm/loader.img --snapshot # discard all writes +# +# **This is the fallback.** The short route is docker-compose.yml beside this file, which +# runs Synology's own Virtual DSM release and needs no image at all — but it needs a Linux +# host with KVM. Use this one when that is not available, or when the machine has to be +# something other than Virtual DSM: it takes whatever loader image you supply and gives it +# the hardware, the disks and the port forwards that matter. See README.md for what the rig +# is evidence about, and what it is not. +# +# What this sets up: +# +# * hardware acceleration where the host has it (KVM on Linux, HVF on an Intel Mac); +# on Apple silicon an x86_64 guest is fully emulated and slow, which is a reason to run +# the rig on a Linux box rather than a reason to skip it; +# * the loader as a USB device with the boot index, which is how these images expect to +# be booted, and a SATA data disk created on first run; +# * user-mode networking with forwards, so no bridge and no root: DSM's web UI on +# localhost:5000/5001, ssh on 2222, and the answer port on 8000; +# * --snapshot, so a package that breaks the machine costs one Ctrl-C. Take a real +# qcow2 snapshot once DSM is installed and configured: +# qemu-img snapshot -c clean dsm-data.qcow2 +# qemu-img snapshot -a clean dsm-data.qcow2 + +set -euo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) + +LOADER="" +DISK="$HERE/dsm-data.qcow2" +SIZE=32G +MEM=2048 +CPUS=2 +SSH_PORT=2222 +WEB_PORT=5000 +WEBS_PORT=5001 +APP_PORT=8000 +NIC=e1000e +SNAPSHOT="" + +while [ $# -gt 0 ]; do + case "$1" in + --loader) LOADER="$2"; shift 2 ;; + --disk) DISK="$2"; shift 2 ;; + --size) SIZE="$2"; shift 2 ;; + --mem) MEM="$2"; shift 2 ;; + --cpus) CPUS="$2"; shift 2 ;; + --ssh) SSH_PORT="$2"; shift 2 ;; + --port) APP_PORT="$2"; shift 2 ;; + --nic) NIC="$2"; shift 2 ;; + --snapshot) SNAPSHOT="-snapshot"; shift ;; + -h | --help) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +[ -n "$LOADER" ] || { echo "--loader is required (README.md says where an image comes from)" >&2; exit 2; } +[ -f "$LOADER" ] || { echo "no loader image at $LOADER" >&2; exit 1; } +command -v qemu-system-x86_64 >/dev/null || { echo "qemu-system-x86_64 is not installed" >&2; exit 1; } + +if [ ! -f "$DISK" ]; then + echo "==> creating a $SIZE data disk at $DISK" + qemu-img create -f qcow2 "$DISK" "$SIZE" >/dev/null +fi + +# Accelerate where we can, emulate where we cannot, and say which — a rig that is silently +# running under TCG looks like a rig that is broken. +ACCEL=tcg +CPU=qemu64 +case "$(uname -s)/$(uname -m)" in +Linux/x86_64) if [ -w /dev/kvm ]; then ACCEL=kvm; CPU=host; fi ;; +Darwin/x86_64) ACCEL=hvf; CPU=host ;; +esac +if [ "$ACCEL" = tcg ]; then + echo "==> no hardware acceleration here: the guest is emulated, and it will be slow" +fi + +echo "==> DSM will be at http://localhost:$WEB_PORT (https on $WEBS_PORT), ssh on $SSH_PORT" +echo "==> rescriptum's port is forwarded from localhost:$APP_PORT" +echo "==> then: packaging/dsm/vm/on-dsm.sh admin@localhost -p $SSH_PORT" +if [ -n "$SNAPSHOT" ]; then + echo "==> --snapshot: every write to $DISK is discarded when this exits" +fi + +exec qemu-system-x86_64 \ + -machine q35,accel="$ACCEL" \ + -cpu "$CPU" \ + -smp "$CPUS" \ + -m "$MEM" \ + $SNAPSHOT \ + -device qemu-xhci,id=xhci \ + -drive file="$LOADER",format=raw,if=none,id=loader \ + -device usb-storage,bus=xhci.0,drive=loader,bootindex=1 \ + -device ahci,id=ahci \ + -drive file="$DISK",format=qcow2,if=none,id=data \ + -device ide-hd,bus=ahci.0,drive=data \ + -netdev user,id=net0,hostfwd=tcp::"$SSH_PORT"-:22,hostfwd=tcp::"$WEB_PORT"-:5000,hostfwd=tcp::"$WEBS_PORT"-:5001,hostfwd=tcp::"$APP_PORT"-:"$APP_PORT" \ + -device "$NIC",netdev=net0 \ + -display none -serial mon:stdio diff --git a/packaging/dsm/vm/snapshot.sh b/packaging/dsm/vm/snapshot.sh new file mode 100755 index 0000000..f476fae --- /dev/null +++ b/packaging/dsm/vm/snapshot.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Freeze and restore the rig's DSM machine — entirely inside Docker. +# +# packaging/dsm/vm/snapshot.sh save # after the wizard, once +# packaging/dsm/vm/snapshot.sh restore # back to that state, any time +# packaging/dsm/vm/snapshot.sh list +# packaging/dsm/vm/snapshot.sh save clean-7.2 # a name, if you want several +# +# DSM's setup wizard cannot be scripted — the image offers no admin-account variable — so +# the machine is worth exactly one manual setup. Snapshot it immediately afterwards and the +# rig becomes repeatable: `remote-check.sh` is destructive on purpose, and getting back to a +# known-good DSM has to cost one command rather than an afternoon. +# +# The copy is **volume to volume**, not a tarball on the host, and that is deliberate: on a +# Mac, Docker's disk image is already allocated, so a copy inside it costs no host disk at +# all — which is the same reason the machine's storage is a named volume in the first place. +# `--file` writes a tarball instead, when you want it somewhere you can see. +# +# **It copies with GNU cp and --sparse=always, and that is not a detail.** DSM's storage is +# a handful of raw disk images — a 16 GiB data.img holding 4 GiB — and busybox's cp writes +# every hole out as real zeroes. Done that way a 3.9 GB machine snapshots to 26 GB and the +# restore fills Docker's disk before it finishes, leaving a broken machine and no snapshot +# worth the name. Measured, the hard way. + +set -euo pipefail + +VOLUME="${DSM_VOLUME:-}" +if [ -z "$VOLUME" ]; then + VOLUME=$(docker volume ls --format '{{.Name}}' | grep -m 1 'dsm-storage$' || true) +fi +[ -n "$VOLUME" ] || { echo "no DSM storage volume found — start the machine first, or set DSM_VOLUME" >&2; exit 1; } + +CONTAINER="${DSM_CONTAINER:-rescriptum-dsm}" +ACTION="${1:-}" +NAME="${2:-clean}" +SNAP="dsm-snapshot-$NAME" +FILE="" +case "${3:-}" in --file) FILE="$NAME.tar" ;; esac + +# A snapshot taken while DSM is writing is a snapshot of a half-written filesystem. +stopped() { [ -z "$(docker ps -q --filter "name=^${CONTAINER}$")" ]; } +require_stopped() { + if ! stopped; then + echo "==> stopping $CONTAINER first (a running machine is still writing)" + docker stop "$CONTAINER" >/dev/null + fi +} + +case "$ACTION" in +save) + require_stopped + if [ -n "$FILE" ]; then + docker run --rm -v "$VOLUME":/from -v "$PWD":/to debian:stable-slim \ + tar -cSf "/to/$FILE" -C /from . + echo "==> $PWD/$FILE" + else + docker volume rm "$SNAP" >/dev/null 2>&1 || true + docker volume create "$SNAP" >/dev/null + docker run --rm -v "$VOLUME":/from -v "$SNAP":/to debian:stable-slim \ + cp -a --sparse=always /from/. /to/ + echo "==> saved $VOLUME to the volume $SNAP" + fi + ;; +restore) + require_stopped + docker volume inspect "$SNAP" >/dev/null 2>&1 || { echo "no snapshot named $NAME (try: $0 list)" >&2; exit 1; } + docker run --rm -v "$VOLUME":/to -v "$SNAP":/from debian:stable-slim \ + sh -c 'rm -rf /to/..?* /to/.[!.]* /to/* 2>/dev/null; cp -a --sparse=always /from/. /to/' + echo "==> $VOLUME restored from $SNAP — start the machine again" + ;; +list) + docker volume ls --format '{{.Name}}' | grep '^dsm-snapshot-' | sed 's/^dsm-snapshot-/ /' || echo " (none)" + ;; +*) + sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' + exit 2 + ;; +esac diff --git a/src/cli.rs b/src/cli.rs index 5413a00..00dbd82 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -23,6 +23,11 @@ USAGE: rescriptum check validate the configured store rescriptum import load a directory of TOML into the store rescriptum export write the store out as a directory of TOML + rescriptum config show the configuration, and where each value comes from + rescriptum config --json the same, for a settings panel + rescriptum config --value K one value, for a script (never a credential) + rescriptum config set K=V edit the file RESCRIPTUM_ENV_FILE names + rescriptum config unset K comment a setting back out of it rescriptum --help ENVIRONMENT: @@ -405,3 +410,325 @@ fn copy( ExitCode::FAILURE } } + +// ---- config --------------------------------------------------------------- + +/// `config` / `config --json` / `config set KEY=VALUE` / `config unset KEY` +/// +/// **This one deliberately does not take a `Config`.** Every other subcommand is handed +/// one that `main` already built and validated, which is exactly what cannot be relied on +/// here: a file that will not parse, or a token one character too short, are the states in +/// which somebody reaches for this command. It loads the file itself, reports what is +/// wrong rather than dying of it, and is the way back out. +/// +/// The exit code is a contract, like `check`'s: **zero when the configuration would +/// start**, one when it would not, or when a write was refused. +pub fn config(args: &[String]) -> ExitCode { + let path = std::env::var(crate::envfile::ENV_FILE) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + + match args.split_first() { + None => show(path.as_deref(), false), + Some((flag, rest)) if flag == "--json" && rest.is_empty() => show(path.as_deref(), true), + Some((flag, rest)) if flag == "--value" && rest.len() == 1 => { + value(path.as_deref(), &rest[0]) + } + Some((cmd, rest)) if cmd == "set" && !rest.is_empty() => edit(path.as_deref(), rest, true), + Some((cmd, rest)) if cmd == "unset" && !rest.is_empty() => { + edit(path.as_deref(), rest, false) + } + _ => { + eprintln!( + "usage: rescriptum config\n\ + \x20 rescriptum config --json\n\ + \x20 rescriptum config --value KEY\n\ + \x20 rescriptum config set KEY=VALUE [KEY=VALUE …]\n\ + \x20 rescriptum config unset KEY [KEY …]" + ); + ExitCode::FAILURE + } + } +} + +/// One value, on stdout, for a script that wants it. +/// +/// The alternative is a shell reading the env file with `sed`, which gets the *defaults* +/// wrong: a variable absent from the file is not unset, it is whatever this program falls +/// back to. Precedence goes the same way — the environment beats the file — and neither is +/// visible to something grepping a file. +/// +/// **A secret is never printed**, whatever is asked. Exit code one means "no such value", +/// so `if v=$(rescriptum config --value KEY)` reads correctly. +fn value(path: Option<&str>, key: &str) -> ExitCode { + let Some(known) = crate::config::KNOWN.iter().find(|k| k.key == key) else { + eprintln!("{key} is not a variable this program reads"); + return ExitCode::FAILURE; + }; + if known.secret { + eprintln!("{key} is a credential and will not be printed"); + return ExitCode::FAILURE; + } + + let (file, _, unreadable) = load_file(path); + if let Some(reason) = unreadable { + eprintln!("{reason}"); + return ExitCode::FAILURE; + } + match crate::config::settings(file.as_ref(), from_environment) + .into_iter() + .find(|s| s.key == key) + .and_then(|s| s.value) + { + Some(v) => { + println!("{v}"); + ExitCode::SUCCESS + } + None => ExitCode::FAILURE, + } +} + +/// Load the named file, if one is named at all. +/// +/// A file that will not parse still **prints**, rather than being a single error line: +/// seeing the other twelve variables next to the reason the file is broken is what makes +/// this usable. It is returned separately from the warnings because it is not one — an +/// unreadable env file is a startup *error*, so it has to reach the exit code. +fn load_file(path: Option<&str>) -> (Option, Vec, Option) { + match path { + None => (None, Vec::new(), None), + Some(p) => match crate::envfile::EnvFile::load(p) { + Ok(file) => { + let warnings = file.warnings.clone(); + (Some(file), warnings, None) + } + Err(e) => (None, Vec::new(), Some(e)), + }, + } +} + +/// The environment as `settings` and `Config` both want to read it. +fn from_environment(key: &str) -> Option { + std::env::var(key).ok() +} + +/// Rebuild the configuration exactly as the server would, from a file plus the real +/// environment, so that what this command reports is what would actually happen. +fn effective(file: Option<&crate::envfile::EnvFile>) -> Config { + Config::from_lookup(|key| { + std::env::var(key) + .ok() + .filter(|v| !v.trim().is_empty()) + .or_else(|| file.and_then(|f| f.get(key))) + }) +} + +fn show(path: Option<&str>, as_json: bool) -> ExitCode { + let (file, problems, unreadable) = load_file(path); + let settings = crate::config::settings(file.as_ref(), from_environment); + // Either of these stops a server starting, so either of them is the answer here. + // A file that cannot be read comes first: it is the more basic failure, and the + // configuration `validate` would inspect is not the one the operator wrote. + let refusal = unreadable.or_else(|| effective(file.as_ref()).validate().err()); + + if as_json { + println!( + "{}", + as_json_text(path, &settings, &problems, refusal.as_deref()) + ); + } else { + match path { + Some(p) => println!("env file: {p}"), + // Not an error. Plenty of deployments configure a container or a unit file + // and have nothing for this to edit; saying so beats an empty line. + None => println!( + "env file: none — {} names one, and nothing does", + crate::envfile::ENV_FILE + ), + } + println!(); + + let width = settings.iter().map(|s| s.key.len()).max().unwrap_or(0); + for s in &settings { + let shown = match (&s.value, s.secret, s.set) { + (_, true, true) => "(set)".to_string(), + (Some(v), _, _) => v.clone(), + _ => "(not set)".to_string(), + }; + println!(" {:, + settings: &[crate::config::Setting], + problems: &[String], + refusal: Option<&str>, +) -> String { + let rows: Vec = settings + .iter() + .map(|s| { + serde_json::json!({ + "key": s.key, + // Null for a secret, always: this is what anything reading the output + // renders, and it must not be able to render a token by accident. + "value": s.value, + "set": s.set, + "source": s.source.label(), + "default": s.default, + "secret": s.secret, + "help": s.help, + }) + }) + .collect(); + + serde_json::json!({ + "env_file": path, + "writable": path.is_some_and(writable), + "settings": rows, + "warnings": problems, + "starts": refusal.is_none(), + "error": refusal, + }) + .to_string() +} + +/// Whether this process could actually rewrite the file — which is not the same question +/// as whether it exists. A panel that offered an editable form over a file it cannot write +/// would fail at the save button, having promised otherwise. +/// +/// Asked by trying rather than by reading permission bits, so that ownership, groups, ACLs +/// and a read-only mount all count — the same reasoning as the answers-directory check at +/// startup, and the same failure a packaged, non-root run meets first. Opening for append +/// changes nothing; the handle is dropped unused. +fn writable(path: &str) -> bool { + let path = std::path::Path::new(path); + if path.exists() { + return std::fs::OpenOptions::new().append(true).open(path).is_ok(); + } + // Not there yet, so it would be created and the directory is the real question. + let dir = path.parent().unwrap_or(std::path::Path::new(".")); + let probe = dir.join(format!(".rescriptum-writable.{}", std::process::id())); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + +fn edit(path: Option<&str>, args: &[String], setting: bool) -> ExitCode { + let Some(path) = path else { + eprintln!( + "there is no file to edit: {} names one, and nothing does", + crate::envfile::ENV_FILE + ); + return ExitCode::FAILURE; + }; + + let mut changes: std::collections::BTreeMap> = Default::default(); + for arg in args { + let (key, value) = if setting { + match arg.split_once('=') { + Some((k, v)) => (k.trim().to_string(), Some(v.to_string())), + None => { + eprintln!("expected KEY=VALUE, found {arg:?}"); + return ExitCode::FAILURE; + } + } + } else { + (arg.trim().to_string(), None) + }; + + // A misspelled name would otherwise be written, read back as a stranger, and + // warned about only at the next start — by which time nobody connects the two. + if !crate::envfile::KNOWN_KEYS.contains(&key.as_str()) { + eprintln!("{key} is not a variable this program reads — check the spelling"); + return ExitCode::FAILURE; + } + if changes.insert(key.clone(), value).is_some() { + eprintln!("{key} given twice"); + return ExitCode::FAILURE; + } + } + + // The file has to be readable and sound before it can be edited: rewriting one that + // does not parse would bake the mistake in rather than report it. + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + // A file that is not there yet is one to create — that is how a fresh deployment + // gets its first setting. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => { + eprintln!("cannot read {path}: {e}"); + return ExitCode::FAILURE; + } + }; + if let Err(e) = crate::envfile::parse(&text) { + eprintln!("{path} does not parse, so it will not be edited: {e}"); + return ExitCode::FAILURE; + } + + let rewritten = match crate::envfile::rewrite(&text, &changes) { + Ok(text) => text, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + // **A write may never leave a server that cannot start.** The same reasoning as the + // admin API's rollback: the panel doing the writing is reached over the very service + // this would stop, so getting it wrong costs somebody an SSH session at best. + let parsed = match crate::envfile::parse(&rewritten) { + Ok(vars) => vars, + Err(e) => { + eprintln!("refusing to write a file this program could not read back: {e}"); + return ExitCode::FAILURE; + } + }; + let would = Config::from_lookup(|key| { + std::env::var(key) + .ok() + .filter(|v| !v.trim().is_empty()) + .or_else(|| parsed.get(key).cloned()) + }); + if let Err(reason) = would.validate() { + eprintln!("refused: this would leave a server that cannot start — {reason}"); + return ExitCode::FAILURE; + } + + if let Err(e) = crate::envfile::write_atomic(std::path::Path::new(path), &rewritten) { + eprintln!("cannot write {path}: {e}"); + return ExitCode::FAILURE; + } + + // Said last, and only on success. A value the environment overrides is written all + // the same — the file is still the record — but pretending it took effect would be + // the silent half-failure this whole module exists to remove. + for key in changes.keys() { + if std::env::var(key).is_ok_and(|v| !v.trim().is_empty()) { + eprintln!( + "note: {key} is also set in the environment, which wins — this file will \ + not change what a server started from it uses" + ); + } + } + eprintln!("wrote {path}"); + ExitCode::SUCCESS +} diff --git a/src/config.rs b/src/config.rs index 44876fe..bfb01cb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -262,6 +262,196 @@ impl Config { } } +/// One configuration variable, **described** rather than merely read. +/// +/// `from_lookup` above knows how to interpret each of these. This table is what anything +/// that has to *present* one needs instead: its default, whether printing it would hand +/// out a credential, and a line saying what it does. They sit in the same file so that +/// adding a variable to one and forgetting the other is a test failure rather than a +/// setting nobody can see. +pub struct Known { + pub key: &'static str, + /// What is in force when nothing sets it, written the way the file would write it. + /// `None` means the feature is simply off until someone turns it on. + pub default: Option<&'static str>, + /// A credential: its value is never printed, and never leaves this process. + pub secret: bool, + /// One line, for whatever has to label the field. + pub help: &'static str, +} + +/// Every variable, in the order a person would want to meet them: what answers come +/// from, where the server listens, how much it says, then the two credentials. +pub const KNOWN: [Known; 13] = [ + Known { + key: "RESCRIPTUM_STORE", + default: Some("files"), + secret: false, + help: "Where answers come from: a directory of documents, or a database.", + }, + Known { + key: "RESCRIPTUM_ANSWERS_DIR", + default: Some(DEFAULT_ANSWERS_DIR), + secret: false, + help: "The directory of answer documents, when the store is files.", + }, + Known { + key: "RESCRIPTUM_DB_PATH", + default: Some(DEFAULT_DB_PATH), + secret: false, + help: "The SQLite database, when the store is sqlite.", + }, + Known { + key: "RESCRIPTUM_LISTEN_ADDR", + default: Some(DEFAULT_LISTEN_ADDR), + secret: false, + help: "Where installers reach the answer endpoint.", + }, + Known { + // The only default that is not a constant: it is this machine's CPU count, so + // `settings` fills it in rather than the table claiming a number it cannot know. + key: "RESCRIPTUM_WORKERS", + default: None, + secret: false, + help: "Runtime threads. Not a concurrency limit; the default is the CPU count.", + }, + Known { + key: "RESCRIPTUM_MAX_CONNECTIONS", + default: Some("2048"), + secret: false, + help: "In-flight connections before a burst is shed with 503 rather than queued.", + }, + Known { + key: "RESCRIPTUM_TIMEOUT_SECS", + default: Some("10"), + secret: false, + help: "Header-read timeout, and the whole-connection deadline.", + }, + Known { + key: "RESCRIPTUM_LOG", + default: Some("all"), + secret: false, + help: "all, problems (drops the requests that worked), or off.", + }, + Known { + key: "RESCRIPTUM_LOG_FILE", + default: None, + secret: false, + help: "A file to append to, or stdout or stderr. Unset means stderr.", + }, + Known { + key: "RESCRIPTUM_CAPTURE_DIR", + default: None, + secret: false, + help: "Record what installers actually send, for when nothing is answered.", + }, + Known { + key: "RESCRIPTUM_ANSWER_TOKEN", + default: None, + secret: true, + help: "Required of installers, when they have one to offer. Off by default.", + }, + Known { + key: "RESCRIPTUM_ADMIN_ADDR", + default: None, + secret: false, + help: "The write API's own listener. Off unless set; keep it on loopback.", + }, + Known { + key: "RESCRIPTUM_ADMIN_TOKEN", + default: None, + secret: true, + help: "Bearer token for the write API. At least 16 characters, and required.", + }, +]; + +/// Which of the three places a value came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// The process environment, which **wins over the file**. + Environment, + /// The file `RESCRIPTUM_ENV_FILE` names. + File, + /// Nothing set it. + Default, +} + +impl Source { + pub fn label(self) -> &'static str { + match self { + Source::Environment => "environment", + Source::File => "file", + Source::Default => "default", + } + } +} + +/// A variable as it currently stands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Setting { + pub key: &'static str, + /// What is in force. **Always `None` for a secret** — this type does not carry one. + pub value: Option, + /// Whether anything is in force at all. For a secret this is the whole story. + pub set: bool, + pub source: Source, + pub default: Option, + pub secret: bool, + pub help: &'static str, +} + +/// Describe every variable: what is in force, and **which of the file and the environment +/// put it there**. +/// +/// That distinction is the entire reason this returns a source rather than a map. The +/// file supplies defaults and the real environment wins, so anything offering to edit the +/// file has to know when doing so would change nothing — and say so, rather than write a +/// value the running server will keep ignoring. +/// +/// Empty counts as unset in both places, exactly as `from_lookup` treats it: an +/// exported-but-empty variable is a mistake, not an instruction. +pub fn settings( + file: Option<&crate::envfile::EnvFile>, + env: impl Fn(&str) -> Option, +) -> Vec { + let useful = |v: String| -> Option { + let v = v.trim().to_string(); + (!v.is_empty()).then_some(v) + }; + + KNOWN + .iter() + .map(|known| { + let from_env = env(known.key).and_then(useful); + let from_file = file.and_then(|f| f.get(known.key)).and_then(useful); + + let source = if from_env.is_some() { + Source::Environment + } else if from_file.is_some() { + Source::File + } else { + Source::Default + }; + + let default = match known.key { + "RESCRIPTUM_WORKERS" => Some(default_workers().to_string()), + _ => known.default.map(str::to_string), + }; + let value = from_env.or(from_file).or_else(|| default.clone()); + + Setting { + key: known.key, + set: value.is_some(), + value: if known.secret { None } else { value }, + source, + default, + secret: known.secret, + help: known.help, + } + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -463,4 +653,118 @@ mod tests { let c = Config::from_lookup(lookup(&[("RESCRIPTUM_LISTEN_ADDR", " 0.0.0.0:8080 ")])); assert_eq!(c.listen_addr, "0.0.0.0:8080"); } + + // ---- the described surface ------------------------------------------- + + #[test] + fn every_variable_is_described_exactly_once() { + // Two lists of the same thing drift. `KNOWN_KEYS` is what reports a typo in the + // file; `KNOWN` is what a settings panel shows. A variable in one and not the + // other is either invisible or unwarned about, and neither failure announces + // itself. + let described: Vec<&str> = KNOWN.iter().map(|k| k.key).collect(); + let mut sorted = described.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), described.len(), "a key is described twice"); + + for key in crate::envfile::KNOWN_KEYS { + assert!(described.contains(&key), "{key} is read but not described"); + } + for key in described { + assert!( + crate::envfile::KNOWN_KEYS.contains(&key), + "{key} is described but not read" + ); + } + } + + /// An `EnvFile` can only be loaded from a real file, which is the point of it. + fn env_file(name: &str, body: &str) -> (std::path::PathBuf, crate::envfile::EnvFile) { + let dir = std::env::temp_dir().join(format!("pve-settings-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rescriptum.env"); + std::fs::write(&path, body).unwrap(); + let file = crate::envfile::EnvFile::load(&path).expect("parses"); + (dir, file) + } + + fn setting<'a>(settings: &'a [Setting], key: &str) -> &'a Setting { + settings.iter().find(|s| s.key == key).expect("described") + } + + #[test] + fn the_environment_is_reported_as_beating_the_file() { + // The file is defaults. Offering to edit a value the environment is overriding + // would be offering to change nothing, which is worse than refusing. + let (dir, file) = env_file( + "override", + "RESCRIPTUM_LISTEN_ADDR=0.0.0.0:8000\nRESCRIPTUM_LOG=problems\n", + ); + let s = settings(Some(&file), |k| { + (k == "RESCRIPTUM_LISTEN_ADDR").then(|| "127.0.0.1:9999".to_string()) + }); + + let addr = setting(&s, "RESCRIPTUM_LISTEN_ADDR"); + assert_eq!(addr.source, Source::Environment); + assert_eq!(addr.value.as_deref(), Some("127.0.0.1:9999")); + + let log = setting(&s, "RESCRIPTUM_LOG"); + assert_eq!(log.source, Source::File); + assert_eq!(log.value.as_deref(), Some("problems")); + + let store = setting(&s, "RESCRIPTUM_STORE"); + assert_eq!(store.source, Source::Default); + assert_eq!(store.value.as_deref(), Some("files")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_secret_reports_that_it_is_set_and_nothing_else() { + // This is the whole reason `value` is separate from `set`: a settings panel has + // to show that a token exists without ever being handed one. + let (dir, file) = env_file("secret", "RESCRIPTUM_ADMIN_TOKEN=0123456789abcdef0\n"); + let s = settings(Some(&file), |_| None); + + let token = setting(&s, "RESCRIPTUM_ADMIN_TOKEN"); + assert!(token.secret); + assert!(token.set, "it is set"); + assert_eq!(token.value, None, "a secret's value must never be carried"); + assert_eq!(token.source, Source::File); + + let unset = setting(&s, "RESCRIPTUM_ANSWER_TOKEN"); + assert!(!unset.set); + assert_eq!(unset.source, Source::Default); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_empty_value_counts_as_unset_in_both_places() { + // Matching `from_lookup`, where an exported-but-empty variable is a mistake + // rather than an instruction — otherwise the panel and the server would disagree + // about what is in force. + let (dir, file) = env_file("empty", "RESCRIPTUM_LOG=\n"); + let s = settings(Some(&file), |k| { + (k == "RESCRIPTUM_STORE").then(|| " ".to_string()) + }); + + assert_eq!(setting(&s, "RESCRIPTUM_LOG").source, Source::Default); + assert_eq!(setting(&s, "RESCRIPTUM_LOG").value.as_deref(), Some("all")); + assert_eq!(setting(&s, "RESCRIPTUM_STORE").source, Source::Default); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_one_default_that_is_not_a_constant_is_filled_in() { + // The CPU count is this machine's, so the table cannot hold it and `settings` + // has to. A panel showing "default: (none)" for workers would be wrong. + let s = settings(None, |_| None); + let workers = setting(&s, "RESCRIPTUM_WORKERS"); + assert_eq!(workers.default, Some(default_workers().to_string())); + assert_eq!(workers.value, Some(default_workers().to_string())); + assert!(workers.set); + } } diff --git a/src/envfile.rs b/src/envfile.rs index cf6f905..4762d8f 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -188,6 +188,202 @@ fn is_valid_key(key: &str) -> bool { && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } +/// Apply changes to the **text** of an env file, leaving everything else exactly as it is. +/// +/// The file a program writes here is the same file a person edits by hand, and on a +/// packaged install it is the only documentation the configuration has — the template +/// `postinst` lays down explains every variable in comments above it. A writer that +/// regenerated the file would throw all of that away the first time anyone changed a +/// setting, so this one edits lines where they stand. +/// +/// Three cases, tried in that order: +/// +/// * a **live** assignment is replaced in place, keeping its indentation and its `export`; +/// * a **commented** one is uncommented and set — which is how the template's +/// `# RESCRIPTUM_STORE=sqlite` becomes a real setting instead of a duplicate appearing +/// at the bottom of the file with its explanation left behind; +/// * anything else is appended. +/// +/// `None` comments a setting out rather than deleting it, so the paragraph explaining it +/// survives and setting it again lands back in the same place. +/// +/// Keys are not checked here: whether a name is one this program reads is the caller's +/// question, and `KNOWN_KEYS` is where it is answered. What *is* refused here is a value +/// this file could not carry back — the parser has no escapes, so a value it would not +/// return unchanged must not be written rather than written and silently misread. +pub fn rewrite(text: &str, changes: &BTreeMap>) -> Result { + let mut lines: Vec = text.lines().map(str::to_string).collect(); + let mut appended: Vec = Vec::new(); + + for (key, change) in changes { + if !is_valid_key(key) { + return Err(format!("{key:?} is not a usable variable name")); + } + + let live = lines + .iter() + .position(|l| assignment_prefix(l, key).is_some()); + + match change { + Some(value) => { + let rendered = render_value(key, value)?; + if let Some(n) = live { + let prefix = assignment_prefix(&lines[n], key).unwrap_or_default(); + lines[n] = format!("{prefix}{key}={rendered}"); + continue; + } + let commented = lines + .iter() + .position(|l| commented_prefix(l, key).is_some()); + if let Some(n) = commented { + let prefix = commented_prefix(&lines[n], key).unwrap_or_default(); + lines[n] = format!("{prefix}{key}={rendered}"); + continue; + } + appended.push(format!("{key}={rendered}")); + } + None => { + // Only a live line means anything here. An already-commented one is + // already unset, and appending a comment saying so would be noise. + if let Some(n) = live { + let indent: String = + lines[n].chars().take_while(|c| c.is_whitespace()).collect(); + lines[n] = format!("{indent}# {}", &lines[n][indent.len()..]); + } + } + } + } + + let mut out = String::new(); + for line in lines.iter().chain(appended.iter()) { + out.push_str(line); + out.push('\n'); + } + Ok(out) +} + +/// Recognise `KEY=` on a line, and return everything that should stay in front of it — +/// the indentation and an `export` if the line carried one. `None` when this line is not +/// an assignment to this key. +fn assignment_prefix(line: &str, key: &str) -> Option { + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + let rest = &line[indent.len()..]; + let (export, rest) = match rest.strip_prefix("export ") { + Some(r) => ("export ", r.trim_start()), + None => ("", rest), + }; + // `RESCRIPTUM_STORE_X=1` must not answer for `RESCRIPTUM_STORE`: after the name only + // whitespace and the `=` may follow. + let rest = rest.strip_prefix(key)?; + rest.trim_start().strip_prefix('=')?; + Some(format!("{indent}{export}")) +} + +/// The same, for a line that is commented out. `#KEY=`, `# KEY=` and `# export KEY=` all +/// count — a template written by hand does not keep to one of them. +fn commented_prefix(line: &str, key: &str) -> Option { + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + let rest = line[indent.len()..].strip_prefix('#')?.trim_start(); + let inner = assignment_prefix(rest, key)?; + Some(format!("{indent}{inner}")) +} + +/// How a value has to be written so that `parse` gives it back unchanged. +/// +/// There are no escape sequences in this format, deliberately, which means some values +/// cannot be represented at all. Refusing those is the only honest option: writing one +/// and reading back something else is the silent failure this whole module exists to +/// remove. +fn render_value(key: &str, value: &str) -> Result { + if let Some(c) = value.chars().find(|c| c.is_control()) { + return Err(format!( + "{key}: a value cannot contain {c:?} — one line, one setting" + )); + } + // A value that already looks quoted would come back stripped of its own first and + // last character. + let bytes = value.as_bytes(); + if bytes.len() >= 2 { + let (first, last) = (bytes[0], bytes[bytes.len() - 1]); + if (first == b'"' || first == b'\'') && first == last { + return Err(format!( + "{key}: a value that begins and ends with a quote cannot be written to \ + this file — it would be read back without them" + )); + } + } + if value == value.trim() { + return Ok(value.to_string()); + } + // Only quoting protects the whitespace, and only a value without quotes of its own + // can be quoted. + if value.contains('"') { + return Err(format!( + "{key}: a value with leading or trailing whitespace cannot also contain a \ + double quote" + )); + } + Ok(format!("\"{value}\"")) +} + +/// Replace a file's contents without a reader ever seeing half of them, and **without +/// changing who owns it**. +/// +/// The rename is the atomic part, and it is the same trick the file store uses. The +/// ownership is the part that is easy to miss and expensive to get wrong: on a packaged +/// install this file is `0600` and owned by the service's own user, so a rewrite by root +/// that left a root-owned file behind would mean the service could no longer read its own +/// configuration — and the symptom would be a server that stops starting, one restart +/// later, for no reason anybody changed. +pub fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> { + let dir = path.parent().unwrap_or(Path::new(".")); + let name = path.file_name().map_or_else( + || std::ffi::OsString::from("env"), + std::ffi::OsStr::to_os_string, + ); + let mut tmp = std::ffi::OsString::from("."); + tmp.push(&name); + tmp.push(format!(".tmp.{}", std::process::id())); + let tmp = dir.join(tmp); + + let existing = std::fs::metadata(path).ok(); + std::fs::write(&tmp, text)?; + + if let Err(e) = preserve(&tmp, existing.as_ref()) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + if let Err(e) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) +} + +/// Carry the old file's mode and ownership onto the new one. A file that did not exist +/// gets `0600`: this one holds tokens, and inheriting a umask would be how it ends up +/// world-readable on somebody's NAS. +#[cfg(unix)] +fn preserve(tmp: &Path, existing: Option<&std::fs::Metadata>) -> std::io::Result<()> { + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + let mode = existing.map_or(0o600, |m| m.permissions().mode() & 0o7777); + std::fs::set_permissions(tmp, std::fs::Permissions::from_mode(mode))?; + + if let Some(meta) = existing { + // Only root can give a file away, and only root needs to: anyone else is already + // writing as the owner. A refusal here is therefore not an error. + let _ = std::os::unix::fs::chown(tmp, Some(meta.uid()), Some(meta.gid())); + } + Ok(()) +} + +#[cfg(not(unix))] +fn preserve(_tmp: &Path, _existing: Option<&std::fs::Metadata>) -> std::io::Result<()> { + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -343,4 +539,176 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + // ---- rewriting ------------------------------------------------------- + + fn changes(pairs: &[(&str, Option<&str>)]) -> BTreeMap> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.map(str::to_string))) + .collect() + } + + /// What `parse` makes of a rewrite — the only thing that actually matters about one. + fn reparse(text: &str) -> BTreeMap { + parse(text).expect("a rewrite must produce a file this parser accepts") + } + + #[test] + fn a_setting_is_replaced_where_it_stands() { + // The comment above a setting is the only documentation a packaged install has. + // Regenerating the file would lose it the first time anybody changed anything. + let before = "# Which store answers come from.\nRESCRIPTUM_STORE=files\n\n# The log.\nRESCRIPTUM_LOG=all\n"; + let after = rewrite(before, &changes(&[("RESCRIPTUM_STORE", Some("sqlite"))])).unwrap(); + + assert!( + after.contains("# Which store answers come from."), + "{after}" + ); + assert!(after.contains("# The log."), "{after}"); + assert_eq!(reparse(&after)["RESCRIPTUM_STORE"], "sqlite"); + assert_eq!(reparse(&after)["RESCRIPTUM_LOG"], "all"); + // Replaced, not appended: exactly one line mentions it. + assert_eq!( + after + .lines() + .filter(|l| l.contains("RESCRIPTUM_STORE")) + .count(), + 1, + "{after}" + ); + } + + #[test] + fn a_commented_setting_is_uncommented_rather_than_duplicated() { + // The DSM template ships several of these — `# RESCRIPTUM_STORE=sqlite` with a + // paragraph above explaining it. Appending a second one at the bottom of the file + // would leave the explanation attached to the wrong line. + let before = "# Not the default, because the package user cannot write it.\n# RESCRIPTUM_STORE=sqlite\n"; + let after = rewrite(before, &changes(&[("RESCRIPTUM_STORE", Some("sqlite"))])).unwrap(); + + assert_eq!(reparse(&after)["RESCRIPTUM_STORE"], "sqlite"); + assert!(after.contains("# Not the default"), "{after}"); + assert_eq!(after.lines().count(), 2, "{after}"); + } + + #[test] + fn a_setting_the_file_never_mentioned_is_appended() { + let after = rewrite( + "RESCRIPTUM_STORE=files\n", + &changes(&[("RESCRIPTUM_LOG", Some("problems"))]), + ) + .unwrap(); + assert_eq!(reparse(&after)["RESCRIPTUM_LOG"], "problems"); + assert_eq!(reparse(&after)["RESCRIPTUM_STORE"], "files"); + } + + #[test] + fn indentation_and_export_survive() { + // The same file has to keep working when it is sourced by a shell. + let after = rewrite( + " export RESCRIPTUM_STORE=files\n", + &changes(&[("RESCRIPTUM_STORE", Some("sqlite"))]), + ) + .unwrap(); + assert_eq!(after, " export RESCRIPTUM_STORE=sqlite\n"); + assert_eq!(reparse(&after)["RESCRIPTUM_STORE"], "sqlite"); + } + + #[test] + fn a_similarly_named_setting_is_not_mistaken_for_this_one() { + let after = rewrite( + "RESCRIPTUM_LOG_FILE=/var/log/r.log\n", + &changes(&[("RESCRIPTUM_LOG", Some("problems"))]), + ) + .unwrap(); + let vars = reparse(&after); + assert_eq!(vars["RESCRIPTUM_LOG_FILE"], "/var/log/r.log"); + assert_eq!(vars["RESCRIPTUM_LOG"], "problems"); + } + + #[test] + fn unsetting_comments_the_line_out_and_keeps_it() { + // So the paragraph above it survives, and setting it again lands back here. + let before = "# The write API.\nRESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001\n"; + let after = rewrite(before, &changes(&[("RESCRIPTUM_ADMIN_ADDR", None)])).unwrap(); + + assert!( + !reparse(&after).contains_key("RESCRIPTUM_ADMIN_ADDR"), + "{after}" + ); + assert!( + after.contains("# RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001"), + "{after}" + ); + + let again = rewrite( + &after, + &changes(&[("RESCRIPTUM_ADMIN_ADDR", Some("127.0.0.1:9001"))]), + ) + .unwrap(); + assert_eq!(reparse(&again)["RESCRIPTUM_ADMIN_ADDR"], "127.0.0.1:9001"); + assert_eq!(again.lines().count(), 2, "{again}"); + } + + #[test] + fn a_value_that_would_not_survive_the_round_trip_is_refused() { + // There are no escapes in this format. Writing one of these and reading back + // something else is exactly the silent failure this module exists to remove. + for bad in ["two\nlines", "tab\there", "\"quoted\"", "'quoted'"] { + let e = rewrite( + "RESCRIPTUM_STORE=files\n", + &changes(&[("RESCRIPTUM_ADMIN_TOKEN", Some(bad))]), + ) + .expect_err("{bad} should be refused"); + assert!(e.contains("RESCRIPTUM_ADMIN_TOKEN"), "{e}"); + } + } + + #[test] + fn whitespace_a_value_needs_is_quoted_and_comes_back() { + let after = rewrite( + "", + &changes(&[("RESCRIPTUM_ADMIN_TOKEN", Some(" padded token "))]), + ) + .unwrap(); + assert_eq!(reparse(&after)["RESCRIPTUM_ADMIN_TOKEN"], " padded token "); + } + + #[cfg(unix)] + #[test] + fn an_atomic_write_keeps_the_mode_and_leaves_no_temporary_behind() { + // A `0600` file that came back `0644` would put the admin token within reach of + // every account on the machine, quietly. + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("pve-envfile-write-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rescriptum.env"); + + write_atomic(&path, "RESCRIPTUM_STORE=files\n").expect("writes"); + let fresh = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(fresh, 0o600, "a new file must not inherit the umask"); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + write_atomic(&path, "RESCRIPTUM_STORE=sqlite\n").expect("writes"); + let kept = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(kept, 0o640, "an existing file keeps the mode it had"); + + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "RESCRIPTUM_STORE=sqlite\n" + ); + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "rescriptum.env") + .collect(); + assert!( + leftovers.is_empty(), + "temporary files left behind: {leftovers:?}" + ); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/main.rs b/src/main.rs index 510284c..4302675 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,12 @@ fn main() -> ExitCode { // Answered before the configuration is even read: `--help` is what you reach for when // something is wrong, so a broken RESCRIPTUM_ENV_FILE must not be what stops you // reading it. + // + // `config` is here for a stronger version of the same reason. Every other subcommand + // is handed a configuration that `Config::from_env` built and `validate` accepted — + // and a file that will not parse, or a token one character short, are precisely the + // states somebody runs `config` to get *out* of. Dispatching it below would make the + // diagnostic tool the first casualty of the thing it diagnoses. match args.first().map(String::as_str) { Some("--help" | "-h" | "help") => { print!("{}", cli::USAGE); @@ -46,6 +52,7 @@ fn main() -> ExitCode { println!("rescriptum {}", env!("CARGO_PKG_VERSION")); return ExitCode::SUCCESS; } + Some("config") => return cli::config(&args[1..]), _ => {} } diff --git a/src/store/file.rs b/src/store/file.rs index fc186f0..4a55c66 100644 --- a/src/store/file.rs +++ b/src/store/file.rs @@ -86,6 +86,19 @@ fn answer_entry(entry: &fs::DirEntry, path: &Path) -> Option<(String, String)> { if !is_file { return None; } + + // A hidden file is never somebody's answer, and one kind of hidden file is actively + // dangerous: macOS writes an AppleDouble `._` beside a file whose extended + // attributes the filesystem will not take, and `._98-fa-9b-50-d8-10.toml` normalizes to + // the same identity as the real `98-fa-9b-50-d8-10.toml`. It therefore claims the same + // machine, with a body that is binary — so the machine it was meant to configure gets a + // parse error instead of its answer. Found on a real NAS whose answers directory was + // being edited over SMB from a Mac. + let name = entry.file_name(); + if name.to_str().is_none_or(|n| n.starts_with('.')) { + return None; + } + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); Kind::for_extension(&ext)?; Some((path.file_stem()?.to_str()?.to_string(), ext)) diff --git a/tests/cli.rs b/tests/cli.rs index aec802f..1ebc51d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -681,3 +681,293 @@ fn naming_the_env_file_inside_itself_says_why_it_does_nothing() { "the message must not claim it is unknown\n{r}" ); } + +// ---- config --------------------------------------------------------------- +// +// The command a settings panel drives, and the one people reach for when the server will +// not start — so its exit code is a contract like `check`'s: zero when the configuration +// would start, one when it would not. + +impl Case { + /// `run_env` takes paths, which most of these do not have. Configuration is strings. + fn run_config(&self, env: &[(&str, &str)], args: &[&str]) -> Run { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rescriptum")); + // Inherited from the test runner's own environment, these would silently beat the + // file and make every assertion below about the wrong thing. + for (key, _, _) in [("RESCRIPTUM_LOG", "", ""), ("RESCRIPTUM_STORE", "", "")] { + cmd.env_remove(key); + } + for (key, value) in env { + cmd.env(key, value); + } + Run::from(cmd.args(args).output().expect("run rescriptum")) + } + + fn env_file(&self, body: &str) -> String { + let path = self.dir.join("rescriptum.env"); + fs::write(&path, body).expect("env file"); + path.to_string_lossy().into_owned() + } +} + +#[test] +fn config_says_which_of_the_file_and_the_environment_is_winning() { + // The file is only defaults. A panel that offered to edit a value the environment + // overrides would be offering to change nothing at all. + let c = Case::new(&[]); + let env = c.env_file("RESCRIPTUM_LOG=problems\nRESCRIPTUM_LISTEN_ADDR=0.0.0.0:8000\n"); + + let r = c.run_config( + &[ + ("RESCRIPTUM_ENV_FILE", &env), + ("RESCRIPTUM_LISTEN_ADDR", "127.0.0.1:9999"), + ], + &["config"], + ); + assert!(r.ok, "{r}"); + + let listen = line_for(&r.stdout, "RESCRIPTUM_LISTEN_ADDR"); + assert!(listen.contains("127.0.0.1:9999"), "{r}"); + assert!(listen.ends_with("environment"), "{listen:?}\n{r}"); + + let log = line_for(&r.stdout, "RESCRIPTUM_LOG "); + assert!( + log.contains("problems") && log.ends_with("file"), + "{log:?}\n{r}" + ); + + let store = line_for(&r.stdout, "RESCRIPTUM_STORE"); + assert!( + store.contains("files") && store.ends_with("default"), + "{store:?}\n{r}" + ); +} + +/// The row for one variable, trimmed — the table is padded. +fn line_for(stdout: &str, key: &str) -> String { + stdout + .lines() + .find(|l| l.trim_start().starts_with(key)) + .unwrap_or_else(|| panic!("no row for {key} in\n{stdout}")) + .trim() + .to_string() +} + +#[test] +fn config_never_prints_a_token() { + // This output is what the DSM panel renders. A token reaching it once is a token in + // somebody's browser, their history, and a screenshot in a support thread. + let c = Case::new(&[]); + let env = c.env_file( + "RESCRIPTUM_ADMIN_TOKEN=sup3rs3cr3ttok3nvalue\nRESCRIPTUM_ANSWER_TOKEN=an0th3rs3cr3tvalue\n", + ); + + for args in [vec!["config"], vec!["config", "--json"]] { + let r = c.run_config(&[("RESCRIPTUM_ENV_FILE", &env)], &args); + assert!(r.ok, "{r}"); + assert!( + !r.stdout.contains("sup3rs3cr3ttok3nvalue") && !r.stdout.contains("an0th3rs3cr3tvalue"), + "a token reached the output of {args:?}\n{r}" + ); + assert!( + !r.stderr.contains("sup3rs3cr3ttok3nvalue"), + "a token reached stderr\n{r}" + ); + } + + // And it still has to say that there *is* one, or the panel cannot tell you whether + // the endpoint is guarded. + let r = c.run_config(&[("RESCRIPTUM_ENV_FILE", &env)], &["config"]); + assert!( + line_for(&r.stdout, "RESCRIPTUM_ADMIN_TOKEN").contains("(set)"), + "{r}" + ); +} + +#[test] +fn config_set_keeps_the_comments_that_explain_the_file() { + // On a packaged install those comments are the only documentation the configuration + // has. A writer that regenerated the file would eat them on the first save. + let c = Case::new(&[]); + let env = c.env_file( + "# Where answers live.\nRESCRIPTUM_ANSWERS_DIR=/srv/answers\n\n# Off by default.\n# RESCRIPTUM_LOG=problems\n", + ); + + let r = c.run_config( + &[("RESCRIPTUM_ENV_FILE", &env)], + &["config", "set", "RESCRIPTUM_LOG=problems"], + ); + assert!(r.ok, "{r}"); + + let after = fs::read_to_string(&env).expect("still there"); + assert!(after.contains("# Where answers live."), "{after}"); + assert!(after.contains("# Off by default."), "{after}"); + // Uncommented in place rather than appended below, or the paragraph above it now + // explains a line that is no longer there. + assert!(after.contains("\nRESCRIPTUM_LOG=problems\n"), "{after}"); + assert_eq!( + after.matches("RESCRIPTUM_LOG").count(), + 1, + "the setting was duplicated\n{after}" + ); +} + +#[test] +fn config_set_refuses_to_leave_a_server_that_cannot_start() { + // The panel doing the writing is reached over the very service this would stop. Get + // it wrong and the way back in is SSH — which is the thing the panel exists to avoid. + let c = Case::new(&[]); + let before = "RESCRIPTUM_STORE=sqlite\n"; + let env = c.env_file(before); + + let r = c.run_config( + &[("RESCRIPTUM_ENV_FILE", &env)], + &["config", "set", "RESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001"], + ); + assert!(!r.ok, "an unauthenticated admin API must be refused\n{r}"); + assert!(r.stderr.contains("refused"), "{r}"); + assert_eq!( + fs::read_to_string(&env).expect("still there"), + before, + "the file must be untouched when the write is refused" + ); +} + +#[test] +fn config_set_refuses_a_misspelled_variable() { + // Written, it would be read back as a stranger and warned about only at the next + // start — by which time nobody connects the two. + let c = Case::new(&[]); + let env = c.env_file("RESCRIPTUM_STORE=files\n"); + + let r = c.run_config( + &[("RESCRIPTUM_ENV_FILE", &env)], + &["config", "set", "RESCRIPTUM_ADMIN_TOKENN=0123456789abcdef0"], + ); + assert!(!r.ok, "{r}"); + assert!(r.stderr.contains("check the spelling"), "{r}"); + assert_eq!( + fs::read_to_string(&env).unwrap(), + "RESCRIPTUM_STORE=files\n" + ); +} + +#[test] +fn config_exit_code_says_whether_the_server_would_start() { + // The same contract `check` has, for the same reason: something automated keys on it. + let c = Case::new(&[]); + + let healthy = c.env_file("RESCRIPTUM_STORE=files\n"); + let r = c.run_config(&[("RESCRIPTUM_ENV_FILE", &healthy)], &["config"]); + assert!(r.ok, "{r}"); + + // A file that will not parse is a *startup error*, not a warning — the server refuses + // to come up on it — so it has to reach the exit code rather than only the text. + let broken = c.dir.join("broken.env"); + fs::write(&broken, "RESCRIPTUM_STORE files\n").unwrap(); + let broken = broken.to_string_lossy().into_owned(); + let r = c.run_config(&[("RESCRIPTUM_ENV_FILE", &broken)], &["config"]); + assert!(!r.ok, "an unparseable file must fail\n{r}"); + assert!(r.stderr.contains("would not start"), "{r}"); + // It still prints the table: seeing the other variables beside the reason is what + // makes this usable when everything is broken. + assert!(r.stdout.contains("RESCRIPTUM_ANSWERS_DIR"), "{r}"); +} + +#[test] +fn config_works_when_the_configuration_is_too_broken_to_start_a_server() { + // Every other subcommand is handed a configuration `validate` has accepted. This one + // must survive one it would reject, or the diagnostic tool is the first casualty of + // the thing it diagnoses. + let c = Case::new(&[]); + let env = c.env_file( + "RESCRIPTUM_STORE=sqlite\nRESCRIPTUM_ADMIN_ADDR=127.0.0.1:8001\nRESCRIPTUM_ADMIN_TOKEN=short\n", + ); + + let r = c.run_config(&[("RESCRIPTUM_ENV_FILE", &env)], &["config"]); + assert!(!r.ok, "{r}"); + assert!( + r.stdout.contains("RESCRIPTUM_ADMIN_ADDR"), + "it still prints\n{r}" + ); + assert!(r.stderr.contains("16"), "and says why\n{r}"); + + // And it can put it right, which is the whole point. + let fix = c.run_config( + &[("RESCRIPTUM_ENV_FILE", &env)], + &["config", "set", "RESCRIPTUM_ADMIN_TOKEN=0123456789abcdef0"], + ); + assert!(fix.ok, "{fix}"); + let r = c.run_config(&[("RESCRIPTUM_ENV_FILE", &env)], &["config"]); + assert!(r.ok, "{r}"); +} + +#[test] +fn config_json_carries_the_source_and_the_help_a_panel_needs() { + let c = Case::new(&[]); + let env = c.env_file("RESCRIPTUM_STORE=sqlite\n"); + + let r = c.run_config(&[("RESCRIPTUM_ENV_FILE", &env)], &["config", "--json"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("\"key\":\"RESCRIPTUM_STORE\""), "{r}"); + assert!(r.stdout.contains("\"source\":\"file\""), "{r}"); + assert!( + r.stdout.contains("\"secret\":true"), + "the tokens are marked\n{r}" + ); + assert!(r.stdout.contains("\"starts\":true"), "{r}"); + assert!(r.stdout.contains("\"writable\":true"), "{r}"); + // One line, so a CGI can hand it straight to a browser. + assert_eq!(r.stdout.lines().count(), 1, "{r}"); +} + +#[test] +fn config_with_no_file_named_says_so_rather_than_failing() { + // A container or a systemd unit configures the environment directly and has nothing + // here to edit. That is a normal deployment, not an error. + let c = Case::new(&[]); + let r = c.run_config(&[], &["config"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("none"), "{r}"); + + let w = c.run_config(&[], &["config", "set", "RESCRIPTUM_LOG=off"]); + assert!(!w.ok, "there is nothing to write to\n{w}"); + assert!(w.stderr.contains("RESCRIPTUM_ENV_FILE"), "{w}"); +} + +#[test] +fn config_value_prints_one_setting_but_never_a_credential() { + // The DSM panel's backend reads a value this way rather than grepping the file, + // because a variable absent from the file is not unset — it is the default, and a + // grep cannot know that. The refusal matters just as much: this output is a shell + // variable, and a shell variable ends up in a log or a `set -x` trace. + let c = Case::new(&[]); + let env = c.env_file("RESCRIPTUM_ADMIN_TOKEN=sup3rs3cr3ttok3nvalue\n"); + + let d = c.run_config( + &[("RESCRIPTUM_ENV_FILE", &env)], + &["config", "--value", "RESCRIPTUM_ANSWERS_DIR"], + ); + assert!(d.ok, "{d}"); + assert_eq!( + d.stdout.trim(), + "/srv/answers", + "the default, not nothing\n{d}" + ); + + let s = c.run_config( + &[("RESCRIPTUM_ENV_FILE", &env)], + &["config", "--value", "RESCRIPTUM_ADMIN_TOKEN"], + ); + assert!(!s.ok, "a credential must not be printable\n{s}"); + assert!(!s.stdout.contains("sup3rs3cr3ttok3nvalue"), "{s}"); + assert!(!s.stderr.contains("sup3rs3cr3ttok3nvalue"), "{s}"); + + // Unset is an exit code, not an empty line that a script would mistake for a value. + let u = c.run_config( + &[("RESCRIPTUM_ENV_FILE", &env)], + &["config", "--value", "RESCRIPTUM_CAPTURE_DIR"], + ); + assert!(!u.ok, "{u}"); + assert!(u.stdout.is_empty(), "{u}"); +} diff --git a/tests/stores.rs b/tests/stores.rs index 2ac5485..16eb2b9 100644 --- a/tests/stores.rs +++ b/tests/stores.rs @@ -374,6 +374,47 @@ fn matching_is_deterministic_whatever_the_store_order() { // Store-specific // --------------------------------------------------------------------------- +#[test] +fn the_file_store_ignores_what_a_mac_leaves_in_a_shared_folder() { + // An answers directory edited over SMB from a Mac collects two kinds of litter. + // `.DS_Store` is harmless — its extension is not on the allowlist. `._.toml` is + // not: macOS writes that AppleDouble beside a file whose extended attributes the + // filesystem will not take, its extension *is* on the allowlist, and normalization + // strips the leading `._` — so it claims the very machine the real file is for, with a + // body that is binary. The machine that was being configured then gets a parse error + // instead of its answer. Found on a real NAS. + let dir = scratch("appledouble"); + fs::create_dir_all(dir.join("groups")).unwrap(); + fs::write( + dir.join("98-fa-9b-50-d8-10.toml"), + "[global]\nfqdn = \"m\"\n", + ) + .unwrap(); + fs::write( + dir.join("groups/rack.toml"), + "members = [\"98:fa:9b:50:d8:10\"]\n\n[global]\nkeyboard = \"fr\"\n", + ) + .unwrap(); + fs::write(dir.join(".DS_Store"), b"Mac OS X\x00\x02binary").unwrap(); + fs::write( + dir.join("._98-fa-9b-50-d8-10.toml"), + b"Mac OS X\x00\x02binary", + ) + .unwrap(); + fs::write(dir.join("groups/._rack.toml"), b"Mac OS X\x00\x02binary").unwrap(); + + let store = FileStore::new(&dir); + let snapshot = store.snapshot().unwrap(); + let names: Vec<_> = snapshot.machines.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + names, + ["98-fa-9b-50-d8-10"], + "hidden files were taken for answer documents" + ); + assert_eq!(snapshot.groups.len(), 1, "hidden files reached the groups"); + let _ = fs::remove_dir_all(&dir); +} + #[test] fn the_file_store_writes_atomically_and_leaves_no_scratch_files() { let dir = scratch("atomic");