fix(p8-security): enforce daemon ownership policy, gate fake-super replay, drop implicit numeric-ids, make copy-as failures per-entry
A1: daemon refuses every client-chosen ownership/super-user request (--numeric-ids/--chown/--usermap/--groupmap/--fake-super/--copy-as/--super) unless the selected module opts in with 'client owner = yes'. A2: fake-super owner replay requires an explicit ownership identity policy. A3: --super no longer implies --numeric-ids (ownership stays opt-in). A5: a failed --copy-as chown marks the entry failed instead of reporting success with the wrong owner.
This commit is contained in:
+8
-7
@@ -257,14 +257,14 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
|
|||||||
| `-N`, `--crtimes` | Preserve create times | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) |
|
| `-N`, `--crtimes` | Preserve create times | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) |
|
||||||
| `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
| `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
||||||
| `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
| `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
||||||
| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. With `--super` and **no** explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`), ownership is treated as raw numeric-id preservation (as if `--numeric-ids`); an explicit identity policy still wins. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates |
|
| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates |
|
||||||
| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped when `--no-super` forbids super-user activities (even for root) or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front |
|
| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped unless an explicit ownership identity policy (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) is active — `--fake-super` on its own only *records* the source owner and must not act as an un-gated chown primitive — when `--no-super` forbids super-user activities (even for root), or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front |
|
||||||
| `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path |
|
| `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path |
|
||||||
| `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) |
|
| `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) |
|
||||||
| `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues |
|
| `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues |
|
||||||
| `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes |
|
| `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes |
|
||||||
| `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) |
|
| `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) |
|
||||||
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as` outright even when root: FastSync has no per-module opt-in for client-chosen ownership, so a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring it for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR but remains non-fatal (the multithreaded receiver is never aborted). USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** |
|
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it, and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (the standalone listener and SSH `--stdio` server keep honoring these for their single operator-authorized root). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner (the receiver never claims a `--copy-as` success it did not achieve), while a single entry failure does not abort the multithreaded run. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** |
|
||||||
|
|
||||||
**Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`,
|
**Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`,
|
||||||
`-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new.
|
`-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new.
|
||||||
@@ -625,7 +625,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
|||||||
|
|
||||||
| Flag | Rsync Description | FastSync Status | Notes |
|
| Flag | Rsync Description | FastSync Status | Notes |
|
||||||
|------|-------------------|-----------------|-------|
|
|------|-------------------|-----------------|-------|
|
||||||
| `--daemon` | Run as rsync daemon | ✅ Implemented | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; a client `--copy-as` is refused outright and the operator `--no-super` veto is honored). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding |
|
| `--daemon` | Run as rsync daemon | ✅ Implemented | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; every client-chosen-ownership/super-user request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/`--copy-as`/explicit `--super`) is refused unless the module opts in with `client owner = yes`, and the operator `--no-super` veto is honored). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding |
|
||||||
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
|
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
|
||||||
| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
|
| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
|
||||||
| `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
|
| `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
|
||||||
@@ -634,8 +634,9 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
|||||||
|
|
||||||
**Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
|
**Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
|
||||||
|
|
||||||
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
|
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars). Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
|
||||||
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, a client `--copy-as` is refused outright (no per-module opt-in for client-chosen ownership), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
|
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
|
||||||
|
- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (the standalone listener and the SSH `--stdio` server always honor them for their single operator-authorized root). The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible.
|
||||||
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
|
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
|
||||||
- **`auth users` (Wave B password authentication):** a module that declares `auth users` requires the client to present credentials. The client sends a username + the lowercase hex SHA-256 of the password (never the literal password) in the config frame; the daemon accepts a connection only when the presented username is **on the module's `auth users` list** AND the presented digest matches that user's credential-store entry. Verification is constant-time (username present/absent both take the same comparison work, so there is no timing oracle distinguishing "unknown user" from "wrong password"), and the daemon logs the username but **never the digest or the password**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open".
|
- **`auth users` (Wave B password authentication):** a module that declares `auth users` requires the client to present credentials. The client sends a username + the lowercase hex SHA-256 of the password (never the literal password) in the config frame; the daemon accepts a connection only when the presented username is **on the module's `auth users` list** AND the presented digest matches that user's credential-store entry. Verification is constant-time (username present/absent both take the same comparison work, so there is no timing oracle distinguishing "unknown user" from "wrong password"), and the daemon logs the username but **never the digest or the password**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open".
|
||||||
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:SHA256HEX`, one per line, where `SHA256HEX` is the lowercase hex SHA-256 of the user's password (exactly what the client transmits). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). The client `--password-file` holds `user:password` on its first meaningful line (the literal password, hashed client-side then wiped from memory); keep both files readable only by their owner (mode 0600) since the client file holds the password and the server file holds the equivalent credential. Per-username wire length is bounded (256 chars) and digests are validated to be exactly 64 lowercase hex on receive.
|
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:SHA256HEX`, one per line, where `SHA256HEX` is the lowercase hex SHA-256 of the user's password (exactly what the client transmits). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). The client `--password-file` holds `user:password` on its first meaningful line (the literal password, hashed client-side then wiped from memory); keep both files readable only by their owner (mode 0600) since the client file holds the password and the server file holds the equivalent credential. Per-username wire length is bounded (256 chars) and digests are validated to be exactly 64 lowercase hex on receive.
|
||||||
@@ -830,7 +831,7 @@ These are the last compatibility items and the closing phase toward rsync flag p
|
|||||||
|
|
||||||
`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. With `ON` and no explicit identity policy, ownership falls back to raw numeric-id preservation (as `--numeric-ids`); explicit `--usermap`/`--groupmap`/`--chown`/`--numeric-ids` still win. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection and refuses client `--copy-as`/`--super`.
|
`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. With `ON` and no explicit identity policy, ownership falls back to raw numeric-id preservation (as `--numeric-ids`); explicit `--usermap`/`--groupmap`/`--chown`/`--numeric-ids` still win. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection and refuses client `--copy-as`/`--super`.
|
||||||
|
|
||||||
`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon divergence: a `--daemon` receiver refuses `--copy-as` **and** `--super`=ON outright, because there is no per-module operator opt-in for client-chosen ownership (the standalone listener and the SSH-launched `--stdio` server, which each serve one operator-authorized root, honor them).
|
`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (the standalone listener and the SSH-launched `--stdio` server, which each serve one operator-authorized root, honor these requests). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner.
|
||||||
|
|
||||||
**Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity.
|
**Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity.
|
||||||
|
|
||||||
|
|||||||
+38
-30
@@ -172,46 +172,29 @@ static bool configure_authorization(const char* root) {
|
|||||||
* --destination-root, but per-module and NEVER client-chosen. The module is
|
* --destination-root, but per-module and NEVER client-chosen. The module is
|
||||||
* refused (with a clear log) when it is unknown, when it is `read only` (every
|
* refused (with a clear log) when it is unknown, when it is `read only` (every
|
||||||
* FastSync network transfer writes; there is no read-only wire operation yet),
|
* FastSync network transfer writes; there is no read-only wire operation yet),
|
||||||
* or when the presented daemon credentials fail for a module that declares
|
* when it requests client-chosen ownership without the module's
|
||||||
* `auth users`. Wave A refused every auth-required module (auth was not yet
|
* `client owner = yes` opt-in (P7 Wave E hardening), or when the presented
|
||||||
* implemented); Wave B authenticates the client instead (see below). */
|
* daemon credentials fail for a module that declares `auth users`. Wave A
|
||||||
|
* refused every auth-required module (auth was not yet implemented); Wave B
|
||||||
|
* authenticates the client instead (see below). */
|
||||||
static const char* server_module_gate(const Config* config, void* context) {
|
static const char* server_module_gate(const Config* config, void* context) {
|
||||||
ModuleGateContext* gate_ctx = (ModuleGateContext*)context;
|
ModuleGateContext* gate_ctx = (ModuleGateContext*)context;
|
||||||
if (!config)
|
if (!config)
|
||||||
return "missing config frame";
|
return "missing config frame";
|
||||||
/* --copy-as (P7 Wave E, protocol 2.18.0): FastSync's safe subset forces the
|
|
||||||
ownership of every written entry to the requested ids, which needs a
|
|
||||||
privileged (root) receiver. An unprivileged receiver REFUSES the whole
|
|
||||||
transfer here, at the config handshake and BEFORE the STATUS_OK ack, so no
|
|
||||||
file data is exchanged and there is never a silent wrong-ownership result.
|
|
||||||
Placed first so it applies to the standalone server and daemon alike. */
|
|
||||||
/* Daemon divergence (P7 Wave E): a daemon has no per-module opt-in for
|
|
||||||
client-chosen ownership, so it refuses --copy-as outright even when running
|
|
||||||
as root -- otherwise any anonymous client could pick an arbitrary owner.
|
|
||||||
The standalone listener and the SSH-launched --stdio server keep honoring
|
|
||||||
it (they serve exactly one operator-authorized root). */
|
|
||||||
if (g_daemon_conf != NULL && config->copy_as_set) {
|
|
||||||
log_message(LOG_LEVEL_ERROR, "--copy-as is refused by the daemon (no per-module opt-in for "
|
|
||||||
"client-chosen ownership); refusing");
|
|
||||||
return "--copy-as is not permitted by this daemon";
|
|
||||||
}
|
|
||||||
/* --super (SUPER_MODE_ON) with no explicit identity policy implies raw
|
|
||||||
numeric-id ownership, i.e. a client-chosen owner. A daemon has no
|
|
||||||
per-module opt-in, so refuse the explicit ON request for the same reason it
|
|
||||||
refuses --copy-as; the pre-existing --numeric-ids/--chown/--usermap surfaces
|
|
||||||
are unchanged (documented daemon trust model). --no-super still works. */
|
|
||||||
if (g_daemon_conf != NULL && config->super_mode == SUPER_MODE_ON) {
|
|
||||||
log_message(LOG_LEVEL_ERROR,
|
|
||||||
"--super is refused by the daemon (no per-module opt-in for client-chosen "
|
|
||||||
"ownership); refusing");
|
|
||||||
return "--super is not permitted by this daemon";
|
|
||||||
}
|
|
||||||
/* Operator veto: --no-super forces SUPER_MODE_OFF for this connection before
|
/* Operator veto: --no-super forces SUPER_MODE_OFF for this connection before
|
||||||
the copy-as gate is evaluated, and the caller clamps the accepted config
|
the copy-as gate is evaluated, and the caller clamps the accepted config
|
||||||
again after this returns so the ownership/device gates see it too. */
|
again after this returns so the ownership/device gates see it too. */
|
||||||
Config* effective = (Config*)config;
|
Config* effective = (Config*)config;
|
||||||
if (server_no_super)
|
if (server_no_super)
|
||||||
effective->super_mode = SUPER_MODE_OFF;
|
effective->super_mode = SUPER_MODE_OFF;
|
||||||
|
/* --copy-as (P7 Wave E, protocol 2.18.0): FastSync's safe subset forces the
|
||||||
|
ownership of every written entry to the requested ids, which needs a
|
||||||
|
privileged (root) receiver. An unprivileged receiver REFUSES the whole
|
||||||
|
transfer here, at the config handshake and BEFORE the STATUS_OK ack, so no
|
||||||
|
file data is exchanged and there is never a silent wrong-ownership result.
|
||||||
|
The daemon's per-module client-chosen-ownership refusal is enforced after
|
||||||
|
the module lookup below (it needs the module's opt-in) and covers --copy-as
|
||||||
|
like every other ownership flag. */
|
||||||
if (identity_copy_as_refused(effective)) {
|
if (identity_copy_as_refused(effective)) {
|
||||||
if (geteuid() != 0)
|
if (geteuid() != 0)
|
||||||
log_message(LOG_LEVEL_ERROR, "--copy-as requires a privileged receiver (root); refusing");
|
log_message(LOG_LEVEL_ERROR, "--copy-as requires a privileged receiver (root); refusing");
|
||||||
@@ -256,6 +239,20 @@ static const char* server_module_gate(const Config* config, void* context) {
|
|||||||
config->module);
|
config->module);
|
||||||
return "requested daemon module is read only";
|
return "requested daemon module is read only";
|
||||||
}
|
}
|
||||||
|
/* Client-chosen ownership / super-user policy (P7 Wave E hardening): a daemon
|
||||||
|
module refuses EVERY ownership-affecting request (--numeric-ids, --chown,
|
||||||
|
--usermap/--groupmap, --fake-super, --copy-as, explicit --super) unless the
|
||||||
|
operator opted THIS module in with `client owner = yes`. Otherwise any
|
||||||
|
client could force arbitrary ownership inside the module root. The
|
||||||
|
standalone/SSH server has a single operator-authorized root and keeps
|
||||||
|
honoring these. */
|
||||||
|
if (!module->client_owner && identity_ownership_requested(effective)) {
|
||||||
|
log_message(LOG_LEVEL_ERROR,
|
||||||
|
"daemon module '%s' refuses client-chosen ownership/super-user activities "
|
||||||
|
"(no `client owner = yes` opt-in); refusing",
|
||||||
|
config->module);
|
||||||
|
return "client-chosen ownership is not permitted by this daemon module";
|
||||||
|
}
|
||||||
if (module->auth_user_count > 0) {
|
if (module->auth_user_count > 0) {
|
||||||
/* Auth-required module (Wave B): verify the presented credentials against
|
/* Auth-required module (Wave B): verify the presented credentials against
|
||||||
* the store BEFORE the module root is installed and before any data moves.
|
* the store BEFORE the module root is installed and before any data moves.
|
||||||
@@ -766,6 +763,17 @@ int main(int argc, char* argv[]) {
|
|||||||
if (g_daemon_conf->module_count == 0)
|
if (g_daemon_conf->module_count == 0)
|
||||||
log_message(LOG_LEVEL_WARNING,
|
log_message(LOG_LEVEL_WARNING,
|
||||||
"daemon config has no modules; every connection will be refused");
|
"daemon config has no modules; every connection will be refused");
|
||||||
|
/* Surface the operator's client-chosen-ownership opt-in prominently: an
|
||||||
|
opted-in module lets its clients request arbitrary owner ids inside that
|
||||||
|
module root. */
|
||||||
|
for (int i = 0; i < g_daemon_conf->module_count; i++) {
|
||||||
|
if (g_daemon_conf->modules[i].client_owner)
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"daemon module '%s' allows client-chosen ownership "
|
||||||
|
"(`client owner = yes`); clients may request arbitrary owner ids within "
|
||||||
|
"that module root",
|
||||||
|
g_daemon_conf->modules[i].name);
|
||||||
|
}
|
||||||
/* Daemon credential store (Wave B). --password-file and --early-input
|
/* Daemon credential store (Wave B). --password-file and --early-input
|
||||||
* feed the same store, loaded BEFORE the listener forks so every
|
* feed the same store, loaded BEFORE the listener forks so every
|
||||||
* connection child shares one read-only store. Fail closed at startup: a
|
* connection child shares one read-only store. Fail closed at startup: a
|
||||||
|
|||||||
+8
-7
@@ -404,18 +404,19 @@ typedef struct Config {
|
|||||||
|
|
||||||
/* --super / --no-super (P7 Wave E, protocol 2.18.0): receiver-side privilege
|
/* --super / --no-super (P7 Wave E, protocol 2.18.0): receiver-side privilege
|
||||||
* policy for super-user activities confined below the authorized receive
|
* policy for super-user activities confined below the authorized receive
|
||||||
* root. SUPER_MODE_AUTO (default) preserves the pre-existing behavior: a
|
* root. SUPER_MODE_AUTO (default) preserves the pre-existing best-effort
|
||||||
* privileged operation is only attempted when the receiver is ALREADY root
|
* behavior: the confined super-user operation is ALWAYS attempted and an
|
||||||
* (geteuid() == 0). SUPER_MODE_ON (--super) PERMITS the receiver to attempt
|
* unprivileged attempt is refused by the kernel and skipped per entry.
|
||||||
* those activities (ownership application, char/block device-node creation)
|
* SUPER_MODE_ON (--super) explicitly REQUESTS those activities (char/block
|
||||||
* even when it is not root -- the attempt is then confined exactly as before
|
* device-node creation, --write-devices); it does NOT imply --numeric-ids and
|
||||||
* and simply fails/skips if the kernel refuses it. SUPER_MODE_OFF
|
* never enables ownership application on its own. SUPER_MODE_OFF
|
||||||
* (--no-super) FORBIDS them even when running as root. FastSync NEVER
|
* (--no-super) FORBIDS them even when running as root. FastSync NEVER
|
||||||
* elevates privileges (no setuid/seteuid/setgid) and never bypasses the
|
* elevates privileges (no setuid/seteuid/setgid) and never bypasses the
|
||||||
* fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks);
|
* fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks);
|
||||||
* --super only permits an attempt that is already confined. Crosses the wire
|
* --super only permits an attempt that is already confined. Crosses the wire
|
||||||
* as a trailing int so the receiver can enforce the policy. See
|
* as a trailing int so the receiver can enforce the policy. See
|
||||||
* privilege_super_permitted() in identity.h. */
|
* privilege_super_permitted() and identity_ownership_requested() in
|
||||||
|
* identity.h. */
|
||||||
int super_mode;
|
int super_mode;
|
||||||
|
|
||||||
// Receiver-side runtime staging registry for --delay-updates. Never sent
|
// Receiver-side runtime staging registry for --delay-updates. Never sent
|
||||||
|
|||||||
@@ -170,6 +170,17 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
|
|||||||
module->read_only = parsed;
|
module->read_only = parsed;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (key_equals(key, "client owner")) {
|
||||||
|
bool parsed;
|
||||||
|
if (!parse_bool_value(value, &parsed)) {
|
||||||
|
set_error(err, err_size,
|
||||||
|
"module '%s': 'client owner' must be yes/no (or true/false/1/0), got '%s'",
|
||||||
|
module->name, value);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
module->client_owner = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (key_equals(key, "auth users")) {
|
if (key_equals(key, "auth users")) {
|
||||||
char* list = str_dup(value);
|
char* list = str_dup(value);
|
||||||
if (!list) {
|
if (!list) {
|
||||||
|
|||||||
@@ -24,12 +24,16 @@
|
|||||||
* selects this module to this path (file_open_secure_parent /
|
* selects this module to this path (file_open_secure_parent /
|
||||||
* has_path_traversal / path_is_within all keep the existing confinement, just
|
* has_path_traversal / path_is_within all keep the existing confinement, just
|
||||||
* per-module). There is never any client-chosen root: a module path always
|
* per-module). There is never any client-chosen root: a module path always
|
||||||
* stays confined. A daemon also REFUSES a client --copy-as outright, because
|
* stays confined. A daemon REFUSES every client-chosen ownership / super-user
|
||||||
* there is no per-module opt-in for client-chosen ownership (unlike the
|
* request by default -- --numeric-ids, --chown, --usermap/--groupmap,
|
||||||
* standalone/SSH server, which honors it for its single operator-authorized
|
* --fake-super, --copy-as and an explicit --super -- because there is no
|
||||||
* root); the operator-level --no-super veto additionally forces super-user
|
* per-module opt-in unless the operator adds one. An operator opts a single
|
||||||
* activities off for every daemon connection. See server_module_gate in
|
* module in with `client owner = yes` (DaemonModule.client_owner), which allows
|
||||||
* server.c and RSYNC_COMPAT.md.
|
* that client to choose ownership within that module's root (the standalone/SSH
|
||||||
|
* server honors such requests for its single operator-authorized root). The
|
||||||
|
* operator-level --no-super veto additionally forces super-user activities off
|
||||||
|
* for every daemon connection, even an opted-in module. See server_module_gate
|
||||||
|
* in server.c and RSYNC_COMPAT.md.
|
||||||
*
|
*
|
||||||
* `auth_users` is honored by Wave B daemon authentication: a module that
|
* `auth_users` is honored by Wave B daemon authentication: a module that
|
||||||
* declares auth users accepts a connection only when the presented username is
|
* declares auth users accepts a connection only when the presented username is
|
||||||
@@ -41,6 +45,11 @@ typedef struct DaemonModule {
|
|||||||
char* name; /* module name, as the client requests it */
|
char* name; /* module name, as the client requests it */
|
||||||
char* path; /* module root (daemon-side authorized root) */
|
char* path; /* module root (daemon-side authorized root) */
|
||||||
bool read_only; /* `read only = yes/no`; default no */
|
bool read_only; /* `read only = yes/no`; default no */
|
||||||
|
bool client_owner; /* `client owner = yes/no`; default no. Per-module opt-in
|
||||||
|
that lets this module's clients choose ownership
|
||||||
|
(--numeric-ids/--chown/--usermap/--groupmap/--fake-super/
|
||||||
|
--copy-as) and request explicit --super super-user
|
||||||
|
activities. Without it the daemon refuses all of them. */
|
||||||
char** auth_users; /* `auth users = a,b`; Wave B credential list */
|
char** auth_users; /* `auth users = a,b`; Wave B credential list */
|
||||||
int auth_user_count;
|
int auth_user_count;
|
||||||
} DaemonModule;
|
} DaemonModule;
|
||||||
|
|||||||
@@ -468,13 +468,16 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
|||||||
every entry (a char/block node path is already privilege-gated above). The
|
every entry (a char/block node path is already privilege-gated above). The
|
||||||
no-follow helper changes the node's own ownership without dereferencing it;
|
no-follow helper changes the node's own ownership without dereferencing it;
|
||||||
it is a no-op unless an identity policy is active. */
|
it is a no-op unless an identity policy is active. */
|
||||||
|
bool owner_ok = true;
|
||||||
if (identity_active_enabled())
|
if (identity_active_enabled())
|
||||||
identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
owner_ok = identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||||
(int32_t)file->metadata->gid);
|
(int32_t)file->metadata->gid);
|
||||||
close(parent_fd);
|
close(parent_fd);
|
||||||
free(leaf);
|
free(leaf);
|
||||||
free(destination);
|
free(destination);
|
||||||
return FILE_SAVE_WRITTEN;
|
/* A failed required --copy-as ownership marks the node as failed; every other
|
||||||
|
* identity policy stays best-effort. */
|
||||||
|
return owner_ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --write-devices (receiver): write the received data directly into an EXISTING
|
/* --write-devices (receiver): write the received data directly into an EXISTING
|
||||||
@@ -626,8 +629,9 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
|||||||
char* leaf = NULL;
|
char* leaf = NULL;
|
||||||
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
|
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
|
||||||
if (parent_fd >= 0) {
|
if (parent_fd >= 0) {
|
||||||
identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
if (!identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
||||||
(int32_t)file->metadata->gid);
|
(int32_t)file->metadata->gid))
|
||||||
|
ok = false;
|
||||||
close(parent_fd);
|
close(parent_fd);
|
||||||
}
|
}
|
||||||
free(leaf);
|
free(leaf);
|
||||||
|
|||||||
+41
-31
@@ -128,29 +128,29 @@ bool privilege_super_mode_permitted(int mode) {
|
|||||||
return mode != SUPER_MODE_OFF;
|
return mode != SUPER_MODE_OFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --super with NO explicit identity policy implies raw numeric-id preservation,
|
|
||||||
* exactly as if --numeric-ids had been given. An explicit usermap/groupmap/
|
|
||||||
* --chown/--numeric-ids always wins: identity_resolve_targets() checks those
|
|
||||||
* before the numeric fallback, and this predicate is false whenever any of them
|
|
||||||
* is present. In AUTO (the default) no implication is made, preserving the
|
|
||||||
* opt-in-only behavior. */
|
|
||||||
static bool identity_super_implies_numeric(void) {
|
|
||||||
return g_identity.super_mode == SUPER_MODE_ON && !g_identity.numeric_ids &&
|
|
||||||
!g_identity.chown_uid_set && !g_identity.chown_gid_set && g_identity.usermap_count == 0 &&
|
|
||||||
g_identity.groupmap_count == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool identity_active_enabled(void) {
|
bool identity_active_enabled(void) {
|
||||||
/* numeric_ids is included: this set only gates identity_apply_ownership,
|
/* numeric_ids is included: this set only gates identity_apply_ownership,
|
||||||
which runs only when metadata is present (a -M/--preserve transfer). A
|
which runs only when metadata is present (a -M/--preserve transfer). A
|
||||||
standalone --numeric-ids (no ownership-affecting flag) carries no
|
standalone --numeric-ids (no ownership-affecting flag) carries no
|
||||||
metadata, never reaches identity_apply_ownership, and therefore correctly
|
metadata, never reaches identity_apply_ownership, and therefore correctly
|
||||||
stays inert; combined with -M it activates raw-id application. --super
|
stays inert; combined with -M it activates raw-id application. --super /
|
||||||
with no explicit identity policy acts like --numeric-ids here. */
|
--no-super does NOT enable ownership: it only permits or forbids the
|
||||||
|
already-requested super-user activities, so a --super with no explicit
|
||||||
|
identity flag must never silently apply client-chosen ownership. */
|
||||||
return g_identity.set &&
|
return g_identity.set &&
|
||||||
(g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set ||
|
(g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set ||
|
||||||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set ||
|
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set);
|
||||||
identity_super_implies_numeric());
|
}
|
||||||
|
|
||||||
|
bool identity_ownership_requested(const Config* config) {
|
||||||
|
if (!config)
|
||||||
|
return false;
|
||||||
|
/* Every value that makes the receiver act on a client-chosen owner, plus an
|
||||||
|
* explicit --super (super-user device-node activities). Pure config, so the
|
||||||
|
* daemon gate can evaluate it before identity_set_active(). */
|
||||||
|
return config->numeric_ids || config->chown_uid_set || config->chown_gid_set ||
|
||||||
|
config->usermap_count > 0 || config->groupmap_count > 0 || config->copy_as_set ||
|
||||||
|
config->fake_super || config->super_mode == SUPER_MODE_ON;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool identity_copy_as_active(void) {
|
bool identity_copy_as_active(void) {
|
||||||
@@ -613,7 +613,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
|
|||||||
} else if (g_identity.chown_uid_set) {
|
} else if (g_identity.chown_uid_set) {
|
||||||
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
||||||
set_uid = true;
|
set_uid = true;
|
||||||
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
|
} else if (g_identity.numeric_ids) {
|
||||||
uid = (uid_t)source_uid;
|
uid = (uid_t)source_uid;
|
||||||
set_uid = true;
|
set_uid = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -637,7 +637,7 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
|
|||||||
} else if (g_identity.chown_gid_set) {
|
} else if (g_identity.chown_gid_set) {
|
||||||
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
|
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
|
||||||
set_gid = true;
|
set_gid = true;
|
||||||
} else if (g_identity.numeric_ids || identity_super_implies_numeric()) {
|
} else if (g_identity.numeric_ids) {
|
||||||
gid = (gid_t)source_gid;
|
gid = (gid_t)source_gid;
|
||||||
set_gid = true;
|
set_gid = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -676,9 +676,11 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
|
|||||||
* --copy-as is different: the whole point of the flag is that the target
|
* --copy-as is different: the whole point of the flag is that the target
|
||||||
* ownership is REQUIRED (the pre-flight gate already refused an unprivileged
|
* ownership is REQUIRED (the pre-flight gate already refused an unprivileged
|
||||||
* receiver). If the chown still fails with EPERM/EACCES (a capability-
|
* receiver). If the chown still fails with EPERM/EACCES (a capability-
|
||||||
* restricted root, root-squash, or a read-only mount) the run is silently
|
* restricted root, root-squash, or a read-only mount) the run would be
|
||||||
* producing the WRONG ownership, so surface it at ERROR. It stays
|
* silently producing the WRONG ownership, so surface it at ERROR. The
|
||||||
* non-fatal: never abort the multithreaded receiver mid-transfer. */
|
* caller (identity_apply_ownership*) then reports the ENTRY as failed rather
|
||||||
|
* than as written; the receiver never claims a --copy-as success it did not
|
||||||
|
* achieve, but a single entry failure does not abort the whole run. */
|
||||||
if (errno == EPERM || errno == EACCES) {
|
if (errno == EPERM || errno == EACCES) {
|
||||||
if (identity_copy_as_active())
|
if (identity_copy_as_active())
|
||||||
log_message(LOG_LEVEL_ERROR,
|
log_message(LOG_LEVEL_ERROR,
|
||||||
@@ -695,35 +697,43 @@ static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
bool identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
||||||
/* Ownership application is OFF unless the client requested an identity flag.
|
/* Ownership application is OFF unless the client requested an identity flag.
|
||||||
* This is the controlled gate: a default (or plain -M) transfer never changes
|
* This is the controlled gate: a default (or plain -M) transfer never changes
|
||||||
* ownership, byte-for-byte preserving FastSync's existing behavior. --no-super
|
* ownership, byte-for-byte preserving FastSync's existing behavior. --no-super
|
||||||
* additionally forbids it even when the receiver is root. */
|
* additionally forbids it even when the receiver is root. */
|
||||||
if (!identity_active_enabled() || !privilege_super_permitted() || fd < 0)
|
if (!identity_active_enabled() || !privilege_super_permitted() || fd < 0)
|
||||||
return;
|
return true;
|
||||||
struct stat st;
|
struct stat st;
|
||||||
if (fstat(fd, &st) != 0)
|
if (fstat(fd, &st) != 0)
|
||||||
return;
|
return !identity_copy_as_active();
|
||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
||||||
return;
|
return true;
|
||||||
if (fchown(fd, uid, gid) != 0)
|
if (fchown(fd, uid, gid) != 0) {
|
||||||
identity_log_chown_failure("file", uid, gid);
|
identity_log_chown_failure("file", uid, gid);
|
||||||
|
/* A required --copy-as ownership that did not land is a per-entry failure;
|
||||||
|
* every other policy stays best-effort (rsync parity). */
|
||||||
|
return !identity_copy_as_active();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
bool identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||||
int32_t source_gid) {
|
int32_t source_gid) {
|
||||||
if (!identity_active_enabled() || !privilege_super_permitted() || parent_fd < 0 || !leaf)
|
if (!identity_active_enabled() || !privilege_super_permitted() || parent_fd < 0 || !leaf)
|
||||||
return;
|
return true;
|
||||||
struct stat st;
|
struct stat st;
|
||||||
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
|
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
|
||||||
return;
|
return !identity_copy_as_active();
|
||||||
uid_t uid;
|
uid_t uid;
|
||||||
gid_t gid;
|
gid_t gid;
|
||||||
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
||||||
return;
|
return true;
|
||||||
if (fchownat(parent_fd, leaf, uid, gid, AT_SYMLINK_NOFOLLOW) != 0)
|
if (fchownat(parent_fd, leaf, uid, gid, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||||
identity_log_chown_failure("no-follow entry", uid, gid);
|
identity_log_chown_failure("no-follow entry", uid, gid);
|
||||||
|
return !identity_copy_as_active();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-7
@@ -73,23 +73,40 @@ void identity_clear_active(void);
|
|||||||
|
|
||||||
/* True when any ownership-affecting identity option is present in the active
|
/* True when any ownership-affecting identity option is present in the active
|
||||||
* snapshot. Ownership stays OFF ("do not apply") for every transfer that
|
* snapshot. Ownership stays OFF ("do not apply") for every transfer that
|
||||||
* requests none of them, preserving FastSync's existing behavior. */
|
* requests none of them, preserving FastSync's existing behavior. --super /
|
||||||
|
* --no-super alone does NOT enable ownership; an explicit identity flag
|
||||||
|
* (--numeric-ids / --chown / --usermap / --groupmap / --copy-as) is required. */
|
||||||
bool identity_active_enabled(void);
|
bool identity_active_enabled(void);
|
||||||
|
|
||||||
|
/* Pure, config-only predicate: true when the client requested ANY
|
||||||
|
* client-chosen ownership or super-user activity (--numeric-ids, --chown,
|
||||||
|
* --usermap/--groupmap, --copy-as, --fake-super, or an explicit --super). Used
|
||||||
|
* by the daemon module gate to decide whether a module's per-module opt-in is
|
||||||
|
* required; it never reads the per-connection snapshot. */
|
||||||
|
bool identity_ownership_requested(const Config* config);
|
||||||
|
|
||||||
/* Apply the negotiated ownership to an already-written file descriptor.
|
/* Apply the negotiated ownership to an already-written file descriptor.
|
||||||
* source_uid/source_gid are the transmitted numeric ids. Resolution order:
|
* source_uid/source_gid are the transmitted numeric ids. Resolution order:
|
||||||
* a matching usermap/groupmap rule, then --chown, then --numeric-ids (raw),
|
* a matching usermap/groupmap rule, then --chown, then --numeric-ids (raw),
|
||||||
* then a best-effort name lookup on the receiver's own databases (skipped when
|
* then a best-effort name lookup on the receiver's own databases (skipped when
|
||||||
* the transmitted id has no name on this system). Only calls fchown() when the
|
* the transmitted id has no name on this system). Only calls fchown() when the
|
||||||
* result differs from the current value; EPERM/EACCES are logged and ignored,
|
* result differs from the current value.
|
||||||
* never fatal (rsync parity: the transfer must not abort). */
|
*
|
||||||
void identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid);
|
* Returns false ONLY when an active --copy-as ownership application failed: its
|
||||||
|
* forced ownership is REQUIRED, so the caller must treat the entry as failed
|
||||||
|
* rather than reporting success with the wrong owner. For every other identity
|
||||||
|
* policy an fchown EPERM/EACCES is logged and ignored and true is returned
|
||||||
|
* (rsync parity: the transfer must not abort). A no-op when no identity policy
|
||||||
|
* is active returns true. */
|
||||||
|
bool identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid);
|
||||||
|
|
||||||
/* P7 Wave D: the no-follow (symlink) counterpart. Resolves the same
|
/* P7 Wave D: the no-follow (symlink) counterpart. Resolves the same
|
||||||
* usermap/groupmap/chown/numeric-ids policy but applies it with
|
* usermap/groupmap/chown/numeric-ids/copy-as policy but applies it with
|
||||||
* fchownat(..., AT_SYMLINK_NOFOLLOW) so a symlink's own ownership is changed
|
* fchownat(..., AT_SYMLINK_NOFOLLOW) so a symlink's own ownership is changed
|
||||||
* without ever dereferencing it. A no-op unless an identity flag is active. */
|
* without ever dereferencing it. A no-op unless an identity flag is active.
|
||||||
void identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
* The return value follows identity_apply_ownership(): false only when an
|
||||||
|
* active --copy-as application failed. */
|
||||||
|
bool identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||||
int32_t source_gid);
|
int32_t source_gid);
|
||||||
|
|
||||||
/* Receiver-side wire validation of the resolved identity fields. */
|
/* Receiver-side wire validation of the resolved identity fields. */
|
||||||
|
|||||||
@@ -409,10 +409,14 @@ bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserv
|
|||||||
--groupmap / --chown). identity_apply_ownership is the controlled,
|
--groupmap / --chown). identity_apply_ownership is the controlled,
|
||||||
privilege-gated path: it consults the negotiated policy, resolves the
|
privilege-gated path: it consults the negotiated policy, resolves the
|
||||||
target ids, and applies them via an fd-relative fchown() that is confined
|
target ids, and applies them via an fd-relative fchown() that is confined
|
||||||
to the just-written file (EPERM/EACCES are logged, never fatal). With no
|
to the just-written file (EPERM/EACCES are logged, never fatal) -- EXCEPT
|
||||||
identity flag set it is a no-op, so a default or plain -M transfer keeps
|
for an active --copy-as, whose forced ownership is REQUIRED: a failure
|
||||||
FastSync's existing behavior of never applying client ownership. */
|
marks this entry as failed instead of reporting a wrong-owner write as
|
||||||
identity_apply_ownership(fd, (int32_t)metadata->uid, (int32_t)metadata->gid);
|
success. With no identity flag set it is a no-op, so a default or plain -M
|
||||||
|
transfer keeps FastSync's existing behavior of never applying client
|
||||||
|
ownership. */
|
||||||
|
if (!identity_apply_ownership(fd, (int32_t)metadata->uid, (int32_t)metadata->gid))
|
||||||
|
ok = false;
|
||||||
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
||||||
{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}};
|
{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}};
|
||||||
if (metadata->atime_valid) {
|
if (metadata->atime_valid) {
|
||||||
|
|||||||
+7
-2
@@ -340,7 +340,12 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
|
|||||||
* mirroring the normal metadata identity path; other errors are logged) and
|
* mirroring the normal metadata identity path; other errors are logged) and
|
||||||
* still applies mode/mtime where permitted.
|
* still applies mode/mtime where permitted.
|
||||||
*
|
*
|
||||||
* The OWNER leg additionally honors two policies:
|
* The OWNER leg additionally honors three policies:
|
||||||
|
* - an explicit ownership identity policy must be active (numeric-ids /
|
||||||
|
* chown / usermap / groupmap / copy-as). --fake-super on its own only
|
||||||
|
* RECORDS the source owner; replaying that owner as a live chown without an
|
||||||
|
* explicit ownership opt-in would be an un-gated client-chosen-ownership
|
||||||
|
* primitive.
|
||||||
* - --no-super (privilege_super_permitted() false) suppresses it even for a
|
* - --no-super (privilege_super_permitted() false) suppresses it even for a
|
||||||
* root receiver, exactly like the normal metadata identity path.
|
* root receiver, exactly like the normal metadata identity path.
|
||||||
* - an active --copy-as is AUTHORITATIVE: the identity path already forced the
|
* - an active --copy-as is AUTHORITATIVE: the identity path already forced the
|
||||||
@@ -370,7 +375,7 @@ bool fake_super_restore_fd(int fd) {
|
|||||||
not hidden. --no-super suppresses the owner leg even for root, and an
|
not hidden. --no-super suppresses the owner leg even for root, and an
|
||||||
active --copy-as is authoritative so its forced owner must not be
|
active --copy-as is authoritative so its forced owner must not be
|
||||||
overwritten by the recorded source owner. */
|
overwritten by the recorded source owner. */
|
||||||
if (privilege_super_permitted() && !identity_copy_as_active() &&
|
if (identity_active_enabled() && privilege_super_permitted() && !identity_copy_as_active() &&
|
||||||
fchown(fd, (uid_t)ul_uid, (gid_t)ul_gid) != 0 && errno != EPERM && errno != EACCES)
|
fchown(fd, (uid_t)ul_uid, (gid_t)ul_gid) != 0 && errno != EPERM && errno != EACCES)
|
||||||
log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore owner on destination file: %s",
|
log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore owner on destination file: %s",
|
||||||
strerror(errno));
|
strerror(errno));
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ FILES_MODULE = os.path.join(MODULE_ROOT, "files")
|
|||||||
READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
|
READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
|
||||||
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
|
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
|
||||||
TEAM_MODULE = os.path.join(MODULE_ROOT, "team")
|
TEAM_MODULE = os.path.join(MODULE_ROOT, "team")
|
||||||
|
OWNER_MODULE = os.path.join(MODULE_ROOT, "owner")
|
||||||
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
|
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
|
||||||
CRED_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.passwd")
|
CRED_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.passwd")
|
||||||
STARTFAIL_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_startfail.conf")
|
STARTFAIL_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_startfail.conf")
|
||||||
@@ -149,7 +150,8 @@ def _config_port(config_path):
|
|||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
def daemon_env():
|
def daemon_env():
|
||||||
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, DETACH_MODULE):
|
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE,
|
||||||
|
DETACH_MODULE):
|
||||||
shutil.rmtree(d, ignore_errors=True)
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
os.makedirs(d, exist_ok=True)
|
os.makedirs(d, exist_ok=True)
|
||||||
generate_test_files(SOURCE_DIR, full=False)
|
generate_test_files(SOURCE_DIR, full=False)
|
||||||
@@ -184,7 +186,11 @@ def daemon_env():
|
|||||||
"[team]\n"
|
"[team]\n"
|
||||||
"path = %s\n"
|
"path = %s\n"
|
||||||
"auth users = alice,bob\n"
|
"auth users = alice,bob\n"
|
||||||
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE))
|
"\n"
|
||||||
|
"[owner]\n"
|
||||||
|
"path = %s\n"
|
||||||
|
"client owner = yes\n"
|
||||||
|
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, TEAM_MODULE, OWNER_MODULE))
|
||||||
|
|
||||||
# A dedicated config for the fail-closed startup check: an auth-required
|
# A dedicated config for the fail-closed startup check: an auth-required
|
||||||
# module with no credential store must refuse to start. Its own free port
|
# module with no credential store must refuse to start. Its own free port
|
||||||
@@ -320,49 +326,62 @@ class TestDaemonRejection:
|
|||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
assert _tree_file_count(AUTH_MODULE) == 0
|
assert _tree_file_count(AUTH_MODULE) == 0
|
||||||
|
|
||||||
def test_copy_as_refused_by_daemon(self, daemon):
|
def _assert_ownership_refused(self, daemon, module, flags):
|
||||||
"""P7 Wave E: a daemon refuses client-chosen ownership (--copy-as)
|
"""A daemon module without `client owner = yes` refuses every
|
||||||
outright. There is no per-module opt-in, so even a root daemon must not
|
client-chosen ownership / super-user request at the config handshake,
|
||||||
honor an arbitrary client-selected owner. The refusal happens at the
|
|
||||||
config handshake, before any data lands."""
|
|
||||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
|
||||||
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
|
||||||
before_files = self._tree_files()
|
|
||||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=daemon.port,
|
|
||||||
flags=["--copy-as=@65534:@65534"])
|
|
||||||
assert result.returncode != 0, "the daemon must refuse --copy-as"
|
|
||||||
assert self._tree_files() == before_files, \
|
|
||||||
"--copy-as refusal wrote under the module root"
|
|
||||||
time.sleep(0.3)
|
|
||||||
with open(log_path, "rb") as f:
|
|
||||||
f.seek(before)
|
|
||||||
tail = f.read().decode("utf-8", "replace")
|
|
||||||
assert "copy-as is refused by the daemon" in tail, (
|
|
||||||
f"daemon did not log the copy-as refusal: {tail[-400:]!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_super_refused_by_daemon(self, daemon):
|
|
||||||
"""P7 Wave E: --super (SUPER_MODE_ON) implies raw numeric-id ownership
|
|
||||||
with no explicit identity flag, so a daemon refuses it for the same
|
|
||||||
reason it refuses --copy-as: there is no per-module opt-in for
|
|
||||||
client-chosen ownership. The refusal happens at the config handshake,
|
|
||||||
before any data lands."""
|
before any data lands."""
|
||||||
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
||||||
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
before = os.path.getsize(log_path) if os.path.exists(log_path) else 0
|
||||||
before_files = self._tree_files()
|
before_files = self._tree_files()
|
||||||
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=daemon.port,
|
result, _ = run_client(SOURCE_DIR, f"127.0.0.1::{module}", port=daemon.port, flags=flags)
|
||||||
flags=["--super", "--preserve"])
|
assert result.returncode != 0, f"the daemon must refuse {flags}"
|
||||||
assert result.returncode != 0, "the daemon must refuse --super"
|
|
||||||
assert self._tree_files() == before_files, \
|
assert self._tree_files() == before_files, \
|
||||||
"--super refusal wrote under the module root"
|
f"{flags} refusal wrote under the module root"
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
with open(log_path, "rb") as f:
|
with open(log_path, "rb") as f:
|
||||||
f.seek(before)
|
f.seek(before)
|
||||||
tail = f.read().decode("utf-8", "replace")
|
tail = f.read().decode("utf-8", "replace")
|
||||||
assert "super is refused by the daemon" in tail, (
|
assert "client-chosen ownership" in tail, (
|
||||||
f"daemon did not log the --super refusal: {tail[-400:]!r}"
|
f"daemon did not log the ownership refusal: {tail[-400:]!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_copy_as_refused_by_daemon(self, daemon):
|
||||||
|
"""P7 Wave E hardening: a daemon refuses client-chosen ownership
|
||||||
|
(--copy-as) outright unless the module opts in with `client owner = yes`,
|
||||||
|
so even a root daemon must not honor an arbitrary client-selected owner
|
||||||
|
by default. The refusal happens at the config handshake, before any data
|
||||||
|
lands."""
|
||||||
|
self._assert_ownership_refused(daemon, "files", ["--copy-as=@65534:@65534"])
|
||||||
|
|
||||||
|
def test_super_refused_by_daemon(self, daemon):
|
||||||
|
"""An explicit --super is a super-user activity request, so a daemon
|
||||||
|
module refuses it unless it opts in with `client owner = yes`. The
|
||||||
|
refusal happens at the config handshake, before any data lands."""
|
||||||
|
self._assert_ownership_refused(daemon, "files", ["--super", "--preserve"])
|
||||||
|
|
||||||
|
def test_numeric_ids_refused_by_daemon(self, daemon):
|
||||||
|
"""P7 Wave E hardening (A1): the daemon ownership gate must cover the
|
||||||
|
pre-existing identity flags too, not only --copy-as/--super. A module
|
||||||
|
without `client owner = yes` refuses --numeric-ids at the handshake."""
|
||||||
|
self._assert_ownership_refused(daemon, "files", ["--numeric-ids", "--preserve"])
|
||||||
|
|
||||||
|
def test_chown_refused_by_daemon(self, daemon):
|
||||||
|
"""--chown is client-chosen ownership too and must be refused by a
|
||||||
|
non-opted-in module."""
|
||||||
|
self._assert_ownership_refused(daemon, "files", ["--chown=@65534:@65534", "--preserve"])
|
||||||
|
|
||||||
|
def test_owner_opt_in_allows_numeric_ids(self, daemon):
|
||||||
|
"""A module that opts in with `client owner = yes` accepts the
|
||||||
|
client-chosen ownership flags (here --numeric-ids); the transfer
|
||||||
|
succeeds and lands inside that module root."""
|
||||||
|
result, _ = run_client(SOURCE_DIR, "127.0.0.1::owner", port=daemon.port,
|
||||||
|
flags=["--numeric-ids", "--preserve"])
|
||||||
|
assert result.returncode == 0, result.stderr or result.stdout
|
||||||
|
received = get_dest_received_dir(OWNER_MODULE, SOURCE_DIR)
|
||||||
|
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||||
|
assert not missing, f"missing: {missing[:5]}"
|
||||||
|
assert not mismatches, f"mismatch: {mismatches[:5]}"
|
||||||
|
|
||||||
@pytest.mark.daemon_detach
|
@pytest.mark.daemon_detach
|
||||||
def test_real_detach_path(self):
|
def test_real_detach_path(self):
|
||||||
"""--daemon WITHOUT --no-detach double-forks a real background daemon;
|
"""--daemon WITHOUT --no-detach double-forks a real background daemon;
|
||||||
|
|||||||
@@ -4165,11 +4165,11 @@ class TestSuperPrivilege:
|
|||||||
f"--no-super must not apply ownership (uid={st.st_uid} gid={st.st_gid})"
|
f"--no-super must not apply ownership (uid={st.st_uid} gid={st.st_gid})"
|
||||||
|
|
||||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||||
def test_super_applies_ownership_as_root(self, shared_server):
|
def test_super_alone_does_not_apply_ownership_as_root(self, shared_server):
|
||||||
"""Control/proof the flag is not inert for root: --super with no explicit
|
"""A3: --super no longer implies --numeric-ids, so --super alone must NOT
|
||||||
identity policy treats ownership as raw numeric ids (as --numeric-ids),
|
apply client-chosen ownership even for root; the destination keeps the
|
||||||
applying the very ownership --no-super suppressed."""
|
receiver's owner (the exact ownership --no-super would also suppress)."""
|
||||||
source, dest = self._seed("super")
|
source, dest = self._seed("superonly")
|
||||||
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
||||||
result, _ = run_client(source, dest,
|
result, _ = run_client(source, dest,
|
||||||
flags=["--preserve", "--super"],
|
flags=["--preserve", "--super"],
|
||||||
@@ -4178,8 +4178,25 @@ class TestSuperPrivilege:
|
|||||||
f"exit {result.returncode}: {(result.stderr or '')[:300]}"
|
f"exit {result.returncode}: {(result.stderr or '')[:300]}"
|
||||||
received = get_dest_received_dir(dest, source)
|
received = get_dest_received_dir(dest, source)
|
||||||
st = os.stat(os.path.join(received, "f.txt"))
|
st = os.stat(os.path.join(received, "f.txt"))
|
||||||
|
assert (st.st_uid, st.st_gid) != (12345, 12346), \
|
||||||
|
f"--super alone must not apply ownership (uid={st.st_uid} gid={st.st_gid})"
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||||
|
def test_super_with_numeric_ids_applies_ownership_as_root(self, shared_server):
|
||||||
|
"""Control: an explicit identity policy is what enables ownership, so
|
||||||
|
--numeric-ids --super still applies the raw ids as root (the very
|
||||||
|
ownership --no-super suppresses)."""
|
||||||
|
source, dest = self._seed("supernumeric")
|
||||||
|
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
||||||
|
result, _ = run_client(source, dest,
|
||||||
|
flags=["--preserve", "--numeric-ids", "--super"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, \
|
||||||
|
f"exit {result.returncode}: {(result.stderr or '')[:300]}"
|
||||||
|
received = get_dest_received_dir(dest, source)
|
||||||
|
st = os.stat(os.path.join(received, "f.txt"))
|
||||||
assert (st.st_uid, st.st_gid) == (12345, 12346), \
|
assert (st.st_uid, st.st_gid) == (12345, 12346), \
|
||||||
f"--super should apply raw ids: uid={st.st_uid} gid={st.st_gid}"
|
f"--numeric-ids --super should apply raw ids: uid={st.st_uid} gid={st.st_gid}"
|
||||||
|
|
||||||
@pytest.mark.ci
|
@pytest.mark.ci
|
||||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||||
|
|||||||
@@ -1899,6 +1899,68 @@ static void test_privilege_super_permitted_modes() {
|
|||||||
EXPECT_TRUE(privilege_super_permitted());
|
EXPECT_TRUE(privilege_super_permitted());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* P7 Wave E hardening (A1): identity_ownership_requested() is the pure,
|
||||||
|
config-only predicate the daemon module gate uses. It must fire for every
|
||||||
|
client-chosen ownership / super-user request and stay false for a plain
|
||||||
|
transfer and for SUPER_MODE_AUTO (the default) alone. */
|
||||||
|
static void test_identity_ownership_requested() {
|
||||||
|
EXPECT_FALSE(identity_ownership_requested(NULL));
|
||||||
|
|
||||||
|
Config* c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
EXPECT_FALSE(identity_ownership_requested(c));
|
||||||
|
c->super_mode = SUPER_MODE_AUTO;
|
||||||
|
EXPECT_FALSE(identity_ownership_requested(c)); /* AUTO alone is not ownership */
|
||||||
|
c->super_mode = SUPER_MODE_ON;
|
||||||
|
EXPECT_TRUE(identity_ownership_requested(c)); /* explicit --super is */
|
||||||
|
c->super_mode = SUPER_MODE_AUTO;
|
||||||
|
|
||||||
|
c->numeric_ids = true;
|
||||||
|
EXPECT_TRUE(identity_ownership_requested(c));
|
||||||
|
c->numeric_ids = false;
|
||||||
|
c->chown_uid_set = true;
|
||||||
|
EXPECT_TRUE(identity_ownership_requested(c));
|
||||||
|
c->chown_uid_set = false;
|
||||||
|
c->chown_gid_set = true;
|
||||||
|
EXPECT_TRUE(identity_ownership_requested(c));
|
||||||
|
c->chown_gid_set = false;
|
||||||
|
c->copy_as_set = true;
|
||||||
|
EXPECT_TRUE(identity_ownership_requested(c));
|
||||||
|
c->copy_as_set = false;
|
||||||
|
c->fake_super = true;
|
||||||
|
EXPECT_TRUE(identity_ownership_requested(c));
|
||||||
|
config_delete(c);
|
||||||
|
|
||||||
|
Config* um = config_create();
|
||||||
|
EXPECT_NOT_NULL(um);
|
||||||
|
EXPECT_EQ_INT(identity_parse_map(um, "@1:@2", false), 0);
|
||||||
|
EXPECT_TRUE(identity_ownership_requested(um));
|
||||||
|
config_delete(um);
|
||||||
|
|
||||||
|
Config* gm = config_create();
|
||||||
|
EXPECT_NOT_NULL(gm);
|
||||||
|
EXPECT_EQ_INT(identity_parse_map(gm, "@1:@2", true), 0);
|
||||||
|
EXPECT_TRUE(identity_ownership_requested(gm));
|
||||||
|
config_delete(gm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* P7 Wave E hardening (A3): --super no longer implies raw numeric-id
|
||||||
|
preservation, so it must never enable ownership application on its own; an
|
||||||
|
explicit identity flag is required. */
|
||||||
|
static void test_super_does_not_imply_numeric() {
|
||||||
|
Config* c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
c->super_mode = SUPER_MODE_ON;
|
||||||
|
c->use_metadata = true;
|
||||||
|
identity_set_active(c);
|
||||||
|
EXPECT_FALSE(identity_active_enabled());
|
||||||
|
c->numeric_ids = true;
|
||||||
|
identity_set_active(c);
|
||||||
|
EXPECT_TRUE(identity_active_enabled());
|
||||||
|
identity_clear_active();
|
||||||
|
config_delete(c);
|
||||||
|
}
|
||||||
|
|
||||||
void test_config() {
|
void test_config() {
|
||||||
test_config_lifecycle();
|
test_config_lifecycle();
|
||||||
test_config_ssh_dest();
|
test_config_ssh_dest();
|
||||||
@@ -1953,6 +2015,8 @@ void test_config() {
|
|||||||
test_config_receive_with_validate_rejects();
|
test_config_receive_with_validate_rejects();
|
||||||
}
|
}
|
||||||
test_identity_copy_as_refused();
|
test_identity_copy_as_refused();
|
||||||
|
test_identity_ownership_requested();
|
||||||
|
test_super_does_not_imply_numeric();
|
||||||
test_privilege_super_permitted_modes();
|
test_privilege_super_permitted_modes();
|
||||||
test_config_delete_timing_early_helper();
|
test_config_delete_timing_early_helper();
|
||||||
test_config_is_remote_dest();
|
test_config_is_remote_dest();
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ static void test_daemon_conf_full_parse() {
|
|||||||
"[backup]\n"
|
"[backup]\n"
|
||||||
"path = /srv/backup\n"
|
"path = /srv/backup\n"
|
||||||
"read only = yes\n"
|
"read only = yes\n"
|
||||||
|
"client owner = yes\n"
|
||||||
"auth users = alice, bob\n",
|
"auth users = alice, bob\n",
|
||||||
&path),
|
&path),
|
||||||
0);
|
0);
|
||||||
@@ -57,6 +58,7 @@ static void test_daemon_conf_full_parse() {
|
|||||||
EXPECT_EQ_STR(conf->modules[0].name, "backup");
|
EXPECT_EQ_STR(conf->modules[0].name, "backup");
|
||||||
EXPECT_EQ_STR(conf->modules[0].path, "/srv/backup");
|
EXPECT_EQ_STR(conf->modules[0].path, "/srv/backup");
|
||||||
EXPECT_TRUE(conf->modules[0].read_only);
|
EXPECT_TRUE(conf->modules[0].read_only);
|
||||||
|
EXPECT_TRUE(conf->modules[0].client_owner);
|
||||||
EXPECT_EQ_INT(conf->modules[0].auth_user_count, 2);
|
EXPECT_EQ_INT(conf->modules[0].auth_user_count, 2);
|
||||||
EXPECT_EQ_STR(conf->modules[0].auth_users[0], "alice");
|
EXPECT_EQ_STR(conf->modules[0].auth_users[0], "alice");
|
||||||
EXPECT_EQ_STR(conf->modules[0].auth_users[1], "bob");
|
EXPECT_EQ_STR(conf->modules[0].auth_users[1], "bob");
|
||||||
@@ -84,6 +86,10 @@ static void test_daemon_conf_comments_and_blank_lines() {
|
|||||||
EXPECT_EQ_INT(conf->module_count, 2);
|
EXPECT_EQ_INT(conf->module_count, 2);
|
||||||
EXPECT_EQ_STR(conf->modules[0].name, "alpha");
|
EXPECT_EQ_STR(conf->modules[0].name, "alpha");
|
||||||
EXPECT_EQ_STR(conf->modules[1].name, "beta");
|
EXPECT_EQ_STR(conf->modules[1].name, "beta");
|
||||||
|
/* `client owner` defaults to off: a module must opt in to client-chosen
|
||||||
|
ownership. */
|
||||||
|
EXPECT_FALSE(conf->modules[0].client_owner);
|
||||||
|
EXPECT_FALSE(conf->modules[1].client_owner);
|
||||||
daemon_conf_free(conf);
|
daemon_conf_free(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +213,12 @@ static void test_daemon_conf_malformed_rejected() {
|
|||||||
EXPECT_NULL(conf);
|
EXPECT_NULL(conf);
|
||||||
EXPECT_TRUE(strstr(err, "read only") != NULL);
|
EXPECT_TRUE(strstr(err, "read only") != NULL);
|
||||||
|
|
||||||
|
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nclient owner = maybe\n", &path), 0);
|
||||||
|
conf = daemon_conf_load(path, err, sizeof(err));
|
||||||
|
free(path);
|
||||||
|
EXPECT_NULL(conf);
|
||||||
|
EXPECT_TRUE(strstr(err, "client owner") != NULL);
|
||||||
|
|
||||||
EXPECT_EQ_INT(write_conf("= value\n", &path), 0);
|
EXPECT_EQ_INT(write_conf("= value\n", &path), 0);
|
||||||
conf = daemon_conf_load(path, err, sizeof(err));
|
conf = daemon_conf_load(path, err, sizeof(err));
|
||||||
free(path);
|
free(path);
|
||||||
|
|||||||
+16
-3
@@ -305,6 +305,10 @@ static void test_fake_super_owner_gate() {
|
|||||||
Config* c = config_create();
|
Config* c = config_create();
|
||||||
EXPECT_NOT_NULL(c);
|
EXPECT_NOT_NULL(c);
|
||||||
|
|
||||||
|
/* An explicit ownership policy is required before fake-super replay may
|
||||||
|
chown; --fake-super alone only records the source owner (A2). */
|
||||||
|
c->numeric_ids = true;
|
||||||
|
|
||||||
/* --no-super: the owner leg is skipped even as root. */
|
/* --no-super: the owner leg is skipped even as root. */
|
||||||
c->super_mode = SUPER_MODE_OFF;
|
c->super_mode = SUPER_MODE_OFF;
|
||||||
identity_set_active(c);
|
identity_set_active(c);
|
||||||
@@ -314,7 +318,7 @@ static void test_fake_super_owner_gate() {
|
|||||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||||
EXPECT_EQ_INT((int)st.st_gid, 0);
|
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||||
|
|
||||||
/* AUTO: the recorded source owner is applied. */
|
/* AUTO with an identity policy: the recorded source owner is applied. */
|
||||||
c->super_mode = SUPER_MODE_AUTO;
|
c->super_mode = SUPER_MODE_AUTO;
|
||||||
identity_set_active(c);
|
identity_set_active(c);
|
||||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||||
@@ -322,10 +326,19 @@ static void test_fake_super_owner_gate() {
|
|||||||
EXPECT_EQ_INT((int)st.st_uid, 12345);
|
EXPECT_EQ_INT((int)st.st_uid, 12345);
|
||||||
EXPECT_EQ_INT((int)st.st_gid, 12346);
|
EXPECT_EQ_INT((int)st.st_gid, 12346);
|
||||||
|
|
||||||
|
/* --super / --fake-super with NO explicit identity flag must NOT apply a
|
||||||
|
client-chosen owner: super_mode alone never enables ownership. */
|
||||||
|
EXPECT_EQ_INT(fchown(fd, 0, 0), 0);
|
||||||
|
c->numeric_ids = false;
|
||||||
|
c->super_mode = SUPER_MODE_ON;
|
||||||
|
identity_set_active(c);
|
||||||
|
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||||
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||||
|
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||||
|
|
||||||
/* Active --copy-as is authoritative: the recorded source owner must not
|
/* Active --copy-as is authoritative: the recorded source owner must not
|
||||||
override it, even with AUTO/ON. */
|
override it, even with AUTO/ON. */
|
||||||
EXPECT_EQ_INT(fchown(fd, 0, 0), 0);
|
|
||||||
c->super_mode = SUPER_MODE_ON;
|
|
||||||
c->copy_as_set = true;
|
c->copy_as_set = true;
|
||||||
c->copy_as_uid = 777;
|
c->copy_as_uid = 777;
|
||||||
c->copy_as_gid = 778;
|
c->copy_as_gid = 778;
|
||||||
|
|||||||
Reference in New Issue
Block a user