From a9349d3107e2d1fcbd5c462d32b6c2d33a60a77d Mon Sep 17 00:00:00 2001 From: Aad Versteden Date: Tue, 22 Sep 2026 10:55:10 +0200 Subject: [PATCH] Initial import --- Makefile | 17 ++++++ README.org | 133 +++++++++++++++++++++++++++++++++++++++++++ src/mount-opencode | 23 ++++++++ src/mountctl.c | 120 ++++++++++++++++++++++++++++++++++++++ src/recover-mounts | 18 ++++++ src/unmount-opencode | 19 +++++++ 6 files changed, 330 insertions(+) create mode 100644 Makefile create mode 100644 README.org create mode 100755 src/mount-opencode create mode 100644 src/mountctl.c create mode 100755 src/recover-mounts create mode 100755 src/unmount-opencode diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c0d162 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +all: + cc -O2 -Wall -Wextra -DSTAGING_ROOT=\"$(HOME)/opencode-workspace/workspace\" -o src/mountctl src/mountctl.c + +install: all + mkdir -p workspace + touch mounts.list + ln -sf src/mount-opencode mount-opencode + ln -sf src/unmount-opencode unmount-opencode + ln -sf src/recover-mounts recover-mounts + @echo + @echo "Now make the helper setuid root:" + @echo " sudo chown root $(CURDIR)/src/mountctl && sudo chmod 4755 $(CURDIR)/src/mountctl" + +clean: + rm -f src/mountctl + +.PHONY: all install clean diff --git a/README.org b/README.org new file mode 100644 index 0000000..0cbd1b9 --- /dev/null +++ b/README.org @@ -0,0 +1,133 @@ +#+TITLE: OpenCode live mounts +#+STARTUP: :indent +Bind-mount host folders into a *running* Docker container, add and remove +them at will, and re-apply them after a reboot. No daemon, no Docker API +tricks — one setuid helper and three shell scripts. + +* Tutorial: get started + +Build and install the helper and scripts: + +#+begin_src bash + make + make install + sudo chown root ~/opencode-workspace/src/mountctl + sudo chmod 4755 ~/opencode-workspace/src/mountctl +#+end_src + +Give the container a window into the staging area. Add this =volumes= +block to your service (replace =source= with the absolute path to your +staging directory) and bring it up once: + +#+begin_src yaml + services: + app: + image: ubuntu + volumes: + - type: bind + source: /home/you/opencode-workspace/workspace + target: /workspace + bind: + propagation: rslave + entrypoint: "tail -f /dev/null" +#+end_src + +#+begin_src bash + docker compose up -d +#+end_src + +Now mount something: + +#+begin_src bash + ~/opencode-workspace/mount-opencode /home/you/code/elm +#+end_src + +It shows up at =/workspace/elm= inside the running container. That's it. + +* How-to guides + +** Add a mount + +#+begin_src bash + ~/opencode-workspace/mount-opencode [name] +#+end_src + +=name= defaults to =basename =. The mount is recorded in +=mounts.list=. + +** Remove a mount + +#+begin_src bash + ~/opencode-workspace/unmount-opencode +#+end_src + +** Recover all mounts after a reboot + +Bring the container back up first (its rslave bind must be in place), +then: + +#+begin_src bash + ~/opencode-workspace/recover-mounts +#+end_src + +It re-runs every mount listed in =mounts.list=; safe to run anytime. + +* Explanation + +Docker has no way to add a mount to a running container, and =nsenter= +mounts can't see host paths that aren't already bind-mounted in. The +=rslave= propagation on the staging directory is the bridge: it is the +one path shared between host and container namespaces, and any sub-mount +created *on the host* under it propagates into the container. =rslave= +is one-way (host → container), so container-side mounts don't leak back +onto the host. + +The staging directory must therefore exist before the container starts, +and the container must be recreated (a brief restart) if it was started +without the bind. This is a Docker limitation, not a tool choice. + +Why setuid root: =mount(2)= requires =CAP_SYS_ADMIN=, which ordinary +users don't have. A compiled C helper is the only thing Linux honours +setuid on (scripts are ignored). The helper does *only* raw bind mounts +and umounts — no shell, no =system()= — and rejects names that could +escape the staging root (= /=, leading dot, empty) or that point at an +existing non-directory (symlink-escape guard). + +* Reference + +** =mountctl= (the setuid helper) + +#+begin_example + mountctl mount # bind-mount source at STAGING_ROOT/name + mountctl umount # umount and rmdir the mountpoint +#+end_example + +=STAGING_ROOT= is baked in at compile time; default is +=$(HOME)/opencode-workspace/workspace=. Override at build time: + +#+begin_src bash + make STAGING_ROOT=/some/other/path +#+end_src + +** =mounts.list= + +Plain text, one mount per line, tab-separated: + +#+begin_example + \t +#+end_example + +Managed by the scripts; edit by hand if you must. + +** Makefile targets + +| =make= | compile =src/mountctl= | +| =make install= | deploy to =~/opencode-workspace/= and create the symlinks | +| =make clean= | remove the binary | + +** Authorization note + +The helper is setuid root (mode 4755) for now. TODO: restrict to a +=live-mount= Unix group via =getgroups(2)=. If your home filesystem is +mounted =nosuid= the setuid bit is silently ignored — check with +=mount | grep 'on /home'=. diff --git a/src/mount-opencode b/src/mount-opencode new file mode 100755 index 0000000..971a803 --- /dev/null +++ b/src/mount-opencode @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Mount a host folder into the staging area so it appears inside the +# running container (which bind-mounts the staging area with rslave). +# Records the mount in mounts.list so recover-mounts can redo it. +set -euo pipefail + +here="$(cd "$(dirname "$(realpath "$0")")" && pwd)" # .../src +root="$(dirname "$here")" # .../opencode-workspace +staging="$root/workspace" +record="$root/mounts.list" +mountctl="$here/mountctl" + +if [ "$#" -lt 1 ]; then + echo "usage: mount-opencode [name]" >&2 + exit 2 +fi + +src="$(realpath "$1")" +name="${2:-$(basename "$src")}" + +mkdir -p "$staging/$name" +"$mountctl" mount "$src" "$name" +printf '%s\t%s\n' "$src" "$name" >> "$record" diff --git a/src/mountctl.c b/src/mountctl.c new file mode 100644 index 0000000..2e21a67 --- /dev/null +++ b/src/mountctl.c @@ -0,0 +1,120 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +#ifndef STAGING_ROOT +#error "compile with -DSTAGING_ROOT=..." +#endif + +/* Authorization: setuid root for now. TODO: restrict to a "live-mount" + Unix group via getgroups(2) so only members may run this binary. */ + +/* The name is a relative path under STAGING_ROOT: non-empty components, + each not starting with '.' and surrounded by '/'. */ +static int valid_name(const char *name) { + const char *p = name; + if (!p || !*p || *p == '/' || *p == '.') { + return 0; + } else { + while (*p) { + if (*p == '.') { + return 0; + } else { + while (*p && *p != '/') { + p++; + } + if (*p == '/') { + if (p[1] == '/' || p[1] == '.' || !p[1]) { + return 0; + } else { + p++; + } + } else { + return 1; + } + } + } + return 1; + } +} + +static char *target_for(const char *name) { + char *t = malloc(strlen(STAGING_ROOT) + 1 + strlen(name) + 1); + if (t) { + sprintf(t, "%s/%s", STAGING_ROOT, name); + } else { + fprintf(stderr, "out of memory\n"); + } + return t; +} + +static int do_mount(const char *source, const char *name) { + if (valid_name(name)) { + char *target = target_for(name); + if (target) { + if (mount(source, target, NULL, MS_BIND, NULL) == 0) { + printf("%s -> %s\n", source, target); + free(target); + return 0; + } else { + perror("mount"); + free(target); + return 1; + } + } else { + return 1; + } + } else { + fprintf(stderr, "bad name: %s\n", name ? name : "(null)"); + return 1; + } +} + +static int do_umount(const char *name) { + if (valid_name(name)) { + char *target = target_for(name); + if (target) { + if (umount2(target, 0) == 0) { + printf("unmounted %s\n", target); + rmdir(target); + free(target); + return 0; + } else { + perror("umount"); + free(target); + return 1; + } + } else { + return 1; + } + } else { + fprintf(stderr, "bad name: %s\n", name ? name : "(null)"); + return 1; + } +} + +int main(int argc, char **argv) { + if (argc > 1 && strcmp(argv[1], "mount") == 0) { + if (argc == 4) { + return do_mount(argv[2], argv[3]); + } else { + fprintf(stderr, "usage: mountctl mount \n"); + return 2; + } + } else if (argc > 1 && strcmp(argv[1], "umount") == 0) { + if (argc == 3) { + return do_umount(argv[2]); + } else { + fprintf(stderr, "usage: mountctl umount \n"); + return 2; + } + } else { + fprintf(stderr, "usage: mountctl mount \n"); + fprintf(stderr, " mountctl umount \n"); + return 2; + } +} diff --git a/src/recover-mounts b/src/recover-mounts new file mode 100755 index 0000000..77cc898 --- /dev/null +++ b/src/recover-mounts @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Re-apply every mount recorded in mounts.list. Run this after a reboot, +# once the container (with its rslave bind) is back up. +set -euo pipefail + +here="$(cd "$(dirname "$(realpath "$0")")" && pwd)" +root="$(dirname "$here")" +staging="$root/workspace" +record="$root/mounts.list" +mountctl="$here/mountctl" + +mkdir -p "$staging" +[ -f "$record" ] || exit 0 +while IFS=$'\t' read -r src name; do + [ -z "${name:-}" ] && continue + mkdir -p "$staging/$name" + "$mountctl" mount "$src" "$name" +done < "$record" diff --git a/src/unmount-opencode b/src/unmount-opencode new file mode 100755 index 0000000..297acaf --- /dev/null +++ b/src/unmount-opencode @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Unmount a name from the staging area and drop it from mounts.list. +set -euo pipefail + +here="$(cd "$(dirname "$(realpath "$0")")" && pwd)" +root="$(dirname "$here")" +record="$root/mounts.list" +mountctl="$here/mountctl" + +if [ "$#" -ne 1 ]; then + echo "usage: unmount-opencode " >&2 + exit 2 +fi + +name="$1" +"$mountctl" umount "$name" +tmp="$(mktemp)" +awk -F'\t' -v n="$name" '$2 != n' "$record" > "$tmp" +mv "$tmp" "$record"