uvol: update to 1.2, expose volume operations over ubus - #30360
Conversation
openwrt-ai
left a comment
There was a problem hiding this comment.
Reviewed 3 commits (ee595d7, a71ef0a, f8f948d); each message matches its diff, and the Makefile side looks right — PKG_RELEASE correctly stays at 1 for the 1.1 → 1.2 bump, the postinst follows the same unindented rpcd reload shape as utils/rpcd-mod-lxc and utils/rpcd-mod-wireguard, and size: 64 in the plugin signature is what makes rpcd pick a BLOBMSG_TYPE_INT64 policy entry rather than INT32, so volumes above 2 GiB pass through. CI is green on f8f948d.
One finding I would call merge-blocking: the volume name now crosses a trust boundary and the backends interpolate it into shell command lines unquoted — see the comment on the validation in uvol-rpcd. The remaining three are a design question about create and rpcd's 120 s SIGKILL, plus two nits.
Generated by Claude Code
| let name = args.name; | ||
| if (name != null && (type(name) != "string" || index(name, "/") >= 0)) | ||
| reply_code(22); |
There was a problem hiding this comment.
Rejecting only / is not enough now that the volume name arrives over ubus. The backends interpolate vol_name straight into shell command lines:
system(sprintf("ubimkvol /dev/%s -N \"uvol-%s-%s\" -s %d", ...))at ubi.uc:152system(sprintf("/usr/sbin/mkfs.f2fs -f -l \"%s\" \"%s\"", vol_name, ...))at lvm.uc:356- the
-Sselector built at lvm.uc:85, which ends up infs.popen(...)at lvm.uc:20
A name like x";reboot;" or x`cmd` therefore executes as root. Until now the only caller was a root-only CLI, so this was not a trust boundary; the ubus object makes it one.
Second, nothing stops name from being .meta: remove/down/resize would then hit the internal metadata volume that boot/meta_init manage. uvol_mount.register already refuses dot-names, but backend.remove() does not.
An allowlist covers both:
| let name = args.name; | |
| if (name != null && (type(name) != "string" || index(name, "/") >= 0)) | |
| reply_code(22); | |
| let name = args.name; | |
| // names reach the backends inside shell command lines and lvm -S expressions, | |
| // so allow only characters that are safe there; no leading dot keeps the | |
| // internal '.meta' volume unreachable over ubus | |
| if (name != null && (type(name) != "string" || | |
| !match(name, /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/))) | |
| reply_code(22); |
Generated by Claude Code
| if (cmd == "create") | ||
| reply_code(backend.create(name, size, args.mode)); |
There was a problem hiding this comment.
create runs mkfs.f2fs / mke2fs (or ubimkvol + ubiupdatevol -t) synchronously, and rpcd caps an exec plugin call at RPC_EXEC_DEFAULT_TIMEOUT (120 * 1000) — on expiry rpc_exec_timeout_cb() sends SIGKILL to the plugin process and replies UBUS_STATUS_TIMEOUT.
For a multi-gigabyte volume on slow flash that ceiling is reachable. When it fires, only the ucode process dies: the mkfs started via system() is orphaned and keeps writing, while the flock taken by lock_device() is released with the process — so a following create/remove proceeds concurrently with a still-running mkfs. The wp_/wo_ naming means the volume is at least reclaimable later, but the lock guarantee the commit message relies on ("taking the same locks as the CLI so both entry points stay serialised") does not survive the kill.
Is the intent that consumers keep long-running creates on the CLI, or should create become an async submit/poll pair (return immediately, report progress through status)? Worth a note either way.
Generated by Claude Code
| list: { args: { name: "volname" } }, | ||
| status: { args: { name: "volname" }, need_name: true }, | ||
| up: { args: { name: "volname" }, need_name: true, lock: true }, | ||
| down: { args: { name: "volname" }, need_name: true, lock: true }, | ||
| remove: { args: { name: "volname" }, need_name: true, lock: true }, |
There was a problem hiding this comment.
nit: the method table omits device and size, which the CLI does expose (device volname / size volname) and which every backend implements (backend.device / backend.size in lvm.uc). list does not carry the device either — lvm_list() returns only name, mode and size.
Given the commit message says consumers such as uxc "must not exec the command line tool", how is such a consumer meant to locate a volume's block device after up — is it expected to rely purely on the blockd mount at /tmp/run/uvol/<name>? If so a sentence in the commit message would help; otherwise device looks like it belongs in the table. (write needs a data stream and clearly cannot move here — no comment on that one.)
Generated by Claude Code
| if (type(res) == "int") | ||
| reply_code(res); |
There was a problem hiding this comment.
nit: the error path here reports the wrong thing. lvm_free() / lvm_total() / lvm_align() return 2 when the VG is unavailable (lvm.uc:163-166), and reply_code(2) renders that through error_text as "no such volume" — a per-volume message for three device-wide queries. A dedicated reply reads better:
| if (type(res) == "int") | |
| reply_code(res); | |
| if (type(res) == "int") | |
| reply({ code: res, error: "backend unavailable" }); |
Generated by Claude Code
|
Fixed the two real findings, and two of the four points in the review don't hold up under closer inspection. Pushed as Command injection and On severity: this is root-to-root today, not a new privilege boundary. The unsanitised inputs that reach a volume name are a Two review comments were wrong. Both checked against source, not asserted:
free/total/align error reporting: fixed. These three now reply with their own One thing deliberately left out: two pre-existing |
openwrt-ai
left a comment
There was a problem hiding this comment.
Reviewed the three new commits (f79dda1b, 63fe84a8, 166ccc04); each message matches its diff, including the parts that go beyond the subject and say so — the -j move ahead of the lock in 63fe84a8 (which also fixes lock_volume("-j")) and the free/total/align error text. CI is green on 166ccc04.
Both of your corrections check out, and thanks for them. package/system/rpcd/files/rpcd.config in openwrt.git does ship option timeout 30 as a conffile, so my 120 was the compiled fallback and wrong for a stock image — though 30 s makes a large create more likely to be killed, not less, so the open question on that thread stands rather than goes away. I'll take the O_CLOEXEC/inherited-flock reasoning as read; it matches how flock is scoped to the open file description.
One finding on the new work: the validator closes the shell and the .meta holes, but . is still a metacharacter in the lvm -S ... lvname=~ expression the name is interpolated into, so a non-existent name can select a real neighbouring volume — details on lvs(). Plus one nit on the null-return in lvm(). Neither is a regression from this series; the first is the same trust boundary the series is hardening, so it seems worth settling here.
Generated by Claude Code
| let lvexpr = sprintf("lvname=~^[rw][owp]_%s$ && vg_name=%s%s%s", | ||
| vol_name, vg_name, extra_exp?" && ":"", extra_exp?extra_exp:""); |
There was a problem hiding this comment.
vol_name still lands raw inside an lvm regular expression here (lvname=~), and the new validator admits . — /^[A-Za-z0-9_][A-Za-z0-9._-]*$/ at common.uc:38 — which is a metacharacter in that expression. Shell-quoting the argument in lvm() keeps the shell out of it, but lvm's own selection parser still sees the wildcard.
So on a VG holding rw_axb, uvol remove a.b builds lvname=~^[rw][owp]_a.b$, lvs() returns the unrelated LV, and lvm("lvrename", vg_name, res[0].lv_name, ...) at lvm.uc:501 marks that volume for deletion — a name that does not exist deletes one that does. up/down/status mis-target the same way through lvm_updown()/lvm_status(), and lvs_incomplete() at lvm.uc:113 repeats the pattern, so create a.b can reclaim an unrelated incomplete volume of matching size.
The ubi backend is fine: it matches with wildcard() at ubi.uc:46, and the validator already excludes every fnmatch metacharacter. Only lvm's regex is left.
Two ways out, and it's your call which: escape the regex metacharacters where the expression is built (here and at line 113), or drop . from the second character class in name_valid — the allow_internal branch already returns early for .meta, the one internal name that needs a dot, and <algo>-<hexdigest> content-addressed names don't contain one. No suggestion block since the two fixes differ in what they mean for existing volume names.
Generated by Claude Code
| if (cmd in lvm_json_cmds) | ||
| json_param = "--reportformat json --units b "; | ||
| let stdout = fs.popen(sprintf("LVM_SUPPRESS_FD_WARNINGS=1 %s %s %s%s", lvm_exec, cmd, json_param, join(" ", args))); | ||
| let stdout = fs.popen(sprintf("LVM_SUPPRESS_FD_WARNINGS=1 %s %s %s%s", lvm_exec, cmd, json_param, join(" ", map(args, shell_quote)))); |
There was a problem hiding this comment.
nit: 166ccc04 only half-closes the null-return it describes. When this popen() fails, lvm() prints and return null at lvm.uc:39 — the ?.report?. guards now cover the five report readers, but the ~20 .retval readers of the non-JSON commands still dereference straight into it: lvchange_r.retval at lvm.uc:291, ret.retval after lvcreate at lvm.uc:344, and so on. Those commands never reach the json() parse, so a fork/pipe failure under memory pressure — plausible on a small device midway through a create that is also running mkfs — ends the script with the same reference error the commit set out to replace with an error code. Giving lvm() a { retval: -1 } fallback instead of null would cover both sides in one place.
Generated by Claude Code
|
Both fixed, pushed as LVM regex mis-targeting: fixed in On the fix itself: escaping, not narrowing the validator. Dots are permitted in volume names and uxc uses them for every data volume, so a name can silently match a different volume; a collision needs a specific pair of names and is unlikely in practice, but Incomplete null-guard: fixed in |
…ling Volume names were interpolated into shell command lines at every backend call site, so a name containing shell metacharacters ran arbitrary commands as root. ucode's system() takes an array and execs it directly, so every call site that interpolates a name, a device or a path now passes an argument vector instead. Three sites genuinely want a shell and keep it, with their arguments quoted: the lvm() helper reads its JSON report via popen(), the content-addressed digest pipeline quotes the volume path, and taking a volume down attempts a umount whether or not anything mounted it, so that one keeps its redirection and is now shared by both backends instead of spelt out per call site. Suppressed errors that were only hiding a useless diagnostic are dropped, and the filesystem grow tool is now located by searching PATH instead of asking a shell. lvm matches lvname with its own regular expression, and a volume name was placed in that expression raw, so a dot in a name matched any character instead of itself: a volume called a.b resolved to an unrelated axb. Dotted names are the norm, since uxc composes a data volume as <container>.<volname>; the name is now escaped where the selection expression is built. Every lvs, vgs and pvs caller dereferenced straight into the JSON report, so an lvm invocation that failed to produce one, or failed to run at all, crashed with a reference error instead of an error code. Both now report through the .retval every caller already checks. Volume names reaching the command line are now restricted to ^[A-Za-z0-9_][A-Za-z0-9._-]*$: a leading dot is reserved for uvol's own volumes such as .meta, and a leading dash would be read as an option by the backend tools. Only the read-only verbs may name an internal volume, closing remove, down and resize being able to target the metadata volume, which the backends never rejected the way mount registration does; uvol's own boot path calls the backend directly and is unaffected. The check runs before the per-volume lock, which is keyed on the volume name too, which is why the -j flag is now consumed before both. A rejected name exits 22 rather than falling off the end of the script, which reports success. Reachable only by root today, through the command line or a volume name composed by uxc from an author-controlled registration; fixed as defence in depth. Fixes: 6350c7b ("uvol: replace with re-write in ucode") Signed-off-by: Daniel Golle <daniel@makrotopia.org>
Move the storage backend probe, the ctx construction, the locking helpers and the volume-name check out of the CLI script into /usr/lib/uvol/common.uc, so a second entry point can reuse them without duplicating the logic. Publish ubus object 'uvol' with the volume operations and a readiness query. Consumers such as uxc are pure ubus frontends and must not exec the command line tool. Ship an rpcd exec plugin, installed by the uvol package itself, taking the same locks as the CLI so both entry points stay serialised. As stdout is the plugin's reply channel, point file descriptor 1 at stderr for the duration of a call. Send a 'uvol.ready' ubus event at the end of 'uvol boot', carrying the active backend name and whether the .meta volume is ready. Consumers such as uxc can wait for this event instead of polling volume state. Sending is best-effort: boot keeps its exit code even when ubusd is not reachable. Signed-off-by: Daniel Golle <daniel@makrotopia.org>
openwrt-ai
left a comment
There was a problem hiding this comment.
Reviewed the updated series (rebased onto master, now 2 commits). The only uvol change since my last review is in files/lvm.uc, folded into 9ebb068: lvname_quote() now escapes . at both -S interpolation sites (lvs, lvs_incomplete), and lvm() returns { retval: -1 } instead of null on failure. Both address the findings from the prior round — the regex mis-targeting and the unguarded .retval readers — and the escape set matches name_valid()'s permitted characters (only . is regex-special there). No new issues found.
Generated by Claude Code
📦 Package Details
Maintainer: @dangowrt
Description:
Expose uvol's volume operations over ubus via an rpcd exec plugin.
🧪 Run Testing Details
✅ Formalities