diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1b46a3565..afa949e66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -128,6 +128,19 @@ jobs: - name: Staging disk-monitor unit tests (issue #1684) run: bash scripts/staging/test-disk-monitor.sh + - name: Non-root container helper unit tests + ShellCheck (issue #339) + run: | + bash scripts/test-nonroot-helper.sh + shellcheck docker/nonroot.sh scripts/test-nonroot-helper.sh scripts/test-docker-nonroot.sh scripts/test-nonroot-entrypoint.sh + # SC1091: the entrypoint sources the data volume's .env at run time. + shellcheck -e SC1091 docker/entrypoint-go.sh + + # The real entrypoint and helper as root in the runtime image's pinned + # Alpine base, supervisord stubbed: off-mode start, ownership changes, + # the check before root uses files in the data dir, rollback records. + - name: Non-root container start path, supervisord stubbed (issue #339) + run: bash scripts/test-nonroot-entrypoint.sh + - name: QA blacklist script unit tests (issues #1977, #83) run: bash qa/scripts/test-blacklist-sql.sh diff --git a/Dockerfile b/Dockerfile index 8883953fd..369a6cebc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,9 +92,10 @@ COPY docker/Caddyfile /etc/caddy/Caddyfile RUN mkdir -p /app/data /var/lib/mosquitto /data/caddy && \ chown -R mosquitto:mosquitto /var/lib/mosquitto -# Entrypoint +# Entrypoint (+ opt-in non-root helper, #339; unused unless enabled) COPY docker/entrypoint-go.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +COPY docker/nonroot.sh /usr/local/lib/corescope/nonroot.sh +RUN chmod +x /entrypoint.sh /usr/local/lib/corescope/nonroot.sh EXPOSE 80 443 1883 diff --git a/Dockerfile.go b/Dockerfile.go index 52b0012aa..f6a1be0ab 100644 --- a/Dockerfile.go +++ b/Dockerfile.go @@ -50,9 +50,10 @@ COPY docker/Caddyfile /etc/caddy/Caddyfile RUN mkdir -p /app/data /var/lib/mosquitto /data/caddy && \ chown -R mosquitto:mosquitto /var/lib/mosquitto -# Entrypoint +# Entrypoint (+ opt-in non-root helper, #339; unused unless enabled) COPY docker/entrypoint-go.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +COPY docker/nonroot.sh /usr/local/lib/corescope/nonroot.sh +RUN chmod +x /entrypoint.sh /usr/local/lib/corescope/nonroot.sh EXPOSE 80 443 1883 diff --git a/cmd/ingestor/admin_delete_client_rx.go b/cmd/ingestor/admin_delete_client_rx.go index 8db165a15..4d40798e6 100644 --- a/cmd/ingestor/admin_delete_client_rx.go +++ b/cmd/ingestor/admin_delete_client_rx.go @@ -144,7 +144,35 @@ func runAdminDeleteClientRxDryRun(dbPath, key string) int { return 0 } +// clientRxGeteuid is os.Geteuid; a package var so tests can model a root CLI. +var clientRxGeteuid = os.Geteuid + +// clientRxCheckRequester refuses a root CLI next to a database that another +// user owns (#339): the ingestor then runs as that user and cannot read the +// root-owned 0600 request. It would sit in the queue while the CLI reports +// "ingestor not running?", and run unannounced once its owner changed. +func clientRxCheckRequester(dbPath string) error { + if clientRxGeteuid() != 0 { + return nil + } + fi, err := os.Stat(dbPath) + if err != nil { + return nil + } + uid, ok := fileOwnerUID(fi) + if !ok || uid == 0 { + return nil + } + return fmt.Errorf("refusing to run as root: %s belongs to UID %d, so the ingestor runs as that user "+ + "and could not read a request written by root; run this command as that user "+ + "(e.g. docker exec -u CORESCOPE_UID:CORESCOPE_GID …)", dbPath, uid) +} + func runAdminDeleteClientRxOnline(dbPath, key string, wait time.Duration) int { + if err := clientRxCheckRequester(dbPath); err != nil { + fmt.Fprintf(os.Stderr, "delete-client-rx: %v\n", err) + return 1 + } id := clientRxNewID() if err := clientRxWriteRequest(dbPath, clientRxDeleteRequest{ ID: id, diff --git a/cmd/ingestor/admin_delete_client_rx_owner_339_test.go b/cmd/ingestor/admin_delete_client_rx_owner_339_test.go new file mode 100644 index 000000000..3ec1783c5 --- /dev/null +++ b/cmd/ingestor/admin_delete_client_rx_owner_339_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// #339 review, finding 3: in the container's non-root mode the ingestor runs +// as CORESCOPE_UID and owns the database. A delete requested by a root +// `docker exec` used to leave a root-owned 0600 request that the ingestor +// could not read; the CLI reported "queued — ingestor not running?" and the +// delete then ran unannounced after a later restart. Run as root against a +// database owned by another user, the CLI must refuse and write nothing. + +func captureStderr339(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stderr + os.Stderr = w + defer func() { os.Stderr = orig }() + fn() + w.Close() + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func TestDeleteClientRxAsRootRefusesForeignOwnedDB339(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("needs a database owned by a non-root user") + } + dbPath := filepath.Join(t.TempDir(), "meshcore.db") + if err := os.WriteFile(dbPath, nil, 0o644); err != nil { + t.Fatal(err) + } + orig := clientRxGeteuid + t.Cleanup(func() { clientRxGeteuid = orig }) + clientRxGeteuid = func() int { return 0 } + + var code int + stderr := captureStderr339(t, func() { + code = runAdminDeleteClientRxOnline(dbPath, strings.Repeat("ab", 32), 0) + }) + if code != 1 { + t.Errorf("exit = %d, want 1", code) + } + for _, want := range []string{"refusing to run as root", "-u "} { + if !strings.Contains(stderr, want) { + t.Errorf("stderr %q lacks %q", stderr, want) + } + } + if _, err := os.Stat(clientRxQueueDir(dbPath)); !os.IsNotExist(err) { + t.Errorf("queue directory exists after the refusal (err=%v)", err) + } +} + +// The same user as the database owner (the ingestor's own user, and root in +// the default container mode where root owns the database) still queues. +func TestDeleteClientRxAsDBOwnerStillQueues339(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "meshcore.db") + if err := os.WriteFile(dbPath, nil, 0o644); err != nil { + t.Fatal(err) + } + var code int + stderr := captureStderr339(t, func() { + code = runAdminDeleteClientRxOnline(dbPath, strings.Repeat("ab", 32), 0) + }) + if code != 2 || !strings.Contains(stderr, "queued") { + t.Fatalf("exit = %d (%q), want 2 queued", code, stderr) + } + pending, err := clientRxListPending(dbPath) + if err != nil || len(pending) != 1 { + t.Fatalf("pending = %v, %v; want one request", pending, err) + } +} diff --git a/cmd/server/config.go b/cmd/server/config.go index 3d5e7cf74..6443bc421 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -2,8 +2,10 @@ package main import ( "encoding/json" + "errors" "fmt" "io" + "io/fs" "log" "math" "os" @@ -11,6 +13,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/meshcore-analyzer/channelregistry" @@ -1253,6 +1256,35 @@ var ( configStatFile = func(f *os.File) (os.FileInfo, error) { return f.Stat() } ) +// ErrConfigReadOnly marks a SaveGeoFilter failure caused by the deployment +// arrangement rather than by the data: config.json or its directory cannot be +// written by this process — a read-only mount, or the server running as a +// non-root user without write access (#339). Callers report it as such +// instead of as a generic save failure. +var ErrConfigReadOnly = errors.New("config.json is read-only for the server") + +// configWritesDisabled reports whether the deployment forbids the server to +// write config.json at all: the container's opt-in non-root mode (#339), +// which the entrypoint passes on to the services. There config.json belongs +// to the host or root, and the refusal must not depend on which directory a +// save would happen to resolve to (#339 review, finding 2; #422). +func configWritesDisabled() bool { + switch os.Getenv("CORESCOPE_RUN_AS_NONROOT") { + case "1", "true": + return true + } + return false +} + +// configReadOnlyErr wraps a create/rename failure in ErrConfigReadOnly when +// its cause is a permission or read-only-filesystem error. +func configReadOnlyErr(op string, err error) error { + if errors.Is(err, fs.ErrPermission) || errors.Is(err, syscall.EROFS) { + return fmt.Errorf("%w: %s: %w", ErrConfigReadOnly, op, err) + } + return fmt.Errorf("%s: %w", op, err) +} + // SaveGeoFilter writes the geo_filter section back to config.json on disk. // Pass gf=nil to remove the filter. The rest of config.json is preserved as-is. // @@ -1275,6 +1307,9 @@ var ( // argument is ignored for a file that already exists, and it is masked by // the umask for one that does not. func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error { + if configWritesDisabled() { + return fmt.Errorf("%w: CORESCOPE_RUN_AS_NONROOT is on", ErrConfigReadOnly) + } var configPath string for _, p := range []string{ filepath.Join(configDir, "config.json"), @@ -1342,7 +1377,7 @@ func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error { // Atomic write: unique temp file in the destination directory + rename. tmpFile, err := configCreateTemp(filepath.Dir(configPath), filepath.Base(configPath)+".*.tmp") if err != nil { - return fmt.Errorf("create temp config: %w", err) + return configReadOnlyErr("create temp config", err) } tmpName := tmpFile.Name() renamed := false @@ -1370,7 +1405,7 @@ func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error { return fmt.Errorf("close temp config: %w", err) } if err := configRename(tmpName, configPath); err != nil { - return fmt.Errorf("rename config: %w", err) + return configReadOnlyErr("rename config", err) } renamed = true return nil diff --git a/cmd/server/geofilter_save_readonly_339_test.go b/cmd/server/geofilter_save_readonly_339_test.go new file mode 100644 index 000000000..08c881d4c --- /dev/null +++ b/cmd/server/geofilter_save_readonly_339_test.go @@ -0,0 +1,238 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +// #339: once the Go services can run without root, config.json is read-only +// to the server (it never gets write access to /app, and a read-only config +// mount is supported). A geo-filter save must then fail with a message that +// names the arrangement — not the generic "failed to save config" — and must +// not apply the filter in memory while the file on disk says otherwise. + +const geoReadOnly339Key = "a-strong-api-key-for-testing" +const geoReadOnly339Body = `{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,5.0],[50.5,4.0]],"bufferKm":15}` + +func putGeoFilter339(t *testing.T, router http.Handler) (int, string) { + t.Helper() + req := httptest.NewRequest("PUT", "/api/config/geo-filter", strings.NewReader(geoReadOnly339Body)) + req.Header.Set("X-API-Key", geoReadOnly339Key) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + var body struct { + Error string `json:"error"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + return w.Code, body.Error +} + +func assertReadOnlyGeoFailure339(t *testing.T, srv *Server, code int, msg string) { + t.Helper() + if code != http.StatusConflict { + t.Fatalf("status = %d (%q), want 409", code, msg) + } + for _, want := range []string{"geo filter not saved", "config.json", "read-only"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q does not mention %q", msg, want) + } + } + if srv.cfg.GeoFilter != nil { + t.Error("geo filter was applied in memory although config.json was not written") + } +} + +func TestPutGeoFilterReadOnlyConfigDirIsClear339(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses directory permission checks") + } + srv, router, dir := setupGeoFilterServer(t, geoReadOnly339Key) + before, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + code, msg := putGeoFilter339(t, router) + assertReadOnlyGeoFailure339(t, srv, code, msg) + after, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Error("config.json changed despite the failed save") + } +} + +func TestPutGeoFilterReadOnlyErrnosAreClear339(t *testing.T) { + for _, tc := range []struct { + name string + errno syscall.Errno + stage string + }{ + {"EROFS on create", syscall.EROFS, "create"}, + {"EACCES on create", syscall.EACCES, "create"}, + {"EPERM on create", syscall.EPERM, "create"}, + {"EROFS on rename", syscall.EROFS, "rename"}, + {"EACCES on rename", syscall.EACCES, "rename"}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, router, _ := setupGeoFilterServer(t, geoReadOnly339Key) + origCreate, origRename := configCreateTemp, configRename + t.Cleanup(func() { configCreateTemp, configRename = origCreate, origRename }) + if tc.stage == "create" { + configCreateTemp = func(dir, pattern string) (*os.File, error) { + return nil, &fs.PathError{Op: "open", Path: filepath.Join(dir, pattern), Err: tc.errno} + } + } else { + configRename = func(oldpath, newpath string) error { + return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: tc.errno} + } + } + code, msg := putGeoFilter339(t, router) + assertReadOnlyGeoFailure339(t, srv, code, msg) + }) + } +} + +// Failures that are not about the arrangement keep the historical response. +func TestPutGeoFilterOtherSaveErrorsUnchanged339(t *testing.T) { + srv, router, _ := setupGeoFilterServer(t, geoReadOnly339Key) + orig := configCreateTemp + t.Cleanup(func() { configCreateTemp = orig }) + configCreateTemp = func(dir, pattern string) (*os.File, error) { + return nil, &fs.PathError{Op: "open", Path: filepath.Join(dir, pattern), Err: syscall.ENOSPC} + } + code, msg := putGeoFilter339(t, router) + if code != http.StatusInternalServerError || msg != "failed to save config" { + t.Fatalf("got %d %q, want 500 \"failed to save config\"", code, msg) + } + if srv.cfg.GeoFilter != nil { + t.Error("geo filter was applied in memory although the save failed") + } +} + +// An unreadable config is not a read-only arrangement: it fails before any +// write and keeps the generic response. +func TestPutGeoFilterUnreadableConfigNotReportedAsReadOnly339(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses file permission checks") + } + _, router, dir := setupGeoFilterServer(t, geoReadOnly339Key) + path := filepath.Join(dir, "config.json") + if err := os.Chmod(path, 0o200); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o644) }) + code, msg := putGeoFilter339(t, router) + if code != http.StatusInternalServerError || msg != "failed to save config" { + t.Fatalf("got %d %q, want 500 \"failed to save config\"", code, msg) + } +} + +// In the container's non-root mode config.json belongs to the host or root +// and the services must never replace it (#339 review, finding 2). The 409 +// must therefore not depend on where the file lives or on which directory +// happens to be writable: here every path is writable for the test user, the +// config is reached through a symlink into a writable data directory (the +// container layout, and what #422 resolves), and the save must still be +// refused without creating, renaming or changing anything. +func TestPutGeoFilterNonRootModeRefusesWritableConfig339(t *testing.T) { + for _, setting := range []string{"1", "true"} { + t.Run(setting, func(t *testing.T) { + t.Setenv("CORESCOPE_RUN_AS_NONROOT", setting) + srv, router, dir := setupGeoFilterServer(t, geoReadOnly339Key) + data := filepath.Join(dir, "data") + if err := os.Mkdir(data, 0o777); err != nil { + t.Fatal(err) + } + target := filepath.Join(data, "config.json") + if err := os.Rename(filepath.Join(dir, "config.json"), target); err != nil { + t.Fatal(err) + } + if err := os.Chmod(target, 0o666); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, "config.json")); err != nil { + t.Fatal(err) + } + before := snapshotDir339(t, dir, data) + origCreate, origRename := configCreateTemp, configRename + t.Cleanup(func() { configCreateTemp, configRename = origCreate, origRename }) + configCreateTemp = func(string, string) (*os.File, error) { + t.Error("SaveGeoFilter created a temp file in non-root mode") + return nil, syscall.EIO + } + configRename = func(string, string) error { + t.Error("SaveGeoFilter renamed in non-root mode") + return syscall.EIO + } + + code, msg := putGeoFilter339(t, router) + assertReadOnlyGeoFailure339(t, srv, code, msg) + if after := snapshotDir339(t, dir, data); after != before { + t.Errorf("files changed despite the refused save:\nbefore %s\nafter %s", before, after) + } + }) + } +} + +// The default (setting off) keeps saving a writable config exactly as before. +func TestPutGeoFilterSettingOffStillSaves339(t *testing.T) { + for _, setting := range []string{"", "0", "false"} { + t.Run("value="+setting, func(t *testing.T) { + t.Setenv("CORESCOPE_RUN_AS_NONROOT", setting) + srv, router, dir := setupGeoFilterServer(t, geoReadOnly339Key) + code, msg := putGeoFilter339(t, router) + if code != http.StatusOK { + t.Fatalf("status = %d (%q), want 200", code, msg) + } + if srv.cfg.GeoFilter == nil { + t.Error("geo filter not applied") + } + b, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil || !strings.Contains(string(b), "geo_filter") { + t.Errorf("config.json not saved: %v %s", err, b) + } + }) + } +} + +// snapshotDir339 lists name, mode, size, inode and content of every entry in +// the given directories, without following symlinks. +func snapshotDir339(t *testing.T, dirs ...string) string { + t.Helper() + var sb strings.Builder + for _, d := range dirs { + entries, err := os.ReadDir(d) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + p := filepath.Join(d, e.Name()) + fi, err := os.Lstat(p) + if err != nil { + t.Fatal(err) + } + st := fi.Sys().(*syscall.Stat_t) + fmt.Fprintf(&sb, "%s %v %d %d;", p, fi.Mode(), fi.Size(), st.Ino) + if fi.Mode().IsRegular() { + b, _ := os.ReadFile(p) + sb.Write(b) + } + } + } + return sb.String() +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 84e763cbc..7c90e2555 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -4,6 +4,7 @@ import ( "crypto/subtle" "database/sql" "encoding/json" + "errors" "fmt" "log" "math" @@ -4951,6 +4952,11 @@ func (s *Server) handlePruneGeoFilterStatus(w http.ResponseWriter, r *http.Reque writeError(w, http.StatusNotFound, "unknown request id") } +// geoFilterReadOnlyMsg is the 409 answer when config.json cannot be written in +// this deployment (#339). The filter is then neither saved nor applied. +const geoFilterReadOnlyMsg = "geo filter not saved: config.json is read-only for the server " + + "(read-only mount, or CORESCOPE_RUN_AS_NONROOT is on); edit geo_filter in config.json on the host and restart" + // handlePutConfigGeoFilter writes the geo_filter config to disk and updates the // in-memory pointer atomically. Empty/missing polygon clears the filter. // @@ -5000,6 +5006,10 @@ func (s *Server) handlePutConfigGeoFilter(w http.ResponseWriter, r *http.Request if err := SaveGeoFilter(s.configDir, gf); err != nil { s.saveMu.Unlock() log.Printf("[geofilter] save failed: %v", err) + if errors.Is(err, ErrConfigReadOnly) { + writeError(w, http.StatusConflict, geoFilterReadOnlyMsg) + return + } writeError(w, http.StatusInternalServerError, "failed to save config") return } diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 7faa48472..a2277767e 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -41,6 +41,14 @@ services: - PPROF_PORT=${PPROF_PORT:-} - PPROF_PORT_SERVER=${PPROF_PORT_SERVER:-} - PPROF_PORT_INGESTOR=${PPROF_PORT_INGESTOR:-} + # Opt-in: run corescope-server and corescope-ingestor without root. + # Off unless set. Use a dedicated ID that owns nothing on the host, not + # the owner of DATA_DIR. Back up and record owners first, and read + # docs/deployment.md, "Running the Go services without root (opt-in)", + # for what changes on the host and how to roll back. + # - CORESCOPE_RUN_AS_NONROOT=1 + # - CORESCOPE_UID=10001 + # - CORESCOPE_GID=10001 restart: unless-stopped healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/stats"] diff --git a/docker/entrypoint-go.sh b/docker/entrypoint-go.sh index 9f47efc15..af629a2aa 100644 --- a/docker/entrypoint-go.sh +++ b/docker/entrypoint-go.sh @@ -1,5 +1,22 @@ #!/bin/sh +# Opt-in (#339): run corescope-server and corescope-ingestor as +# CORESCOPE_UID:CORESCOPE_GID. Unset, empty, 0 or false keeps the historical +# start, in which every program runs as root. +nonroot_on() { + case "${CORESCOPE_RUN_AS_NONROOT-}" in '' | 0 | false) return 1 ;; esac +} + +# In that mode the services can write in /app/data. Before root trusts a file +# there (.env is sourced, config.json and theme.json are linked), the helper +# refuses the start if they could have written it. It runs only when the mode +# is requested here or in .env, or was used on this volume (its marker); a +# volume that never used it starts exactly as before. +if nonroot_on || [ -e /app/data/.corescope-nonroot ] || [ -L /app/data/.corescope-nonroot ] || + { [ -f /app/data/.env ] && [ ! -L /app/data/.env ] && grep -q CORESCOPE_RUN_AS_NONROOT /app/data/.env; }; then + /usr/local/lib/corescope/nonroot.sh preflight || exit 1 +fi + # Config lives in the data directory (bind-mounted from host) # The Go server already searches /app/data/config.json via LoadConfig # but the ingestor expects a direct path — symlink for compatibility @@ -34,4 +51,8 @@ elif [ "${DISABLE_CADDY:-false}" = "true" ]; then SUPERVISORD_CONF="/etc/supervisor/conf.d/supervisord-no-caddy.conf" fi +if nonroot_on; then + SUPERVISORD_CONF=$(/usr/local/lib/corescope/nonroot.sh setup "$SUPERVISORD_CONF") || exit 1 +fi + exec /usr/bin/supervisord -c "$SUPERVISORD_CONF" diff --git a/docker/nonroot.sh b/docker/nonroot.sh new file mode 100644 index 000000000..927b6d985 --- /dev/null +++ b/docker/nonroot.sh @@ -0,0 +1,525 @@ +#!/bin/sh +# Opt-in non-root mode for corescope-server and corescope-ingestor (#339). +# +# entrypoint-go.sh calls `preflight` before it reads anything in /app/data, +# but only when the mode is requested or was used on this volume, and `setup` +# only when CORESCOPE_RUN_AS_NONROOT is set to something other than unset, +# empty, 0 or false. On a volume that never used the mode, with the setting +# off, this file is never executed and the container starts exactly as before. +# +# preflight as root, before .env is sourced and config.json and +# theme.json are linked: refuse the start if the +# services could have written any of them +# setup BASE_CONF as root: create the run account, make the data dir +# usable but not ownable for it, hand the services' +# own files to it, set aside queue requests it did not +# write, write a supervisor config that runs only the +# two Go programs as that account, print its path +# owners print the owner and mode of the data dir and of +# every listed path ("UID:GID MODE NAME"), as a record +# for restore-owners +# restore-owners [UID:GID] as root, container stopped: apply a record read +# from stdin exactly; listed paths not in it (created +# while the mode was on) get UID:GID (default 0:0) +# set-owner UID:GID as root, container stopped: give the data dir and the +# listed paths to UID:GID (coarse rollback) +# render-conf BASE_CONF USER print BASE_CONF with user=USER added to the +# two Go programs (Mosquitto and Caddy untouched) +# validate check the settings, print UID:GID +# paths print the migration list ("f name" / "d name") +# +# The security boundary: root reads .env, config.json and theme.json in the +# data dir at every start, so the services must never be able to write or +# replace them. They therefore never own the data dir; they get group write +# on it (SQLite creates -wal/-shm/-journal files there) together with the +# sticky bit, so they cannot remove or rename entries they do not own. A file +# they create later under one of those names is refused by `preflight`. +# +# Ownership is changed only on the paths below, only where it differs, never +# recursively and never through a symlink or onto a hard-linked file. +set -eu + +DATA_DIR=/app/data +RUN_USER=corescope +GENERATED_CONF=/run/corescope/supervisord.conf +STATS_FILE=/tmp/corescope-ingestor-stats.json +PASSWD=${NONROOT_PASSWD:-/etc/passwd} +GROUP=${NONROOT_GROUP:-/etc/group} +# Root-owned record of the IDs the services last ran as. Its presence keeps +# `preflight` active after the setting is removed, until a rollback. +MARKER=.corescope-nonroot + +# What the Go services create next to the database. Kept in sync with the Go +# constants by scripts/test-nonroot-helper.sh. Queue directories are flat. +DB_FILES="meshcore.db meshcore.db-wal meshcore.db-shm meshcore.db-journal ping_scores_history.db ping_scores_history.db-wal ping_scores_history.db-shm ping_scores_history.db-journal" +SIDE_FILES="client-rx-delete.salt client-rx-delete-audit.jsonl" +QUEUE_DIRS="prune-requests mbcap-snapshot channel-proposal-requests client-rx-deletes" +# What root uses from the data dir: sourced (.env) or linked into /app. +ROOT_FILES=".env config.json theme.json" + +log() { echo "[nonroot] $*" >&2; } +die() { + echo "[nonroot] ERROR: $*" >&2 + exit 1 +} + +# A decimal ID in 1..2147483647 without a leading zero. +valid_id() { + case "$1" in '' | 0* | *[!0-9]*) return 1 ;; esac + [ "${#1}" -le 10 ] && [ "$1" -le 2147483647 ] +} + +# name_by FILE COLUMN VALUE: name of the first entry whose COLUMN equals VALUE. +name_by() { awk -F: -v c="$2" -v v="$3" '$c == v { print $1; exit }' "$1"; } + +validate() { + case "${CORESCOPE_RUN_AS_NONROOT-}" in + 1 | true) ;; + *) die "CORESCOPE_RUN_AS_NONROOT='${CORESCOPE_RUN_AS_NONROOT-}' is not a valid value; use 1 or true to enable, unset, 0 or false to disable" ;; + esac + uid=${CORESCOPE_UID-} + gid=${CORESCOPE_GID-} + valid_id "$uid" || die "CORESCOPE_UID='$uid' must be a non-zero numeric user ID, used by nothing else (e.g. 10001)" + valid_id "$gid" || die "CORESCOPE_GID='$gid' must be a non-zero numeric group ID (e.g. 10001)" + owner=$(name_by "$PASSWD" 3 "$uid") + if [ -n "$owner" ] && [ "$owner" != "$RUN_USER" ]; then + die "CORESCOPE_UID=$uid already belongs to the image account '$owner'; choose an unused ID" + fi + [ "$gid" -ge 100 ] || die "CORESCOPE_GID=$gid is a system group ID (<100); choose 100 or higher" + group=$(name_by "$GROUP" 3 "$gid") + case "$group" in + mosquitto | caddy) die "CORESCOPE_GID=$gid is the '$group' group of another service; choose another ID" ;; + esac +} + +# env_value KEY: the value root will see for KEY once .env is sourced — the +# inherited one, overridden by a plain KEY=VALUE line in .env (last wins). +# .env is only read as data here. A line that sets KEY in any other form +# stops the start, because sourcing could then give a value nobody checked. +env_value() { + eval "val=\${$1-}" + f=$DATA_DIR/.env + if [ -f "$f" ] && [ ! -L "$f" ]; then + set +e + from_file=$(awk -v k="$1" -v q="'" ' + index($0, k) { + s = $0 + sub(/^[ \t]+/, "", s) + if (s ~ /^#/) next + sub(/[ \t]+#.*$/, "", s) + sub(/[ \t]+$/, "", s) + sub(/^export[ \t]+/, "", s) + if (substr(s, 1, length(k) + 1) == k "=") { + v = substr(s, length(k) + 2) + if (v ~ /^[A-Za-z0-9]*$/) { out = v; set = 1; next } + if (v ~ /^"[A-Za-z0-9]*"$/ || v ~ ("^" q "[A-Za-z0-9]*" q "$")) { + out = substr(v, 2, length(v) - 2); set = 1; next + } + } + if ($0 ~ ("(^|[^A-Za-z0-9_])" k "([^A-Za-z0-9_]|$)")) bad = 1 + } + END { if (bad) exit 3; if (set) print "=" out } + ' "$f") + rc=$? + set -e + [ "$rc" -eq 0 ] || die "$f sets $1 in a form that cannot be checked before it is sourced; write it as a plain line, $1=VALUE" + [ -z "$from_file" ] || val=${from_file#=} + fi + printf '%s' "$val" +} + +# read_marker: m_ids = the UID:GID the services last ran as, or empty. +read_marker() { + m_ids= + p=$DATA_DIR/$MARKER + [ -e "$p" ] || [ -L "$p" ] || return 0 + if [ -L "$p" ] || [ ! -f "$p" ] || [ "$(stat -c '%u %h' "$p")" != "0 1" ] || + [ $((0$(stat -c %a "$p") & 022)) -ne 0 ]; then + die "$p is not the root-owned file CoreScope writes; check how it got there, then remove it" + fi + m_ids=$(head -n 1 "$p") + if ! valid_id "${m_ids%%:*}" || ! valid_id "${m_ids#*:}"; then + die "$p does not hold UID:GID; check how it got there, then remove it" + fi +} + +# untrusted MESSAGE: stop because root would use a file the services could +# have written. +untrusted() { + die "$1. Root uses this file at every start, so CORESCOPE_UID/GID must not be able to write or replace it. Check its content, give it to root or the host user without write access for that group, and start again (docs/deployment.md, \"Running the Go services without root\")" +} + +# readonly_mount PATH: PATH is itself a read-only mount (e.g. a :ro bind +# mount of config.json). Nothing in the container can write it, and a mount +# point cannot be removed or renamed over. +readonly_mount() { + awk -v p="$1" '$5 == p { ro = ($6 ~ /(^|,)ro(,|$)/) } END { exit !ro }' /proc/self/mountinfo +} + +# check_trust: refuse when any UID:GID in $check_ids owns the data dir, or +# when one of ROOT_FILES could have been written by anyone but root or the +# data dir's owner. The data dir is writable for the services' group, so a +# file there must be owned by root or that owner (never by a service UID) and +# must not be writable for the services' group or for everyone. +check_trust() { + dir_owner=$(stat -c %u "$DATA_DIR") + for cid in $check_ids; do + [ "$dir_owner" != "${cid%%:*}" ] || + die "CORESCOPE_UID=${cid%%:*} owns the data directory $DATA_DIR; the services must never own it. Use a dedicated ID, or give the directory back to root or the host user (docs/deployment.md, \"Running the Go services without root\")" + done + for name in $ROOT_FILES; do + p=$DATA_DIR/$name + [ ! -L "$p" ] || untrusted "$p is a symlink" + [ -e "$p" ] || continue + [ -f "$p" ] || untrusted "$p is not a regular file" + # shellcheck disable=SC2046 # word splitting is intended + set -- $(stat -c '%u %g %a %h' "$p") + [ "$4" = 1 ] || untrusted "$p has $4 hard links" + for cid in $check_ids; do + [ "$1" != "${cid%%:*}" ] || untrusted "$p is owned by ${cid%%:*}, the services' user" + done + if readonly_mount "$p"; then continue; fi + [ "$1" = 0 ] || [ "$1" = "$dir_owner" ] || + untrusted "$p is owned by $1, which is neither root nor the owner of $DATA_DIR ($dir_owner)" + mode=$((0$3)) + [ $((mode & 002)) -eq 0 ] || untrusted "$p is world-writable" + for cid in $check_ids; do + [ $((mode & 020)) -eq 0 ] || [ "$2" != "${cid#*:}" ] || + untrusted "$p is writable for group ${cid#*:}, the services' group" + done + done +} + +# preflight: see the header. Runs before .env is sourced, so the settings +# come from env_value. +preflight() { + [ "$(id -u)" = 0 ] || die "preflight must run as root" + [ -d "$DATA_DIR" ] || return 0 + read_marker + CORESCOPE_RUN_AS_NONROOT=$(env_value CORESCOPE_RUN_AS_NONROOT) + ids= + case "$CORESCOPE_RUN_AS_NONROOT" in + '' | 0 | false) ;; + *) + CORESCOPE_UID=$(env_value CORESCOPE_UID) + CORESCOPE_GID=$(env_value CORESCOPE_GID) + validate + ids="$uid:$gid" + ;; + esac + [ -n "$ids$m_ids" ] || return 0 + check_ids="$ids $m_ids" + check_trust + [ -n "$ids" ] || disarm_data_dir +} + +# disarm_data_dir: the mode is off, but the data dir is still set up for it +# (rollback step 2 not done yet). Root then runs everything, and the kernel's +# fs.protected_regular would stop root from opening the services' files in a +# sticky directory that is group-writable. Take back group write and the +# sticky bit; restore-owners later restores the recorded mode exactly. +disarm_data_dir() { + [ $((0$(stat -c %a "$DATA_DIR") & 01020)) -ne 0 ] || return 0 + chmod g-w,-t "$DATA_DIR" || die "could not change the mode of $DATA_DIR" + log "the mode is off: removed group write and the sticky bit from $DATA_DIR so root can use the services' files; run restore-owners or set-owner (Rollback step 2) to finish the rollback" +} + +paths() { + for f in $DB_FILES $SIDE_FILES; do echo "f $f"; done + for d in $QUEUE_DIRS; do echo "d $d"; done +} + +render_conf() { + awk -v user="$2" ' + { print } + /^\[program:corescope-(ingestor|server)\][ \t]*$/ { print "user=" user; n++ } + END { if (n != 2) exit 3 } + ' "$1" || die "$1 does not define both [program:corescope-ingestor] and [program:corescope-server]" +} + +# ensure_account UID GID: the passwd/group entries supervisor needs for user=. +ensure_account() { + cur=$(awk -F: -v n="$RUN_USER" '$1 == n { print $3 ":" $4; exit }' "$PASSWD") + [ "$cur" = "$1:$2" ] && return 0 + [ -z "$cur" ] || deluser "$RUN_USER" >&2 || true + g=$(name_by "$GROUP" 3 "$2") + if [ -z "$g" ]; then + gname=$(name_by "$GROUP" 1 "$RUN_USER") + [ -z "$gname" ] || delgroup "$RUN_USER" >&2 || true + addgroup -g "$2" "$RUN_USER" >&2 || die "could not create group $2" + g=$RUN_USER + fi + adduser -D -H -h /nonexistent -s /sbin/nologin -g CoreScope -G "$g" -u "$1" "$RUN_USER" >&2 || + die "could not create user $1" +} + +checked=0 +changed=0 + +# usable PATH: PATH may be chowned — not a symlink, not hard-linked, a regular +# file or a directory. Warns and fails otherwise. +usable() { + if [ -L "$1" ]; then + log "WARNING: $1 is a symlink; left unchanged" + return 1 + fi + [ -e "$1" ] || return 1 + if [ -f "$1" ]; then + if [ "$(stat -c %h "$1")" -gt 1 ]; then + log "WARNING: $1 is hard-linked; left unchanged" + return 1 + fi + elif [ ! -d "$1" ]; then + log "WARNING: $1 is not a regular file or directory; left unchanged" + return 1 + fi +} + +# fix_owner PATH OWNER: chown PATH (itself only) when its owner differs. +fix_owner() { + usable "$1" || return 0 + checked=$((checked + 1)) + [ "$(stat -c %u:%g "$1")" = "$2" ] && return 0 + chown -h "$2" "$1" || die "could not change the owner of $1 (read-only data volume?)" + changed=$((changed + 1)) +} + +# migrate OWNER: the services' own files and queue directories. +migrate() { + for f in $DB_FILES $SIDE_FILES; do fix_owner "$DATA_DIR/$f" "$1"; done + for d in $QUEUE_DIRS; do fix_owner "$DATA_DIR/$d" "$1"; done +} + +# each_queue_entry CMD [ARGS...]: run CMD ARGS... ENTRY for every direct +# entry of the queue directories. Globbed, so any file name is one entry; a +# queue directory that is a symlink is skipped. +each_queue_entry() { + for d in $QUEUE_DIRS; do + if [ ! -d "$DATA_DIR/$d" ] || [ -L "$DATA_DIR/$d" ]; then continue; fi + for e in "$DATA_DIR/$d"/* "$DATA_DIR/$d"/.[!.]* "$DATA_DIR/$d"/..?*; do + if [ -e "$e" ] || [ -L "$e" ]; then "$@" "$e"; fi + done + done +} + +# owner_of OWNER PATH: fix_owner with the arguments in each_queue_entry order. +owner_of() { fix_owner "$2" "$1"; } + +# secure_data_dir GID: group write for the services, through the group and +# never as owner, plus the sticky bit; the owner stays as it is. +secure_data_dir() { + [ ! -L "$DATA_DIR" ] || die "$DATA_DIR is a symlink" + set -- "$1" "$(stat -c %g "$DATA_DIR")" "$(stat -c %a "$DATA_DIR")" + dir_changed= + if [ "$2" != "$1" ]; then + chgrp "$1" "$DATA_DIR" || die "could not change the group of $DATA_DIR (read-only data volume?)" + dir_changed=1 + fi + if [ $((0$3 & 01070)) -ne $((01070)) ]; then + chmod g+rwx,+t "$DATA_DIR" || die "could not change the mode of $DATA_DIR" + dir_changed=1 + fi + [ -z "$dir_changed" ] || log "$DATA_DIR: group $1 with write access and the sticky bit; owner unchanged" +} + +# hold_requests UID: a queued request the services did not write (a root +# `docker exec`, a restore, an earlier root run) is never handed to them. It +# is renamed to NAME.held, keeps its owner and is reported; running it takes +# an operator step. +hold_requests() { + for d in $QUEUE_DIRS; do + if [ ! -d "$DATA_DIR/$d" ] || [ -L "$DATA_DIR/$d" ]; then continue; fi + for e in "$DATA_DIR/$d"/request-*.json "$DATA_DIR/$d"/cmd-*.json; do + if [ ! -e "$e" ] && [ ! -L "$e" ]; then continue; fi + [ "$(stat -c %u "$e")" != "$1" ] || continue + held=$e.held + n=1 + while [ -e "$held" ] || [ -L "$held" ]; do + held=$e.held.$n + n=$((n + 1)) + done + mv "$e" "$held" || die "could not set aside $e" + log "WARNING: $d/${e##*/} was not written by the services (owner $(stat -c %u:%g "$held")); renamed to ${held##*/} and NOT run. Repeat the command as $1 (docker exec -u), or rename the file back to run it" + done + done +} + +# write_marker UID:GID: create or update the marker (never through a planted +# name: mktemp, then rename). +write_marker() { + [ "$m_ids" != "$1" ] || return 0 + tmp=$(mktemp "$DATA_DIR/$MARKER.XXXXXX") || die "could not write $DATA_DIR/$MARKER" + printf '%s\n' "$1" > "$tmp" + chmod 644 "$tmp" + mv -f "$tmp" "$DATA_DIR/$MARKER" +} + +as_user() { su -s /bin/sh "$RUN_USER" -c "$1"; } + +setup() { + [ "$(id -u)" = 0 ] || die "setup must run as root" + validate + base=$1 + [ -d "$DATA_DIR" ] || die "$DATA_DIR does not exist" + read_marker + # Again after .env was sourced, with the IDs that will actually be used. + check_ids="$uid:$gid $m_ids" + check_trust + ensure_account "$uid" "$gid" + secure_data_dir "$gid" + migrate "$uid:$gid" + hold_requests "$uid" + write_marker "$uid:$gid" + log "ownership: $changed of $checked path(s) in $DATA_DIR changed to $uid:$gid" + + # The services must be able to use what they did not get to own. + as_user "test -w $DATA_DIR && test -x $DATA_DIR" || + die "$DATA_DIR is not writable for $uid:$gid after the ownership fix" + for cfg in "$DATA_DIR/config.json" /app/config.json; do + [ -e "$cfg" ] || continue + as_user "test -r $cfg" || + die "$cfg is not readable for $uid:$gid; make it readable for that user or group on the host (CoreScope never changes config ownership)" + done + + # A stats file left by a root run of this container (docker restart keeps + # /tmp) would block the non-root ingestor; it is rewritten every second. + if [ -z "${CORESCOPE_INGESTOR_STATS-}" ]; then + for f in "$STATS_FILE" "$STATS_FILE.tmp"; do + if [ -L "$f" ] || { [ -e "$f" ] && [ "$(stat -c %u "$f")" != "$uid" ]; }; then + rm -f "$f" + fi + done + fi + + mkdir -p "$(dirname "$GENERATED_CONF")" + render_conf "$base" "$RUN_USER" > "$GENERATED_CONF.tmp" + mv "$GENERATED_CONF.tmp" "$GENERATED_CONF" + log "corescope-server and corescope-ingestor run as $uid:$gid; Mosquitto and Caddy unchanged" + echo "$GENERATED_CONF" +} + +# rollback_check: a rollback hands the volume back to root, so it refuses +# while a file root uses could have been written by the services. +rollback_check() { + [ "$(id -u)" = 0 ] || die "this command must run as root" + [ -d "$DATA_DIR" ] || die "$DATA_DIR does not exist" + read_marker + check_ids=$m_ids + [ -z "$m_ids" ] || check_trust +} + +valid_owner() { + case "$1" in *:*) ;; *) return 1 ;; esac + for id in "${1%%:*}" "${1#*:}"; do + [ "$id" = 0 ] || valid_id "$id" || return 1 + done +} + +owners() { + [ -d "$DATA_DIR" ] || die "$DATA_DIR does not exist" + echo "$(stat -c '%u:%g %a' "$DATA_DIR") ." + for f in $DB_FILES $SIDE_FILES $QUEUE_DIRS; do + if [ -e "$DATA_DIR/$f" ] && [ ! -L "$DATA_DIR/$f" ]; then + echo "$(stat -c '%u:%g %a' "$DATA_DIR/$f") $f" + fi + done + each_queue_entry record_entry +} + +record_entry() { + [ ! -L "$1" ] || return 0 + case "${1##*/}" in + *[[:space:]]*) log "WARNING: not recording $1 (white space in its name)" ;; + *) echo "$(stat -c '%u:%g %a' "$1") ${1#"$DATA_DIR"/}" ;; + esac +} + +# unrecorded_to OWNER PATH: give PATH to OWNER unless the record names it. +unrecorded_to() { + printf '%s\n' "$record" | awk -v n="${2#"$DATA_DIR"/}" '$3 == n { found = 1 } END { exit !found }' || + fix_owner "$2" "$1" +} + +# recordable NAME: NAME is the data dir, a listed path or a direct entry of a +# listed queue directory. +recordable() { + [ "$1" = . ] && return 0 + for f in $DB_FILES $SIDE_FILES $QUEUE_DIRS; do [ "$1" != "$f" ] || return 0; done + for d in $QUEUE_DIRS; do + case "$1" in + "$d"/*/* | "$d"/. | "$d"/..) ;; + "$d"/?*) return 0 ;; + esac + done + return 1 +} + +restore_owners() { + default=${1:-0:0} + valid_owner "$default" || die "usage: restore-owners [UID:GID] < record" + rollback_check + record=$(cat) + n=0 + has_dir= + while read -r own mode name rest; do + n=$((n + 1)) + [ -n "$own$mode$name" ] || continue + case "$mode" in *[!0-7]* | ?????*) mode= ;; ???*) ;; *) mode= ;; esac + if [ -n "$rest" ] || [ -z "$mode" ] || ! valid_owner "$own" || ! recordable "$name"; then + die "record line $n is not 'UID:GID MODE NAME' for the data dir or a listed path" + fi + [ "$name" != . ] || has_dir=1 + done << EOF +$record +EOF + [ -n "$has_dir" ] || die "the record has no line for the data dir ('UID:GID MODE .'); use the output of 'nonroot.sh owners'" + + # Listed paths created while the mode was on go to the default owner ... + for f in $DB_FILES $SIDE_FILES $QUEUE_DIRS; do unrecorded_to "$default" "$DATA_DIR/$f"; done + each_queue_entry unrecorded_to "$default" + # ... and every recorded one gets its recorded owner and mode back. + while read -r own mode name; do + [ -n "$name" ] || continue + p=$DATA_DIR/$name + [ "$name" != . ] || p=$DATA_DIR + if [ ! -e "$p" ] && [ ! -L "$p" ]; then + log "$name is gone; nothing to restore" + continue + fi + case "$name" in + */*) if [ ! -d "${p%/*}" ] || [ -L "${p%/*}" ]; then continue; fi ;; + esac + [ "$name" = . ] || usable "$p" || continue + fix_owner "$p" "$own" + [ "$(stat -c %a "$p")" = "$mode" ] || chmod "$mode" "$p" || die "could not restore the mode of $p" + done << EOF +$record +EOF + rm -f "$DATA_DIR/$MARKER" + log "ownership: $changed of $checked path(s) in $DATA_DIR restored; modes as recorded" +} + +set_owner() { + valid_owner "$1" || die "usage: set-owner UID:GID" + rollback_check + fix_owner "$DATA_DIR" "$1" + chmod g-w,-t "$DATA_DIR" || die "could not change the mode of $DATA_DIR" + migrate "$1" + each_queue_entry owner_of "$1" + rm -f "$DATA_DIR/$MARKER" + log "ownership: $changed of $checked path(s) in $DATA_DIR changed to $1" +} + +cmd=${1-} +[ $# -eq 0 ] || shift +case "$cmd" in + preflight) [ $# -eq 0 ] || die "usage: $0 preflight"; preflight ;; + setup) [ $# -eq 1 ] || die "usage: $0 setup BASE_CONF"; setup "$1" ;; + owners) [ $# -eq 0 ] || die "usage: $0 owners"; owners ;; + restore-owners) [ $# -le 1 ] || die "usage: $0 restore-owners [UID:GID] < record"; restore_owners "${1-}" ;; + set-owner) [ $# -eq 1 ] || die "usage: $0 set-owner UID:GID"; set_owner "$1" ;; + render-conf) [ $# -eq 2 ] || die "usage: $0 render-conf BASE_CONF USER"; render_conf "$1" "$2" ;; + validate) validate; echo "$uid:$gid" ;; + paths) paths ;; + *) die "usage: $0 preflight|setup|owners|restore-owners|set-owner|render-conf|validate|paths" ;; +esac diff --git a/docs/deployment.md b/docs/deployment.md index 7707245bc..67a45f33d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -79,6 +79,8 @@ docker run -d --name corescope \ | `-e DISABLE_MOSQUITTO=true` | No | Skip the internal Mosquitto broker (use your own) | | `-e DISABLE_CADDY=true` | No | Skip the built-in Caddy reverse proxy | | `-e MQTT_BROKER=mqtt://host:1883` | No | Override MQTT broker URL | +| `-e CORESCOPE_RUN_AS_NONROOT=1` | No | Opt-in: run `corescope-server` and `corescope-ingestor` without root — see [Running the Go services without root](#running-the-go-services-without-root-opt-in) | +| `-e CORESCOPE_UID=10001 -e CORESCOPE_GID=10001` | With the above | Dedicated numeric user and group the two Go services run as | #### `/app/data/.env` convenience file @@ -93,6 +95,188 @@ MQTT_BROKER=mqtt://my-broker:1883 The entrypoint sources this file before starting services. This works with any launch method (`docker run`, compose, or manage.sh). +### Running the Go services without root (opt-in) + +By default every program in the container runs as root, exactly as in earlier +releases. Setting `CORESCOPE_RUN_AS_NONROOT` runs **only** `corescope-server` +and `corescope-ingestor` as an unprivileged user, which limits what a +compromised web server or ingestor can do. Nothing changes until you turn it on. + +| Variable | Values | +|----------|--------| +| `CORESCOPE_RUN_AS_NONROOT` | `1` or `true` turns it on. Unset, empty, `0` or `false` keep the default. Any other value stops the container with an error. | +| `CORESCOPE_UID` | Required when on. A dedicated numeric user ID, e.g. `10001`: not 0, not an account that already exists in the image (`mosquitto` 101, `caddy` 100, `nobody` …), and not the owner of the data directory. | +| `CORESCOPE_GID` | Required when on. Numeric group ID, 100 or higher and not the `mosquitto`/`caddy` group, e.g. `10001`. Use a dedicated group that no host user belongs to: its members can create files in the data directory. (An existing image group such as `users` (100) is reused, but on most hosts GID 100 is a shared group.) | + +Set them with `-e`, in compose `environment:`, or in `/app/data/.env` as plain +`KEY=VALUE` lines. Use a **dedicated** user ID that owns nothing on the host, +not your own host user: root reads `.env`, `config.json` and `theme.json` from +the data directory at every start, so the services must never own the data +directory or those files. A start with `CORESCOPE_UID` equal to the owner of +the data directory is refused. + +**What stays the same.** supervisord, the entrypoint and Caddy still run as +root; Mosquitto still runs as `mosquitto` and still ignores `PUID`/`PGID`. +With the setting off, on a volume that never used it, the entrypoint takes +the old path unchanged and starts the same supervisor config files. + +**What happens at start.** As root, before any service starts: + +1. **Check.** Before `.env` is sourced and `config.json`/`theme.json` are + linked, the start stops with a `[nonroot] ERROR` line, and nothing is + sourced, linked or changed, if any of those three files is a symlink, a + hard link or not a regular file, is owned by `CORESCOPE_UID`, is owned by + anyone but root or the owner of the data directory, or is writable for + `CORESCOPE_GID` or for everyone, or if `CORESCOPE_UID` owns the data + directory. A read-only mount (`config.json:ro`) is accepted whoever owns + it, since nothing in the container can change or replace it. A file the + services created, or could have changed, is therefore never used by root. +2. The entrypoint creates a `corescope` account with those IDs and writes + `/run/corescope/supervisord.conf`: the selected config with + `user=corescope` added to the two Go programs only. +3. **The data directory keeps its owner.** It gets the group `CORESCOPE_GID`, + group write and the sticky bit (for example `755` becomes `1775`). The + services can create their database files there (SQLite needs that for + `-wal`, `-shm` and `-journal`), but the sticky bit stops them from removing, + renaming or replacing anything they do not own. +4. These paths are given to `CORESCOPE_UID:CORESCOPE_GID`, each one only if + its owner differs: + - `meshcore.db`, `ping_scores_history.db` and their `-wal`, `-shm` and + `-journal` files, + - `client-rx-delete.salt` and `client-rx-delete-audit.jsonl` (the only log + files the services write; their other logs go to the container output), + - the queue directories `prune-requests/`, `mbcap-snapshot/`, + `channel-proposal-requests/` and `client-rx-deletes/` (not their + contents). +5. A queued request (`request-*.json`, `cmd-*.json`) in those directories that + the services did not write — for example from an admin command run as + root, or left by a root run — is **not** handed to them. It is renamed to + `….held`, keeps its owner, and the log says + `[nonroot] WARNING: … renamed to ….held and NOT run`. Repeat the command as + the services' user, or rename the file back if you still want it run. +6. It writes `/app/data/.corescope-nonroot` (root-owned, holds `UID:GID`). + While that file exists, the check in step 1 runs at every start, even + after the setting is removed, until the rollback below. + +There is no recursive `chown`. Symlinks and hard-linked files on that list are +left unchanged with a warning in the log. The cost is a few `stat` calls per +start, plus a `chown` for each wrong owner: a few milliseconds, whatever the +database size. The log shows what happened, e.g. +`[nonroot] ownership: 7 of 9 path(s) in /app/data changed to 10001:10001`; +later starts report `0 of …`. Files that a root process creates later (a +`docker cp` restore, a run with the setting off) are fixed at the next start +in this mode. + +**Host-side effect.** With a bind mount, the host sees the data directory with +group `CORESCOPE_GID` and mode `1775` (or similar), the listed files and any +new file the services create owned by those numeric IDs, the +`.corescope-nonroot` file, and possibly `….held` requests. Your own files — +`config.json`, `theme.json`, `.env`, other files and subdirectories — keep +their owner and mode. They must be readable for the services (mode `0644`, or +`0640` with group `CORESCOPE_GID`) and must not be writable for that group; +an unreadable `config.json` stops the start with +`config.json is not readable for UID:GID`. An external `hashRegionsPath` file, +a `dbPath` outside `/app/data` and a `CORESCOPE_INGESTOR_STATS` path are not +migrated and must be accessible to that user. + +**config.json is read-only for the services.** In this mode the server never +writes `config.json`, whether it is mounted read-only +(`-v /srv/corescope/config.json:/app/data/config.json:ro`) or sits in the data +directory. Saving the geo filter from the customizer fails with HTTP 409 and +`geo filter not saved: config.json is read-only for the server …`, and the +filter is not applied; edit `geo_filter` in `config.json` on the host and +restart instead. + +**Admin commands** must run as the services' user, so the running ingestor can +read the request files they write: + +```bash +docker exec -u 10001:10001 corescope /app/corescope-ingestor -config /app/config.json \ + admin delete-client-rx --pubkey <64 hex> --dry-run +``` + +Run as root, `admin delete-client-rx` refuses (`refusing to run as root`) and +writes nothing. Run other tools that open the databases (e.g. `sqlite3`) with +`-u` too: in the sticky, group-writable data directory the kernel's +`fs.protected_regular` protection, enabled by default on many distributions, +can stop even root from opening files that it does not own. + +#### Turning it on + +This is also the migration from the default mode. + +1. Back up both databases with the offline helper (see [Backup](#backup)): + `sh scripts/backup-sqlite.sh corescope /backups`. Also keep a copy or + snapshot of the whole data directory (salt, audit log, queues, + `config.json`, `.env`). +2. Record the current owner and mode of every path this mode changes, with + `nonroot.sh owners`, so you can restore them exactly: + + ```sh + IMAGE=$(docker inspect --format '{{.Image}}' corescope) + docker run --rm --pull=never --network none --volumes-from corescope \ + --entrypoint /usr/local/lib/corescope/nonroot.sh "$IMAGE" owners > /backups/corescope-owners.txt + ``` + +3. On the host, make sure `config.json`, `theme.json` and `.env` are readable + but not writable for the chosen group, e.g. `chmod 644` (or `chgrp 10001` + and `chmod 640`). +4. Add `CORESCOPE_RUN_AS_NONROOT=1`, `CORESCOPE_UID` and `CORESCOPE_GID` to the + container environment or `/app/data/.env`, and recreate or restart the + container. +5. Check the log line `[nonroot] corescope-server and corescope-ingestor run as + …`, that there is no `[nonroot] ERROR`, and the processes: + `docker exec corescope ps -o pid,user,group,args`. + +#### If the start stops with `[nonroot] ERROR` + +The line says what is wrong; nothing was sourced, linked or changed. For a file +that the services own or could write, **check its content before you use it +again**: the services could have changed it. Then give it to root or the host +user, remove group write for `CORESCOPE_GID`, and start again. If +`CORESCOPE_UID` owns the data directory, choose a dedicated ID, or give the +directory back to its previous owner. + +Development builds of this mode from before this check existed gave the data +directory itself to `CORESCOPE_UID`. If you ran one: stop the container, check +`.env`, `config.json` and `theme.json` for changes you did not make, then run +`set-owner` as in Rollback step 2 (or `chown` the directory back to its +previous owner on the host) before you start the new image. + +#### Rollback + +1. Remove the three variables (or set `CORESCOPE_RUN_AS_NONROOT=0`) and + recreate or restart the container. Everything runs as root again, and this + alone restores service: the first start with the setting off removes group + write and the sticky bit from the data directory (logged as + `[nonroot] the mode is off: …`), because `fs.protected_regular` would + otherwise stop root from opening the services' files there. The check from + step 1 above keeps running at every start until step 2. +2. Restore the recorded owners and modes. With the container **stopped**, run + the helper from the same image as root: + + ```sh + IMAGE=$(docker inspect --format '{{.Image}}' corescope) + docker stop corescope + docker run --rm -i --pull=never --network none --volumes-from corescope \ + --entrypoint /usr/local/lib/corescope/nonroot.sh "$IMAGE" restore-owners < /backups/corescope-owners.txt + docker start corescope + ``` + + `restore-owners` gives every recorded path its recorded owner and mode + back, the data directory included, so mixed owners come back exactly. + Listed paths created while the mode was on go to root (`0:0`, as a root + run would create them; pass another `UID:GID` as an argument if needed). + It accepts only the data directory and the listed paths, and removes + `.corescope-nonroot`. Without a record, `set-owner 0:0` (or another + `UID:GID`) gives the data directory and every listed path, queue entries + included, to one owner and removes the group write and sticky bit from the + data directory. Both refuse while `.env`, `config.json` or `theme.json` + fails the check, and neither touches your other files. `….held` requests + stay; delete them or rename them back. +3. If data looks wrong, restore the backup from step 1 as described in + [Restore](#restore). + ### Docker Compose (legacy alternative) Docker Compose files are maintained for backward compatibility but are no longer the recommended approach. diff --git a/scripts/test-docker-nonroot.sh b/scripts/test-docker-nonroot.sh new file mode 100644 index 000000000..320af36c7 --- /dev/null +++ b/scripts/test-docker-nonroot.sh @@ -0,0 +1,505 @@ +#!/usr/bin/env bash +# scripts/test-docker-nonroot.sh — runtime test of the opt-in non-root mode +# (#339) against a locally built image (Dockerfile / Dockerfile.go). +# +# usage: scripts/test-docker-nonroot.sh [baseline-image] +# +# the image under test; never pulled (--pull never) +# [baseline-image] optional image built from the commit before the change; +# the "off" start of both images is then compared +# snapshot by snapshot and must be identical +# +# Local Docker only: every container runs with --network none on labelled +# named volumes that the test creates and removes. It never touches an +# existing container, volume or deployment. +# +# Cases +# 0 off: unset/empty/0/false x the four DISABLE_* combinations start +# supervisord exactly as before (stubbed supervisord takes a snapshot); +# on: the generated config is the selected one plus user= lines +# 1 fresh volume: Go services as UID:GID, Mosquitto as mosquitto, Caddy +# and supervisord as root; the ingestor stores a packet and the server +# reads it; supervisor restarts of both programs; docker restart +# 2 existing root-owned DB with WAL/SHM (copied fixture, hard-killed root +# run): only the listed paths change owner; WAL content survives; no +# symlink or hard link is followed; a root-written request is set aside +# 3 read-only config mount + PUID/PGID: services read it; geo-filter save +# fails with 409 and a clear message; the same for a config.json that +# sits writable in the data volume; an unreadable config fails fast +# 4 toggle off -> on -> off on one volume, in-place toggle via +# /app/data/.env + docker restart, rollback with owners/restore-owners +# 5 invalid settings fail fast and change nothing +# 6 as the services' user inside the running container: the files root +# reads cannot be changed, replaced or removed; one created later stops +# the next start before root uses it; a root admin command is refused +# +# Environment: WAIT_S (default 120) per wait, LOG_DIR for container logs. +# Exit: 0 all checks passed, 1 a check failed, 2 setup error. +# Helpers run indirectly (wait_until, traps); in-container scripts are +# single-quoted on purpose. +# shellcheck disable=SC2317,SC2016 +set -uo pipefail + +IMAGE=${1:-} +BASELINE=${2:-} +WAIT_S=${WAIT_S:-120} +[ -n "$IMAGE" ] || { echo "usage: $0 [baseline-image]" >&2; exit 2; } +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RUN_ID="nonroot-$(date +%Y%m%d%H%M%S)-$$" +LABEL="com.corescope.test.run=$RUN_ID" +TMP=$(mktemp -d) +LOG_DIR=${LOG_DIR:-$TMP/logs} +mkdir -p "$LOG_DIR" +U=10001 +G=10001 +ON=(-e CORESCOPE_RUN_AS_NONROOT=1 -e "CORESCOPE_UID=$U" -e "CORESCOPE_GID=$G") +API_KEY="nonroot-test-only-key-339-0123456789abcdef" +OBSERVER=00000000000000000000000000000000000000000000000000000000c0ffee39 +CHECKS=0 +FAILS=0 + +for img in "$IMAGE" $BASELINE; do + docker image inspect "$img" > /dev/null 2>&1 || { echo "image $img is not available locally" >&2; exit 2; } +done + +cleanup() { + local c v + for c in $(docker ps -aq --filter "label=$LABEL"); do + docker logs "$c" > "$LOG_DIR/$c.log" 2>&1 + docker rm -fv "$c" > /dev/null 2>&1 + done + for v in $(docker volume ls -q --filter "label=$LABEL"); do + docker volume rm "$v" > /dev/null 2>&1 || echo "cleanup: could not remove volume $v" >&2 + done + echo "# logs kept in $LOG_DIR" +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +check() { + CHECKS=$((CHECKS + 1)) + if [ "$2" = "$3" ]; then + echo "ok $CHECKS - $1" + else + FAILS=$((FAILS + 1)) + echo "not ok $CHECKS - $1" + echo "# expected: $2" + echo "# actual: $3" + fi +} +check_cmd() { # LABEL CMD... — passes when CMD succeeds + local label=$1 + shift + if "$@"; then check "$label" yes yes; else check "$label" yes no; fi +} + +wait_until() { # SECONDS CMD... + local end=$((SECONDS + $1)) + shift + until "$@" 2> /dev/null; do + [ "$SECONDS" -lt "$end" ] || return 1 + sleep 2 + done +} + +vol() { docker volume create --label "$LABEL" "cs-$RUN_ID-$1" > /dev/null && echo "cs-$RUN_ID-$1"; } +# as_root VOL SCRIPT [docker run args...]: run SCRIPT as root on the volume. +as_root() { + local v=$1 s=$2 + shift 2 + docker run --rm --label "$LABEL" --pull never --network none -v "$v:/app/data" "$@" \ + --entrypoint sh "$IMAGE" -c "$s" +} +start() { # NAME VOL [docker run args...] — prints the container ID + local n=$1 v=$2 + shift 2 + docker run -d --name "cs-$RUN_ID-$n" --label "$LABEL" --pull never --network none \ + -v "$v:/app/data" "$@" "$IMAGE" +} +dx() { timeout 60 docker exec "$@"; } +json_int() { grep -oE "\"$1\":[0-9]+" | head -1 | cut -d: -f2; } +server_up() { dx "$1" wget -T 5 -qO /dev/null http://localhost:3000/api/stats; } +subscribed() { docker logs "$1" 2>&1 | grep -q 'subscribed to'; } +api_tx() { dx "$1" wget -T 5 -qO- http://localhost:3000/api/stats | json_int totalTransmissions; } +ing_tx() { dx "$1" cat /tmp/corescope-ingestor-stats.json 2> /dev/null | json_int tx_inserted; } +api_tx_is() { [ "$(api_tx "$1")" = "$2" ]; } +ing_tx_is() { [ "$(ing_tx "$1")" = "$2" ]; } +ids_of() { # CONTAINER PROGRAM -> uid:gid of its process + dx "$1" sh -c "p=\$(pidof $2) && awk '/^Uid:/{u=\$2} /^Gid:/{g=\$2} END{print u \":\" g}' /proc/\$p/status" +} +pid_of() { dx "$1" pidof "$2"; } +pid_changed() { local p; p=$(pid_of "$1" "$2") && [ -n "$p" ] && [ "$p" != "$3" ]; } +owner() { as_root "$1" "stat -c %u:%g /app/data/$2"; } +tree() { as_root "$1" 'cd /app/data && find . | sort | while read -r f; do stat -c "%n %u:%g %a %F" "$f"; done'; } +ps_dump() { + echo "# ps ($1):" + dx "$2" ps -o pid,user,group,args | sed 's/^/# /' + for p in supervisord corescope-ingestor corescope-server mosquitto caddy; do + dx "$2" pidof "$p" > /dev/null 2>&1 && echo "# $p uid:gid $(ids_of "$2" "$p")" + done +} +publish() { # CONTAINER N: one distinct synthetic packet + local raw + raw=$(printf '0A00D69FD7A5A7475DB07337749AE61FA53A4788E9%02X' "$((0x70 + $2))") + dx "$1" mosquitto_pub -h localhost -q 1 -t "meshcore/TST/$OBSERVER/packets" \ + -m "{\"raw\":\"$raw\",\"SNR\":5.0,\"RSSI\":-100.0,\"origin\":\"nonroot-test\"}" +} +procs_as() { # LABEL CONTAINER GO_IDS — expectations for every program + check "$1: supervisord" "0:0" "$(ids_of "$2" supervisord)" + check "$1: corescope-ingestor" "$3" "$(ids_of "$2" corescope-ingestor)" + check "$1: corescope-server" "$3" "$(ids_of "$2" corescope-server)" + dx "$2" pidof mosquitto > /dev/null && check "$1: mosquitto" "101:102" "$(ids_of "$2" mosquitto)" + dx "$2" pidof caddy > /dev/null && check "$1: caddy" "0:0" "$(ids_of "$2" caddy)" +} +ready() { # CONTAINER — server answers and the ingestor subscribed + wait_until "$WAIT_S" server_up "$1" && wait_until "$WAIT_S" subscribed "$1" +} +geo_put() { # CONTAINER -> "STATUS BODY" + local body='{"polygon":[[51.0,4.0],[51.0,5.0],[50.5,5.0]],"bufferKm":1}' + dx "$1" sh -c "printf 'PUT /api/config/geo-filter HTTP/1.0\r\nHost: localhost\r\nX-API-Key: $API_KEY\r\nContent-Type: application/json\r\nContent-Length: ${#body}\r\n\r\n%s' '$body' | nc -w 10 localhost 3000" | + awk 'NR==1{s=$2} /^\r?$/{b=1; next} b{print s " " $0}' | tr -d '\r' +} + +CONFIG="{\"apiKey\":\"$API_KEY\",\"mqttSources\":[{\"name\":\"local\",\"broker\":\"mqtt://localhost:1883\",\"topics\":[\"meshcore/#\"],\"connectTimeoutSec\":30}]}" + +echo "# run: $RUN_ID" +echo "# image: $IMAGE ($(docker image inspect -f '{{.Id}}' "$IMAGE"))" +[ -z "$BASELINE" ] || echo "# baseline: $BASELINE ($(docker image inspect -f '{{.Id}}' "$BASELINE"))" + +# --------------------------------------------------------------------------- +echo "# case 0: supervisord start path, stubbed" +cat > "$TMP/stub" << 'EOF' +#!/bin/sh +echo "argv: $*" +echo "ids: $(id -u):$(id -g)" +env | LC_ALL=C sort | grep -vE '^(HOSTNAME|PWD|SHLVL|_)=' | sed 's/^/env: /' +echo "passwd: $(md5sum < /etc/passwd)" +echo "group: $(md5sum < /etc/group)" +find /run /tmp /usr/local/lib | LC_ALL=C sort | sed 's/^/path: /' +cd /app && find . -path ./public -prune -o -print | LC_ALL=C sort | while read -r f; do stat -c 'stat: %n %u:%g %a %F %N' "$f"; done +[ "$1" = -c ] && echo "conf: $(md5sum < "$2")" +EOF +chmod 755 "$TMP/stub" +V0=$(vol off) +as_root "$V0" "printf '%s' '$CONFIG' > /app/data/config.json && echo '{}' > /app/data/theme.json && + echo 'CORESCOPE_UID=1000' > /app/data/.env && : > /app/data/meshcore.db && : > /app/data/meshcore.db-wal && + mkdir /app/data/prune-requests && chmod 640 /app/data/config.json" +V0ON=$(vol on-stub) +as_root "$V0ON" "printf '%s' '$CONFIG' > /app/data/config.json" +snapshot() { # IMAGE VOLUME [docker run args...] + local img=$1 v=$2 + shift 2 + docker run --rm --label "$LABEL" --pull never --network none -v "$v:/app/data" \ + -v "$TMP/stub:/usr/bin/supervisord:ro" "$@" "$img" +} +PRISTINE_PASSWD=$(docker run --rm --pull never --network none --entrypoint sh "$IMAGE" -c 'md5sum < /etc/passwd') +DATA_BEFORE=$(tree "$V0") +for combo in "none" "DISABLE_MOSQUITTO" "DISABLE_CADDY" "DISABLE_MOSQUITTO DISABLE_CADDY"; do + dis=() + for d in $combo; do [ "$d" = none ] || dis+=(-e "$d=true"); done + case "$combo" in + none) conf=supervisord.conf ;; + DISABLE_MOSQUITTO) conf=supervisord-no-mosquitto.conf ;; + DISABLE_CADDY) conf=supervisord-no-caddy.conf ;; + *) conf=supervisord-no-mosquitto-no-caddy.conf ;; + esac + for setting in unset empty 0 false; do + env=() + case "$setting" in unset) ;; empty) env=(-e CORESCOPE_RUN_AS_NONROOT=) ;; *) env=(-e "CORESCOPE_RUN_AS_NONROOT=$setting") ;; esac + tag="off ($setting, $combo)" + snap=$(snapshot "$IMAGE" "$V0" "${dis[@]}" "${env[@]}") + check "$tag: supervisord config" "argv: -c /etc/supervisor/conf.d/$conf" "$(echo "$snap" | grep '^argv:')" + check "$tag: entrypoint runs as root" "ids: 0:0" "$(echo "$snap" | grep '^ids:')" + check "$tag: no account created" "passwd: $PRISTINE_PASSWD" "$(echo "$snap" | grep '^passwd:')" + check "$tag: no generated config" "" "$(echo "$snap" | grep '^path: /run/corescope')" + if [ -n "$BASELINE" ]; then + base_snap=$(snapshot "$BASELINE" "$V0" "${dis[@]}" "${env[@]}") + # The helper file is the only new path in the image. + diff_out=$(diff <(echo "$base_snap") <(echo "$snap" | grep -v '^path: /usr/local/lib/corescope')) + check "$tag: identical to baseline image" "" "$diff_out" + fi + done + snap=$(snapshot "$IMAGE" "$V0ON" "${dis[@]}" "${ON[@]}") + want=$(docker run --rm --pull never --network none --entrypoint /usr/local/lib/corescope/nonroot.sh "$IMAGE" \ + render-conf "/etc/supervisor/conf.d/$conf" corescope | md5sum) + check "on ($combo): generated config used" "argv: -c /run/corescope/supervisord.conf" "$(echo "$snap" | grep '^argv:')" + check "on ($combo): generated from $conf" "conf: $want" "$(echo "$snap" | grep '^conf:')" +done +check "off: data volume untouched by all off starts" "$DATA_BEFORE" "$(tree "$V0")" + +# --------------------------------------------------------------------------- +echo "# case 1: fresh volume, non-root" +V1=$(vol fresh) +C1=$(start fresh "$V1" "${ON[@]}") +check_cmd "case 1: ready" ready "$C1" +procs_as "case 1" "$C1" "$U:$G" +ps_dump "case 1, fresh volume" "$C1" +check "case 1: data dir keeps its owner, gets the services' group" "0:$G" "$(owner "$V1" .)" +check "case 1: data dir group-writable and sticky" "1775" "$(as_root "$V1" 'stat -c %a /app/data')" +check "case 1: meshcore.db owner" "$U:$G" "$(owner "$V1" meshcore.db)" +publish "$C1" 1 +check_cmd "case 1: ingestor stored packet 1" wait_until "$WAIT_S" ing_tx_is "$C1" 1 +check_cmd "case 1: server reads packet 1" wait_until "$WAIT_S" api_tx_is "$C1" 1 +ING=$(pid_of "$C1" corescope-ingestor) +SRV=$(pid_of "$C1" corescope-server) +dx "$C1" kill -TERM "$ING" "$SRV" +check_cmd "case 1: supervisor restarted the ingestor" wait_until "$WAIT_S" pid_changed "$C1" corescope-ingestor "$ING" +check_cmd "case 1: supervisor restarted the server" wait_until "$WAIT_S" pid_changed "$C1" corescope-server "$SRV" +check_cmd "case 1: server back after program restart" wait_until "$WAIT_S" server_up "$C1" +procs_as "case 1 after program restart" "$C1" "$U:$G" +publish "$C1" 2 +check_cmd "case 1: ingestor stored packet 2 after restart" wait_until "$WAIT_S" ing_tx_is "$C1" 1 +check_cmd "case 1: server reads 2 packets" wait_until "$WAIT_S" api_tx_is "$C1" 2 +timeout 120 docker restart "$C1" > /dev/null +check_cmd "case 1: ready after docker restart" ready "$C1" +procs_as "case 1 after docker restart" "$C1" "$U:$G" +ps_dump "case 1, after docker restart" "$C1" +check_cmd "case 1: second start changes no ownership" sh -c \ + "docker logs '$C1' 2>&1 | grep -E '^\[nonroot\] ownership:' | tail -1 | grep -qE ' 0 of [1-9][0-9]* path'" +publish "$C1" 3 +check_cmd "case 1: server reads 3 packets after docker restart" wait_until "$WAIT_S" api_tx_is "$C1" 3 +docker rm -fv "$C1" > /dev/null + +# --------------------------------------------------------------------------- +echo "# case 2: existing root-owned DB with WAL/SHM" +V2=$(vol rootdb) +docker run --rm --label "$LABEL" --pull never --network none -v "$V2:/app/data" \ + -v "$ROOT/test-fixtures/e2e-fixture.db:/fixture.db:ro" --entrypoint sh "$IMAGE" -c " + cp /fixture.db /app/data/meshcore.db && printf '%s' '$CONFIG' > /app/data/config.json && + echo '{}' > /app/data/theme.json && mkdir -p /app/data/notes && echo keep > /app/data/notes/keep.txt && + echo old > /app/data/backup-old.db && echo victim > /app/data/victim.txt && echo victim2 > /app/data/victim2.txt && + ln -s /app/data/victim.txt /app/data/client-rx-delete.salt && + ln /app/data/victim2.txt /app/data/client-rx-delete-audit.jsonl && + mkdir /app/data/prune-requests && echo '{}' > /app/data/prune-requests/request-x.json" +C2=$(start rootdb-root "$V2") +check_cmd "case 2: root run ready" ready "$C2" +BASE_TX=$(api_tx "$C2") +publish "$C2" 4 +check_cmd "case 2: root run stored packet 4" wait_until "$WAIT_S" ing_tx_is "$C2" 1 +check_cmd "case 2: root run reads it" wait_until "$WAIT_S" api_tx_is "$C2" "$((BASE_TX + 1))" +docker kill -s KILL "$C2" > /dev/null +for f in meshcore.db meshcore.db-wal meshcore.db-shm; do + check "case 2: precondition $f root-owned after hard kill" "0:0" "$(owner "$V2" "$f")" +done +check "case 2: precondition WAL not empty" "yes" "$(as_root "$V2" '[ -s /app/data/meshcore.db-wal ] && echo yes')" +docker rm -fv "$C2" > /dev/null +UNRELATED_BEFORE=$(tree "$V2" | grep -E '^\./(config\.json|theme\.json|notes|notes/keep\.txt|backup-old\.db|victim\.txt|victim2\.txt|client-rx-delete\.salt|client-rx-delete-audit\.jsonl) ') +C2=$(start rootdb-nonroot "$V2" "${ON[@]}") +check_cmd "case 2: non-root run ready" ready "$C2" +procs_as "case 2" "$C2" "$U:$G" +ps_dump "case 2, migrated root-owned DB" "$C2" +for f in meshcore.db meshcore.db-wal meshcore.db-shm ping_scores_history.db prune-requests; do + check "case 2: $f migrated" "$U:$G" "$(owner "$V2" "$f")" +done +check "case 2: data dir keeps its owner" "0:$G" "$(owner "$V2" .)" +check "case 2: root-written request no longer pending" "absent" \ + "$(as_root "$V2" '[ -e /app/data/prune-requests/request-x.json ] && echo present || echo absent')" +check "case 2: root-written request set aside, still root's" "0:0" "$(owner "$V2" prune-requests/request-x.json.held)" +check "case 2: unrelated files, symlink and hard link untouched" "$UNRELATED_BEFORE" \ + "$(tree "$V2" | grep -E '^\./(config\.json|theme\.json|notes|notes/keep\.txt|backup-old\.db|victim\.txt|victim2\.txt|client-rx-delete\.salt|client-rx-delete-audit\.jsonl) ')" +check_cmd "case 2: symlink and hard link reported" sh -c "docker logs '$C2' 2>&1 | grep -q 'client-rx-delete.salt is a symlink' && + docker logs '$C2' 2>&1 | grep -q 'client-rx-delete-audit.jsonl is hard-linked'" +check_cmd "case 2: WAL content survives (server reads packet 4)" wait_until "$WAIT_S" api_tx_is "$C2" "$((BASE_TX + 1))" +publish "$C2" 5 +check_cmd "case 2: non-root ingestor writes the migrated DB" wait_until "$WAIT_S" ing_tx_is "$C2" 1 +check_cmd "case 2: server reads it" wait_until "$WAIT_S" api_tx_is "$C2" "$((BASE_TX + 2))" +docker rm -fv "$C2" > /dev/null + +# --------------------------------------------------------------------------- +echo "# case 3: read-only config mount, PUID/PGID, geo-filter save" +printf '%s' "$CONFIG" > "$TMP/config.json" +chmod 644 "$TMP/config.json" +CFG_SUM=$(md5sum < "$TMP/config.json") +V3=$(vol roconfig) +C3=$(start roconfig "$V3" "${ON[@]}" -e PUID=1000 -e PGID=1000 -v "$TMP/config.json:/app/data/config.json:ro") +check_cmd "case 3: ready with a read-only config" ready "$C3" +procs_as "case 3 (PUID/PGID set)" "$C3" "$U:$G" +check "case 3: mosquitto still gets PUID stripped" "" \ + "$(dx "$C3" sh -c "tr '\\0' '\\n' < /proc/\$(pidof mosquitto)/environ | grep '^PUID='")" +publish "$C3" 6 +check_cmd "case 3: ingestor stores with the read-only config" wait_until "$WAIT_S" ing_tx_is "$C3" 1 +check_cmd "case 3: server reads it" wait_until "$WAIT_S" api_tx_is "$C3" 1 +GEO=$(geo_put "$C3") +check "case 3: geo-filter save status" "409" "${GEO%% *}" +check_cmd "case 3: geo-filter message names the arrangement" sh -c "echo '$GEO' | grep -q 'geo filter not saved: config.json is read-only for the server'" +check "case 3: geo filter not applied" "" "$(dx "$C3" wget -qO- http://localhost:3000/api/config/geo-filter | grep -o '"polygon":\[\[')" +check "case 3: host config unchanged" "$CFG_SUM" "$(md5sum < "$TMP/config.json")" +check "case 3: config owner unchanged" "$(stat -c %u:%g "$TMP/config.json")" "$(as_root "$V3" 'stat -c %u:%g /app/data/config.json' -v "$TMP/config.json:/app/data/config.json:ro")" +docker rm -fv "$C3" > /dev/null +# Off mode with the same mount, for the record (behaviour is not changed here). +C3=$(start roconfig-off "$V3" -v "$TMP/config.json:/app/data/config.json:ro") +if ready "$C3"; then + echo "# info: off mode geo-filter save with a read-only config mount -> $(geo_put "$C3" | cut -c1-120)" + echo "# info: off mode /app/config.json afterwards: $(dx "$C3" stat -c %F /app/config.json); host config unchanged: $([ "$(md5sum < "$TMP/config.json")" = "$CFG_SUM" ] && echo yes || echo no)" +fi +docker rm -fv "$C3" > /dev/null +# The same refusal for a config.json that sits writable in the data volume +# (no :ro mount): in this mode the server never replaces it (#339 review, +# finding 2), whoever owns it and wherever the save would resolve to. +for cfg_owner in 0:0 1000:1000; do + V3C=$(vol "inplace-${cfg_owner%%:*}") + # The data dir has the config's owner, as on a bind mount the host user owns. + as_root "$V3C" "printf '%s' '$CONFIG' > /app/data/config.json && chown $cfg_owner /app/data /app/data/config.json && chmod 644 /app/data/config.json" + CFG_STAT=$(as_root "$V3C" 'md5sum < /app/data/config.json; stat -c "%i %u:%g %a" /app/data/config.json') + C3=$(start "inplace-${cfg_owner%%:*}" "$V3C" "${ON[@]}") + check_cmd "case 3 (in-volume config $cfg_owner): ready" ready "$C3" + GEO=$(geo_put "$C3") + check "case 3 (in-volume config $cfg_owner): geo-filter save status" "409" "${GEO%% *}" + check "case 3 (in-volume config $cfg_owner): geo filter not applied" "" "$(dx "$C3" wget -qO- http://localhost:3000/api/config/geo-filter | grep -o '"polygon":\[\[')" + check "case 3 (in-volume config $cfg_owner): file, inode, owner and mode unchanged" "$CFG_STAT" \ + "$(as_root "$V3C" 'md5sum < /app/data/config.json; stat -c "%i %u:%g %a" /app/data/config.json')" + check "case 3 (in-volume config $cfg_owner): no temp file left" "" "$(as_root "$V3C" 'ls -A /app/data | grep -F config.json. || true')" + docker rm -fv "$C3" > /dev/null +done +V3B=$(vol unreadable) +as_root "$V3B" "printf '%s' '$CONFIG' > /app/data/config.json && chmod 600 /app/data/config.json" +C3=$(start unreadable "$V3B" "${ON[@]}") +timeout 60 docker wait "$C3" > "$TMP/rc" 2>&1 +check "case 3: unreadable config stops the start" "1" "$(cat "$TMP/rc")" +check_cmd "case 3: and says why" sh -c "docker logs '$C3' 2>&1 | grep -q 'config.json is not readable for $U:$G'" +docker rm -fv "$C3" > /dev/null + +# --------------------------------------------------------------------------- +echo "# case 4: toggle off -> on -> off, in-place toggle, rollback" +V4=$(vol toggle) +as_root "$V4" "printf '%s' '$CONFIG' > /app/data/config.json" +C4=$(start toggle-off1 "$V4") +check_cmd "case 4 off: ready" ready "$C4" +procs_as "case 4 off" "$C4" "0:0" +ps_dump "case 4, off" "$C4" +publish "$C4" 7 +check_cmd "case 4 off: packet stored and read" wait_until "$WAIT_S" api_tx_is "$C4" 1 +docker rm -fv "$C4" > /dev/null +check "case 4 off: DB root-owned" "0:0" "$(owner "$V4" meshcore.db)" +# Record the owners before the first enable, as the docs say. +RECORD=$(docker run --rm --label "$LABEL" --pull never --network none -v "$V4:/app/data" \ + --entrypoint /usr/local/lib/corescope/nonroot.sh "$IMAGE" owners) +check_cmd "case 4: owners record lists the data dir" sh -c "printf '%s\n' '$RECORD' | grep -qx '0:0 755 \.'" +C4=$(start toggle-on "$V4" "${ON[@]}") +check_cmd "case 4 on: ready" ready "$C4" +procs_as "case 4 on" "$C4" "$U:$G" +ps_dump "case 4, on" "$C4" +publish "$C4" 8 +check_cmd "case 4 on: packet stored and read" wait_until "$WAIT_S" api_tx_is "$C4" 2 +docker rm -fv "$C4" > /dev/null +C4=$(start toggle-off2 "$V4") +check_cmd "case 4 off again: ready" ready "$C4" +procs_as "case 4 off again" "$C4" "0:0" +ps_dump "case 4, off again" "$C4" +check "case 4 off again: no ownership step ran" "0" "$(docker logs "$C4" 2>&1 | grep -c '^\[nonroot\] ownership')" +check "case 4 off again: group write and sticky bit taken back once" "1" \ + "$(docker logs "$C4" 2>&1 | grep -c '^\[nonroot\] the mode is off: removed group write and the sticky bit')" +check "case 4 off again: DB keeps the non-root owner" "$U:$G" "$(owner "$V4" meshcore.db)" +publish "$C4" 9 +check_cmd "case 4 off again: packet stored and read" wait_until "$WAIT_S" api_tx_is "$C4" 3 +# In place: the same container, switched through /app/data/.env. +as_root "$V4" "printf 'CORESCOPE_RUN_AS_NONROOT=1\nCORESCOPE_UID=$U\nCORESCOPE_GID=$G\n' > /app/data/.env" +check "case 4 in place: root-owned stats file left in /tmp" "0" "$(dx "$C4" stat -c %u /tmp/corescope-ingestor-stats.json)" +timeout 120 docker restart "$C4" > /dev/null +check_cmd "case 4 in place: ready" ready "$C4" +procs_as "case 4 in place via .env" "$C4" "$U:$G" +# Two packets: the stale root file from the off run already says 1. +publish "$C4" 10 +publish "$C4" 11 +check_cmd "case 4 in place: ingestor stats file rewritten as $U" wait_until "$WAIT_S" ing_tx_is "$C4" 2 +check "case 4 in place: stats file owner" "$U" "$(dx "$C4" stat -c %u /tmp/corescope-ingestor-stats.json)" +check_cmd "case 4 in place: server reads 5 packets" wait_until "$WAIT_S" api_tx_is "$C4" 5 +as_root "$V4" "rm /app/data/.env" +timeout 120 docker restart "$C4" > /dev/null +check_cmd "case 4 in place off: ready" ready "$C4" +procs_as "case 4 in place off" "$C4" "0:0" +timeout 60 docker stop "$C4" > /dev/null +ROLLBACK=$(printf '%s\n' "$RECORD" | docker run --rm -i --label "$LABEL" --pull never --network none --volumes-from "$C4" \ + --entrypoint /usr/local/lib/corescope/nonroot.sh "$IMAGE" restore-owners 2>&1) +check "case 4 rollback: restore-owners exit status" "0" "$?" +echo "# rollback: $ROLLBACK" +while read -r own mode name; do + # SQLite removes -wal/-shm on a clean stop; restore-owners skips what is gone. + now=$(as_root "$V4" "if [ -e '/app/data/$name' ]; then stat -c '%u:%g %a' '/app/data/$name'; else echo gone; fi") + if [ "$now" = gone ]; then echo "# info: $name is gone since the record"; continue; fi + check "case 4 rollback: $name back to $own $mode" "$own $mode" "$now" +done <<< "$RECORD" +check "case 4 rollback: config untouched" "0:0" "$(owner "$V4" config.json)" +NR_BEFORE=$(docker logs "$C4" 2>&1 | grep -c '^\[nonroot\]') +docker start "$C4" > /dev/null +check_cmd "case 4 after rollback: ready" ready "$C4" +check "case 4 after rollback: no new [nonroot] line" "$NR_BEFORE" "$(docker logs "$C4" 2>&1 | grep -c '^\[nonroot\]')" +docker rm -fv "$C4" > /dev/null + +# --------------------------------------------------------------------------- +echo "# case 5: invalid settings fail fast and change nothing" +V5=$(vol invalid) +as_root "$V5" "printf '%s' '$CONFIG' > /app/data/config.json && : > /app/data/meshcore.db" +BEFORE5=$(tree "$V5") +n=0 +for args in "CORESCOPE_RUN_AS_NONROOT=yes CORESCOPE_UID=$U CORESCOPE_GID=$G" \ + "CORESCOPE_RUN_AS_NONROOT=1 CORESCOPE_GID=$G" \ + "CORESCOPE_RUN_AS_NONROOT=1 CORESCOPE_UID=0 CORESCOPE_GID=$G" \ + "CORESCOPE_RUN_AS_NONROOT=1 CORESCOPE_UID=101 CORESCOPE_GID=$G" \ + "CORESCOPE_RUN_AS_NONROOT=1 CORESCOPE_UID=$U CORESCOPE_GID=102"; do + n=$((n + 1)) + env=() + for a in $args; do env+=(-e "$a"); done + C5=$(start "invalid$n" "$V5" "${env[@]}") + timeout 60 docker wait "$C5" > "$TMP/rc" 2>&1 + check "case 5 ($args): exit status" "1" "$(cat "$TMP/rc")" + check_cmd "case 5 ($args): clear message" sh -c "docker logs '$C5' 2>&1 | grep -q '^\[nonroot\] ERROR: '" + docker rm -fv "$C5" > /dev/null +done +check "case 5: volume unchanged" "$BEFORE5" "$(tree "$V5")" + +# --------------------------------------------------------------------------- +echo "# case 6: the services' user cannot change what root reads" +V6=$(vol access) +# Bind-mount-like: the host user owns the data dir and theme.json, root owns +# config.json. +as_root "$V6" "printf '%s' '$CONFIG' > /app/data/config.json && echo '{}' > /app/data/theme.json && + chown 1000:1000 /app/data /app/data/theme.json" +C6=$(start access "$V6" "${ON[@]}") +check_cmd "case 6: ready" ready "$C6" +ROOT_FILES=$(as_root "$V6" 'cd /app/data && md5sum config.json theme.json && stat -c "%n %i %u:%g %a" config.json theme.json') +svc_try() { # LABEL SCRIPT — must be refused for the services' user + if dx -u "$U:$G" "$C6" sh -c "cd /app/data && $2" > /dev/null 2>&1; then check "case 6: $1 refused" refused allowed + else check "case 6: $1 refused" refused refused; fi +} +for f in config.json theme.json; do + svc_try "$f write" "echo x >> $f" + svc_try "$f remove" "rm -f $f && test ! -e $f" + svc_try "$f replace by rename" "echo '{}' > new.json && mv -f new.json $f" + svc_try "$f replace by symlink" "ln -sfn /etc/hostname $f" +done +svc_try "data dir mode change" "chmod -t /app/data" +check "case 6: root-read files unchanged" "$ROOT_FILES" \ + "$(as_root "$V6" 'cd /app/data && md5sum config.json theme.json && stat -c "%n %i %u:%g %a" config.json theme.json')" +# A missing .env can be created (the directory must stay writable for SQLite); +# the next start must refuse it before root reads it. +check_cmd "case 6: services can create a missing .env" dx -u "$U:$G" "$C6" sh -c \ + 'printf "CORESCOPE_RUN_AS_NONROOT=0\ntouch /app/data/ran-as-root\n" > /app/data/.env' +timeout 120 docker restart "$C6" > /dev/null +timeout 60 docker wait "$C6" > "$TMP/rc" 2>&1 +check "case 6: next start refused" "1" "$(cat "$TMP/rc")" +check_cmd "case 6: and says why" sh -c "docker logs '$C6' 2>&1 | grep -q '^\[nonroot\] ERROR: .*\.env'" +check "case 6: .env not sourced by root" "absent" "$(as_root "$V6" '[ -e /app/data/ran-as-root ] && echo present || echo absent')" +as_root "$V6" "rm /app/data/.env" +docker start "$C6" > /dev/null +check_cmd "case 6: ready again after the operator removed it" ready "$C6" +procs_as "case 6 after recovery" "$C6" "$U:$G" +# Admin commands: root is refused and writes nothing; the services' user works. +PUBKEY=$(printf 'c0ffee39%.0s' 1 2 3 4 5 6 7 8) +dx "$C6" /app/corescope-ingestor -config /app/config.json admin delete-client-rx --pubkey "$PUBKEY" --wait 5s > "$TMP/admin" 2>&1 +check "case 6: root admin delete exit status" "1" "$?" +check_cmd "case 6: root admin delete says why" grep -q 'refusing to run as root' "$TMP/admin" +check "case 6: root admin delete wrote no request" "" \ + "$(as_root "$V6" 'ls /app/data/client-rx-deletes 2>/dev/null | grep "^request-" || true')" +dx -u "$U:$G" "$C6" /app/corescope-ingestor -config /app/config.json admin delete-client-rx --pubkey "$PUBKEY" --wait 60s > "$TMP/admin" 2>&1 +check "case 6: admin delete as the services' user exit status" "0" "$?" +check_cmd "case 6: admin delete as the services' user ran" grep -q '^deleted client_receptions=' "$TMP/admin" +docker rm -fv "$C6" > /dev/null + +echo "1..$CHECKS" +if [ "$FAILS" -eq 0 ]; then + echo "RESULT: PASS ($CHECKS checks)" + exit 0 +fi +echo "RESULT: FAIL ($FAILS of $CHECKS checks failed)" +exit 1 diff --git a/scripts/test-nonroot-entrypoint.sh b/scripts/test-nonroot-entrypoint.sh new file mode 100644 index 000000000..af3d6d0ae --- /dev/null +++ b/scripts/test-nonroot-entrypoint.sh @@ -0,0 +1,420 @@ +#!/usr/bin/env bash +# scripts/test-nonroot-entrypoint.sh — CI test of the container start path of +# the opt-in non-root mode (#339) with supervisord stubbed. +# +# Runs the real docker/entrypoint-go.sh and docker/nonroot.sh as root in the +# runtime image's pinned Alpine base (parsed from Dockerfile.go), with the +# four shipped supervisor configs and a stub supervisord that prints what it +# was started with. No Go build, no network inside the containers; every +# volume is labelled, created here and removed on exit. +# +# What it pins (each case fails if the property breaks): +# off unset/empty/0/false x the DISABLE_* combinations start the shipped +# config exactly as before: no [nonroot] output, .env sourced, config +# linked, volume untouched +# on the generated config comes from the selected one; the data dir +# keeps its owner, gets the services' group, group write and the +# sticky bit; only the services' own paths change owner, only when +# the owner differs (second start: "0 of N") +# access a process running as CORESCOPE_UID:CORESCOPE_GID cannot modify, +# replace, remove or re-mode .env/config.json/theme.json, whether the +# host user or root owns them, nor change the data dir +# gate a root-consumed file the services could have written (their owner, +# group-writable for them, a symlink, a hard link, created after the +# enable) stops the start with [nonroot] ERROR before anything is +# sourced or linked; also with the setting removed afterwards, and +# on a volume without the marker whose .env alone asks for the mode +# queue requests in the queue directories that the services did not write +# are set aside, never handed to the services +# record owners/restore-owners restore mixed owners and the data dir mode +# exactly; set-owner is the coarse fallback; both refuse while an +# untrusted root-consumed file is present +# +# usage: bash scripts/test-nonroot-entrypoint.sh +# Exit: 0 all checks passed, 1 a check failed, 2 setup error. +# In-container scripts are single-quoted on purpose. +# shellcheck disable=SC2016,SC2317 +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE=$(sed -n 's/^FROM \(alpine:[^ ]*\)$/\1/p' "$ROOT/Dockerfile.go" | tail -1) +[ -n "$BASE" ] || { echo "cannot find the runtime base image in Dockerfile.go" >&2; exit 2; } +docker image inspect "$BASE" > /dev/null 2>&1 || docker pull -q "$BASE" > /dev/null || + { echo "cannot pull $BASE" >&2; exit 2; } + +RUN_ID="nonroot-ep-$(date +%Y%m%d%H%M%S)-$$" +LABEL="com.corescope.test.run=$RUN_ID" +TMP=$(mktemp -d) +U=10001 +G=10001 +H=1000 # the host user who owns the bind-mounted data dir +ON=(-e CORESCOPE_RUN_AS_NONROOT=1 -e "CORESCOPE_UID=$U" -e "CORESCOPE_GID=$G") +CHECKS=0 +FAILS=0 + +cleanup() { + local v + for v in $(docker ps -aq --filter "label=$LABEL"); do docker rm -fv "$v" > /dev/null 2>&1; done + for v in $(docker volume ls -q --filter "label=$LABEL"); do docker volume rm "$v" > /dev/null 2>&1; done + rm -rf "$TMP" +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +check() { + CHECKS=$((CHECKS + 1)) + if [ "$2" = "$3" ]; then + echo "ok $CHECKS - $1" + else + FAILS=$((FAILS + 1)) + echo "not ok $CHECKS - $1" + echo "# expected: $2" + echo "# actual: $3" | head -20 + fi +} +has() { if printf '%s\n' "$2" | grep -qE -- "$3"; then check "$1" yes yes; else check "$1" "output matching /$3/" "$2"; fi; } +hasnt() { if printf '%s\n' "$2" | grep -qE -- "$3"; then check "$1" "no line matching /$3/" "$(printf '%s\n' "$2" | grep -E -- "$3")"; else check "$1" yes yes; fi; } + +# The image layout the entrypoint expects, from the files under test. +mkdir -p "$TMP/conf" +install -m 755 "$ROOT/docker/entrypoint-go.sh" "$TMP/entrypoint.sh" +install -m 755 "$ROOT/docker/nonroot.sh" "$TMP/nonroot.sh" +for c in supervisord supervisord-no-mosquitto supervisord-no-caddy supervisord-no-mosquitto-no-caddy; do + cp "$ROOT/docker/${c/supervisord/supervisord-go}.conf" "$TMP/conf/$c.conf" +done +cat > "$TMP/supervisord" << 'EOF' +#!/bin/sh +echo "argv: $*" +echo "conf: $(md5sum < "$2" | cut -d' ' -f1)" +env | grep -E '^(CORESCOPE_|FROM_ENV_FILE=)' | LC_ALL=C sort | sed 's/^/env: /' +if [ -f /hook.sh ]; then sh /hook.sh; fi +EOF +chmod 755 "$TMP/supervisord" +MOUNTS=(-v "$TMP/entrypoint.sh:/entrypoint.sh:ro" -v "$TMP/nonroot.sh:/usr/local/lib/corescope/nonroot.sh:ro" + -v "$TMP/conf:/etc/supervisor/conf.d:ro" -v "$TMP/supervisord:/usr/bin/supervisord:ro") + +vol() { docker volume create --label "$LABEL" "cs-$RUN_ID-$1" > /dev/null && echo "cs-$RUN_ID-$1"; } +# as_root VOL SCRIPT [docker args...]: SCRIPT as root on the volume. +as_root() { + local v=$1 s=$2 + shift 2 + docker run --rm --label "$LABEL" --network none -v "$v:/app/data" "$@" --entrypoint sh "$BASE" -c "$s" +} +# as_svc VOL SCRIPT: SCRIPT as CORESCOPE_UID:CORESCOPE_GID on the volume. +as_svc() { docker run --rm --label "$LABEL" --network none --user "$U:$G" -v "$1:/app/data" --entrypoint sh "$BASE" -c "$2"; } +# start VOL [docker args...]: one container start through the entrypoint. The +# wrapper reports what the root side did after the entrypoint returned or +# the stub supervisord exited. Sets OUT (stdout+stderr) and RC. +start() { + local v=$1 + shift + OUT=$(docker run --rm --label "$LABEL" --network none -v "$v:/app/data" "${MOUNTS[@]}" "$@" \ + --entrypoint sh "$BASE" -c '/entrypoint.sh; rc=$? + for f in /app/config.json /app/theme.json; do if [ -L "$f" ]; then echo "linked: $f"; fi; done + exit $rc' 2>&1) + RC=$? +} +# helper VOL ARGS...: the helper itself as root (rollback commands). +helper() { + local v=$1 + shift + docker run --rm -i --label "$LABEL" --network none -v "$v:/app/data" "${MOUNTS[@]}" \ + --entrypoint /usr/local/lib/corescope/nonroot.sh "$BASE" "$@" +} +tree() { as_root "$1" 'cd /app/data && find . | LC_ALL=C sort | while read -r f; do stat -c "%n %u:%g %a %h %F" "$f"; done'; } +stat_of() { as_root "$1" "cd /app/data && stat -c '%u:%g %a' $2 2>&1"; } + +# layout VOL: a bind-mount-like data dir owned by the host user +# with the user's own config, theme and .env, plus root-owned service files +# left by an earlier root run. +layout() { + as_root "$1" "chown $H:$H /app/data && chmod 775 /app/data && cd /app/data && + printf '{\"apiKey\":\"k\"}' > config.json && echo '{}' > theme.json && + echo FROM_ENV_FILE=yes > .env && chown $H:$H config.json theme.json .env && + chmod 644 config.json theme.json .env && + : > meshcore.db && : > meshcore.db-wal && : > ping_scores_history.db && + mkdir notes && echo keep > notes/keep.txt && chown -R $H:$H notes" +} + +echo "# base image: $BASE" + +# --------------------------------------------------------------------------- +echo "# off: the shipped start, unchanged" +V=$(vol off) +layout "$V" +BEFORE=$(tree "$V") +for combo in none DISABLE_MOSQUITTO DISABLE_CADDY "DISABLE_MOSQUITTO DISABLE_CADDY"; do + dis=() + for d in $combo; do [ "$d" = none ] || dis+=(-e "$d=true"); done + case "$combo" in + none) conf=supervisord.conf ;; + DISABLE_MOSQUITTO) conf=supervisord-no-mosquitto.conf ;; + DISABLE_CADDY) conf=supervisord-no-caddy.conf ;; + *) conf=supervisord-no-mosquitto-no-caddy.conf ;; + esac + for setting in unset empty 0 false; do + env=() + case "$setting" in unset) ;; empty) env=(-e CORESCOPE_RUN_AS_NONROOT=) ;; *) env=(-e "CORESCOPE_RUN_AS_NONROOT=$setting") ;; esac + tag="off ($setting, $combo)" + start "$V" "${dis[@]}" "${env[@]}" -e "CORESCOPE_UID=$U" -e "CORESCOPE_GID=$G" + check "$tag: exit status" 0 "$RC" + has "$tag: shipped supervisor config" "$OUT" "^argv: -c /etc/supervisor/conf.d/$conf$" + hasnt "$tag: helper silent" "$OUT" '\[nonroot\]' + has "$tag: .env sourced" "$OUT" '^env: FROM_ENV_FILE=yes$' + has "$tag: config linked" "$OUT" '^linked: /app/config.json$' + done + start "$V" "${dis[@]}" "${ON[@]}" + want=$(docker run --rm --network none "${MOUNTS[@]}" --entrypoint /usr/local/lib/corescope/nonroot.sh "$BASE" \ + render-conf "/etc/supervisor/conf.d/$conf" corescope | md5sum | cut -d' ' -f1) + has "on ($combo): generated config" "$OUT" '^argv: -c /run/corescope/supervisord.conf$' + has "on ($combo): generated from $conf" "$OUT" "^conf: $want$" + # Back to the off state for the next combination (a rollback, by hand). + as_root "$V" "cd /app/data && chown $H:$H . && chmod 775 . && rm .corescope-nonroot && + chown 0:0 meshcore.db meshcore.db-wal ping_scores_history.db" +done +check "off: the volume is untouched by every off start" "$BEFORE" "$(tree "$V")" +start "$V" -e CORESCOPE_RUN_AS_NONROOT=yes +check "invalid setting: exit status" 1 "$RC" +has "invalid setting: clear message" "$OUT" '^\[nonroot\] ERROR: ' + +# --------------------------------------------------------------------------- +echo "# on: first and second start" +V=$(vol on) +layout "$V" +OWN_BEFORE=$(tree "$V" | grep -E '^\./(config\.json|theme\.json|\.env|notes|notes/keep\.txt) ') +start "$V" "${ON[@]}" +check "on: exit status" 0 "$RC" +has "on: generated config" "$OUT" '^argv: -c /run/corescope/supervisord.conf$' +check "on: data dir keeps its owner, gets the services' group, group write and the sticky bit" \ + "$H:$G 1775" "$(stat_of "$V" .)" +for f in meshcore.db meshcore.db-wal ping_scores_history.db; do + check "on: $f handed to the services" "$U:$G 644" "$(stat_of "$V" "$f")" +done +check "on: the user's own files keep owner and mode" "$OWN_BEFORE" \ + "$(tree "$V" | grep -E '^\./(config\.json|theme\.json|\.env|notes|notes/keep\.txt) ')" +has "on: ownership reported" "$OUT" '^\[nonroot\] ownership: 3 of [0-9]+ path' +check "on: root-owned marker records the IDs" "0:0 644 $U:$G" \ + "$(as_root "$V" 'cd /app/data && echo "$(stat -c "%u:%g %a" .corescope-nonroot) $(cat .corescope-nonroot)"')" +CT1=$(as_root "$V" 'stat -c "%n %z" /app/data /app/data/meshcore.db /app/data/ping_scores_history.db') +start "$V" "${ON[@]}" +check "on, second start: exit status" 0 "$RC" +has "on, second start: nothing to change" "$OUT" '^\[nonroot\] ownership: 0 of [0-9]+ path' +check "on, second start: no chown or chmod touched the listed paths (ctime)" "$CT1" \ + "$(as_root "$V" 'stat -c "%n %z" /app/data /app/data/meshcore.db /app/data/ping_scores_history.db')" +start "$V" -e CORESCOPE_RUN_AS_NONROOT=1 -e CORESCOPE_UID=$H -e "CORESCOPE_GID=$G" +check "on with the data dir owner as CORESCOPE_UID: refused" 1 "$RC" +has "on with the data dir owner as CORESCOPE_UID: says why" "$OUT" '^\[nonroot\] ERROR: .*owns the data directory' + +# --------------------------------------------------------------------------- +echo "# access: the services cannot change what root reads" +cat > "$TMP/hook.sh" << EOF +cd /app/data +svc() { su -s /bin/sh corescope -c "\$1" > /dev/null 2>&1; } +try() { if svc "\$2"; then echo "attempt \$1: allowed"; else echo "attempt \$1: denied"; fi; } +for f in .env config.json theme.json; do + try "\$f append" "echo x >> \$f" + try "\$f truncate" ": > \$f" + try "\$f chmod" "chmod 666 \$f" + try "\$f remove" "rm -f \$f && test ! -e \$f" + try "\$f rename away" "mv \$f moved.\$f" + try "\$f replace by rename" "echo x > new.\$f && mv -f new.\$f \$f" + try "\$f replace by symlink" "ln -sfn /etc/hostname \$f" +done +try "control: runs as the services' user" "[ \\\$(id -u):\\\$(id -g) = $U:$G ]" +try "data dir chmod" "chmod 777 /app/data" +try "data dir chgrp" "chgrp $U /app/data" +try "control: create and remove an own file" "echo x > own.tmp && rm own.tmp" +try "control: write the database" "echo x >> meshcore.db" +EOF +for owner in "$H:$H" "0:0"; do + V=$(vol "access-${owner%%:*}") + layout "$V" + as_root "$V" "chown $owner config.json theme.json .env" + SUMS=$(as_root "$V" 'cd /app/data && md5sum .env config.json theme.json && stat -c "%n %i %u:%g %a" .env config.json theme.json') + start "$V" "${ON[@]}" -v "$TMP/hook.sh:/hook.sh:ro" + check "access ($owner originals): exit status" 0 "$RC" + for f in .env config.json theme.json; do + for op in append truncate chmod remove "rename away" "replace by rename" "replace by symlink"; do + has "access ($owner originals): $f $op denied" "$OUT" "^attempt $f $op: denied$" + done + done + has "access ($owner originals): attempts run as $U:$G" "$OUT" "^attempt control: runs as the services' user: allowed$" + has "access ($owner originals): data dir chmod denied" "$OUT" '^attempt data dir chmod: denied$' + has "access ($owner originals): data dir chgrp denied" "$OUT" '^attempt data dir chgrp: denied$' + has "access ($owner originals): services can still create and remove their own files" "$OUT" '^attempt control: create and remove an own file: allowed$' + has "access ($owner originals): services can still write the database" "$OUT" '^attempt control: write the database: allowed$' + check "access ($owner originals): files byte- and inode-identical" "$SUMS" \ + "$(as_root "$V" 'cd /app/data && md5sum .env config.json theme.json && stat -c "%n %i %u:%g %a" .env config.json theme.json')" + start "$V" "${ON[@]}" + check "access ($owner originals): the next start still passes the check" 0 "$RC" +done + +# --------------------------------------------------------------------------- +echo "# gate: a file the services could have written is never used by root" +PAYLOAD='touch /app/data/ran-as-root' +# gate_refused VOL LABEL [docker args...]: expect the next start to stop +# before sourcing or linking anything. +gate_refused() { + local v=$1 label=$2 + shift 2 + start "$v" "$@" + check "gate ($label): start refused" 1 "$RC" + has "gate ($label): [nonroot] ERROR" "$OUT" '^\[nonroot\] ERROR: ' + hasnt "gate ($label): supervisord not started" "$OUT" '^argv: ' + hasnt "gate ($label): nothing linked" "$OUT" '^linked: ' + check "gate ($label): .env not sourced" "absent" "$(as_root "$v" '[ -e /app/data/ran-as-root ] && echo present || echo absent')" +} +# gate_case LABEL ROOT_SCRIPT [docker args...]: prepare an enabled volume, +# tamper as ROOT_SCRIPT says, then expect gate_refused. +gate_case() { + local label=$1 prep=$2 + shift 2 + local v + v=$(vol "gate-$CHECKS") + layout "$v" + start "$v" "${ON[@]}" + [ "$RC" = 0 ] || { check "gate ($label): setup start" 0 "$RC"; return; } + as_root "$v" "cd /app/data && $prep" + gate_refused "$v" "$label" "$@" +} +gate_case ".env owned by the services" "echo '$PAYLOAD' >> .env && chown $U:$G .env" "${ON[@]}" +has "gate (.env owned by the services): names the services' user" "$OUT" "ERROR: /app/data/.env is owned by $U, the services' user" +gate_case ".env created after the enable" "rm .env && echo '$PAYLOAD' > .env && chown $U:$G .env" "${ON[@]}" +gate_case ".env group-writable for the services" "echo '$PAYLOAD' >> .env && chgrp $G .env && chmod 664 .env" "${ON[@]}" +gate_case ".env world-writable" "chmod 666 .env" "${ON[@]}" +gate_case ".env a symlink" "echo '$PAYLOAD' > notes/x && mv .env notes/env && ln -s notes/x .env" "${ON[@]}" +gate_case ".env hard-linked" "echo '$PAYLOAD' >> .env && ln .env notes/env-link" "${ON[@]}" +gate_case "config.json owned by the services" "chown $U:$G config.json" "${ON[@]}" +gate_case "config.json a symlink" "mv config.json notes/c.json && ln -s notes/c.json config.json" "${ON[@]}" +gate_case "theme.json owned by the services" "chown $U theme.json" "${ON[@]}" +gate_case "theme.json not a regular file" "rm theme.json && mkfifo theme.json" "${ON[@]}" +gate_case "setting only in .env, .env owned by the services" \ + "printf 'CORESCOPE_RUN_AS_NONROOT=1\nCORESCOPE_UID=$U\nCORESCOPE_GID=$G\n$PAYLOAD\n' > .env && chown $U:$G .env" +gate_case "setting removed afterwards, service-created .env" "rm .env && echo '$PAYLOAD' > .env && chown $U:$G .env" +gate_case "setting in .env not written as a plain number" \ + "printf 'CORESCOPE_RUN_AS_NONROOT=1\nCORESCOPE_UID=\$(echo $U)\nCORESCOPE_GID=$G\n' > .env" +gate_case "data dir handed to the services" "chown $U /app/data" "${ON[@]}" +gate_case "marker not written by root" "chown $U:$G .corescope-nonroot" +gate_case ".env owned by neither root nor the data dir's owner" "echo '$PAYLOAD' >> .env && chown 2000:2000 .env" "${ON[@]}" +# No marker yet, the setting only in .env: the mention in .env alone must run +# the check before root sources .env. This is the only trigger on a volume +# from the earlier draft (eb49596f), which never wrote the marker. +NR_ENV="printf 'CORESCOPE_RUN_AS_NONROOT=1\nCORESCOPE_UID=$U\nCORESCOPE_GID=$G\n$PAYLOAD\n' > .env" +for c in "group-writable .env|$NR_ENV && chgrp $G .env && chmod 664 .env" \ + "earlier draft: data dir and .env owned by the services|$NR_ENV && chown $U:$G . .env && chmod 755 ."; do + V=$(vol "gate-$CHECKS") + layout "$V" + as_root "$V" "cd /app/data && ${c#*|}" + check "gate (no marker, ${c%%|*}): volume has no marker" "absent" \ + "$(as_root "$V" '[ -e /app/data/.corescope-nonroot ] && echo present || echo absent')" + gate_refused "$V" "no marker, setting only in .env, ${c%%|*}" +done + +# A read-only mount cannot be changed or replaced from inside the container, +# whoever owns it on the host. +V=$(vol gate-ro-mount) +layout "$V" +as_root "$V" "cd /app/data && chown 0:0 . .env theme.json" +printf '{"apiKey":"k"}' > "$TMP/ro-config.json" +chmod 644 "$TMP/ro-config.json" +start "$V" "${ON[@]}" -v "$TMP/ro-config.json:/app/data/config.json:ro" +check "read-only config mount owned by $(id -u) (not root, not the data dir owner): accepted" 0 "$RC" +has "read-only config mount: linked" "$OUT" '^linked: /app/config.json$' + +# The setting removed, nothing tampered: root runs everything again, and the +# check keeps running until the rollback below clears the mark. +V=$(vol gate-clean-off) +layout "$V" +start "$V" "${ON[@]}" +start "$V" +check "off after on, clean volume: exit status" 0 "$RC" +has "off after on, clean volume: shipped config" "$OUT" '^argv: -c /etc/supervisor/conf.d/supervisord.conf$' +has "off after on, clean volume: .env sourced" "$OUT" '^env: FROM_ENV_FILE=yes$' +has "off after on: says it took back group write and the sticky bit" "$OUT" '^\[nonroot\] the mode is off: removed group write and the sticky bit' +check "off after on: data dir no longer group-writable or sticky (fs.protected_regular)" "$H:$G 755" "$(stat_of "$V" .)" +start "$V" +hasnt "off after on, second start: nothing left to change" "$OUT" '\[nonroot\]' +start "$V" "${ON[@]}" +check "on again: data dir set up again" "$H:$G 1775" "$(stat_of "$V" .)" + +# --------------------------------------------------------------------------- +echo "# queue: requests the services did not write are set aside" +V=$(vol queue) +layout "$V" +as_root "$V" "cd /app/data && mkdir client-rx-deletes prune-requests channel-proposal-requests mbcap-snapshot && + echo '{}' > client-rx-deletes/request-root.json && chmod 600 client-rx-deletes/request-root.json && + echo '{}' > prune-requests/request-root.json && echo '{}' > prune-requests/result-old.json && + echo '{}' > channel-proposal-requests/cmd-root.json && echo '{}' > mbcap-snapshot/snapshot.json && + echo '{}' > prune-requests/request-own.json && chown $U:$G prune-requests/request-own.json" +start "$V" "${ON[@]}" +check "queue: exit status" 0 "$RC" +for q in client-rx-deletes prune-requests channel-proposal-requests mbcap-snapshot; do + check "queue: $q handed to the services" "$U:$G 755" "$(stat_of "$V" "$q")" +done +for r in client-rx-deletes/request-root.json prune-requests/request-root.json channel-proposal-requests/cmd-root.json; do + check "queue: root's $r no longer pending" "absent" "$(as_root "$V" "[ -e /app/data/$r ] && echo present || echo absent")" + check "queue: root's $r kept aside, still root's" "0:0" "$(stat_of "$V" "$r.held" | cut -d' ' -f1)" + has "queue: $r reported" "$OUT" "^\[nonroot\] WARNING: .*$r" +done +check "queue: the services' own request stays pending" "$U:$G" "$(stat_of "$V" prune-requests/request-own.json | cut -d' ' -f1)" +check "queue: other entries untouched" "0:0 644|0:0 644" \ + "$(stat_of "$V" prune-requests/result-old.json)|$(stat_of "$V" mbcap-snapshot/snapshot.json)" +start "$V" "${ON[@]}" +check "queue: held requests stay held on the next start" "0:0" "$(stat_of "$V" client-rx-deletes/request-root.json.held | cut -d' ' -f1)" +hasnt "queue: and are not reported again" "$OUT" 'WARNING: .*request-root' + +# --------------------------------------------------------------------------- +echo "# record: rollback restores mixed owners exactly" +V=$(vol record) +layout "$V" +as_root "$V" "cd /app/data && chown $H:$H meshcore.db && chmod 664 meshcore.db && mkdir prune-requests && + echo '{}' > prune-requests/result-1.json && chmod 700 prune-requests && + printf salt > client-rx-delete.salt && chmod 600 client-rx-delete.salt" +RECORD=$(helper "$V" owners) +has "record: lists the data dir with its mode" "$RECORD" "^$H:$H 775 \.$" +has "record: lists queue entries" "$RECORD" '^0:0 [0-7]+ prune-requests/result-1\.json$' +LISTED_BEFORE=$(tree "$V" | grep -vE '^\./(config\.json|theme\.json|\.env|notes|notes/keep\.txt) ') +start "$V" "${ON[@]}" +check "record: on start" 0 "$RC" +as_svc "$V" ': > /app/data/meshcore.db-shm' +printf '%s\n' "$RECORD" | helper "$V" restore-owners > "$TMP/restore.out" 2>&1 +check "record: restore-owners exit status" 0 "$?" +check "record: every recorded path has its recorded owner and mode again" "$LISTED_BEFORE" \ + "$(tree "$V" | grep -vE '^\./(config\.json|theme\.json|\.env|notes|notes/keep\.txt|meshcore\.db-shm) ')" +check "record: a path created while on goes to root" "0:0" "$(stat_of "$V" meshcore.db-shm | cut -d' ' -f1)" +start "$V" +check "record: off start after the rollback" 0 "$RC" +hasnt "record: no check left after the rollback" "$OUT" '\[nonroot\]' +# A record that names anything outside the list is refused as a whole. +for bad in '0:0 755 ../etc' '0:0 644 notes/keep.txt' '0:0 644 /etc/passwd' "0:0 644 config.json" '0 644 meshcore.db'; do + out=$(printf '%s\n%s\n0:0 644 meshcore.db\n' "$H:$H 775 ." "$bad" | helper "$V" restore-owners 2>&1) + rc=$? + check "record: bad line '$bad' refused" 1 "$rc" + has "record: bad line '$bad' says why" "$out" '^\[nonroot\] ERROR: ' +done +check "record: refused records changed nothing" "$H:$H 664|$H:$H 775" "$(stat_of "$V" meshcore.db)|$(stat_of "$V" .)" +# Rollback refuses while a root-consumed file is not trustworthy. +V=$(vol record-untrusted) +layout "$V" +start "$V" "${ON[@]}" +as_root "$V" "cd /app/data && rm .env && echo '$PAYLOAD' > .env && chown $U:$G .env" +out=$(echo "$H:$H 775 ." | helper "$V" restore-owners 2>&1) +check "record: restore-owners refuses with an untrusted .env" 1 "$?" +has "record: and names it" "$out" 'ERROR: .*\.env' +out=$(helper "$V" set-owner 0:0 2>&1) +check "record: set-owner refuses with an untrusted .env" 1 "$?" +check "record: refusals left the data dir marked" "$H:$G 1775" "$(stat_of "$V" .)" +as_root "$V" "rm /app/data/.env" +out=$(helper "$V" set-owner 0:0 2>&1) +check "record: set-owner after the operator removed it" 0 "$?" +check "record: set-owner gives the listed paths to 0:0" "0:0 0:0" \ + "$(stat_of "$V" . | cut -d' ' -f1) $(stat_of "$V" meshcore.db | cut -d' ' -f1)" +check "record: set-owner clears the group write and sticky bits" "755" "$(stat_of "$V" . | cut -d' ' -f2)" + +echo "1..$CHECKS" +if [ "$FAILS" -eq 0 ]; then + echo "RESULT: PASS ($CHECKS checks)" + exit 0 +fi +echo "RESULT: FAIL ($FAILS of $CHECKS checks failed)" +exit 1 diff --git a/scripts/test-nonroot-helper.sh b/scripts/test-nonroot-helper.sh new file mode 100644 index 000000000..f3badd11d --- /dev/null +++ b/scripts/test-nonroot-helper.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# scripts/test-nonroot-helper.sh — unit tests for docker/nonroot.sh (#339). +# +# No Docker and no root: exercises the helper's pure parts (supervisor config +# rendering, setting/ID validation, the writable-path list) and the docs. +# What needs root (ownership changes, the check before root uses files in the +# data dir, rollback) is covered in CI by scripts/test-nonroot-entrypoint.sh +# and locally by scripts/test-docker-nonroot.sh against a built image. +# +# Run: bash scripts/test-nonroot-helper.sh +# SHELL_UNDER_TEST=busybox-sh-like command to run the helper with (default sh). +set -u + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HELPER="$ROOT/docker/nonroot.sh" +read -r -a RUN <<< "${SHELL_UNDER_TEST:-sh}" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +FAIL=0 +ok() { PASS=$((PASS + 1)); } +bad() { FAIL=$((FAIL + 1)); echo "FAIL: $*" >&2; } +assert_eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 — expected '$2' got '$3'"; fi; } + +helper() { "${RUN[@]}" "$HELPER" "$@"; } + +# ----- render-conf ---------------------------------------------------------- +# The generated config must be the shipped one plus exactly one `user=` line +# directly under each Go program, so Mosquitto and Caddy keep running exactly +# as configured today. +for conf in "$ROOT"/docker/supervisord-go*.conf; do + name=$(basename "$conf") + if ! out=$(helper render-conf "$conf" corescope 2> "$TMP/err"); then + bad "$name: render-conf failed: $(cat "$TMP/err")" + continue + fi + printf '%s\n' "$out" > "$TMP/out" + assert_eq "$name: base has no user=corescope line" "0" "$(grep -cx 'user=corescope' "$conf")" + assert_eq "$name: two user= lines added" "2" "$(grep -cx 'user=corescope' "$TMP/out")" + if grep -vx 'user=corescope' "$TMP/out" | cmp -s - "$conf"; then ok; else bad "$name: anything besides the user= lines changed"; fi + for prog in corescope-ingestor corescope-server; do + after=$(grep -A1 -x "\[program:$prog\]" "$TMP/out" | sed -n 2p) + assert_eq "$name: $prog runs as corescope" "user=corescope" "$after" + done + for prog in mosquitto caddy; do + grep -qx "\[program:$prog\]" "$conf" || continue + section=$(awk -v h="[program:$prog]" '$0==h{on=1;next} /^\[/{on=0} on' "$TMP/out") + if printf '%s\n' "$section" | grep -q '^user='; then bad "$name: $prog got a user= line"; else ok; fi + done +done + +# A config without both Go programs must not silently leave one as root. +grep -v -x '\[program:corescope-server\]' "$ROOT/docker/supervisord-go.conf" > "$TMP/broken.conf" +if helper render-conf "$TMP/broken.conf" corescope > /dev/null 2>&1; then + bad "render-conf accepted a config without [program:corescope-server]" +else ok; fi + +# ----- validate ------------------------------------------------------------- +# Accounts of the Alpine runtime image (abridged): caddy uid 100 / gid 101, +# mosquitto uid 101 / gid 102, users gid 100, shadow gid 42. +cat > "$TMP/passwd" << 'EOF' +root:x:0:0:root:/root:/bin/sh +ntp:x:123:123:NTP:/var/empty:/sbin/nologin +guest:x:405:100:guest:/dev/null:/sbin/nologin +nobody:x:65534:65534:nobody:/:/sbin/nologin +caddy:x:100:101:caddy:/var/lib/caddy:/sbin/nologin +mosquitto:x:101:102:mosquitto:/var/empty:/sbin/nologin +corescope:x:10001:10001:CoreScope:/nonexistent:/sbin/nologin +EOF +cat > "$TMP/group" << 'EOF' +root:x:0:root +wheel:x:10:root +shadow:x:42: +www-data:x:82:caddy +users:x:100:games +caddy:x:101:caddy +mosquitto:x:102:mosquitto +nogroup:x:65533: +EOF + +# validate_case LABEL EXPECT(ok|fail) SETTING UID GID [stdout] +validate_case() { + local label=$1 expect=$2 setting=$3 uid=$4 gid=$5 want_out=${6-} + local out rc + out=$(env -u CORESCOPE_RUN_AS_NONROOT -u CORESCOPE_UID -u CORESCOPE_GID \ + NONROOT_PASSWD="$TMP/passwd" NONROOT_GROUP="$TMP/group" \ + ${setting:+CORESCOPE_RUN_AS_NONROOT="$setting"} \ + ${uid:+CORESCOPE_UID="$uid"} ${gid:+CORESCOPE_GID="$gid"} \ + "${RUN[@]}" "$HELPER" validate 2> "$TMP/verr") + rc=$? + if [ "$expect" = ok ]; then + if [ "$rc" -eq 0 ]; then ok; else bad "validate $label: rc=$rc ($(cat "$TMP/verr"))"; fi + [ -z "$want_out" ] || assert_eq "validate $label: output" "$want_out" "$out" + else + if [ "$rc" -ne 0 ] && grep -q 'ERROR' "$TMP/verr"; then ok; else bad "validate $label: expected a clear failure, rc=$rc"; fi + fi +} +validate_case "1 with ids" ok 1 1000 1000 "1000:1000" +validate_case "true with ids" ok true 1000 1000 "1000:1000" +validate_case "existing corescope entry" ok 1 10001 10001 "10001:10001" +validate_case "gid of the users group" ok 1 99 100 "99:100" +validate_case "yes is not a value" fail yes 1000 1000 +validate_case "TRUE is not a value" fail TRUE 1000 1000 +validate_case "missing uid" fail 1 "" 1000 +validate_case "missing gid" fail 1 1000 "" +validate_case "uid 0" fail 1 0 1000 +validate_case "gid 0" fail 1 1000 0 +validate_case "leading zero" fail 1 01000 1000 +validate_case "non-numeric uid" fail 1 abc 1000 +validate_case "negative uid" fail 1 -5 1000 +validate_case "uid too large" fail 1 2147483648 1000 +validate_case "uid of mosquitto" fail 1 101 1000 +validate_case "uid of caddy" fail 1 100 1000 +validate_case "uid of nobody" fail 1 65534 1000 +validate_case "gid of mosquitto" fail 1 1000 102 +validate_case "gid of caddy" fail 1 1000 101 +validate_case "system gid (shadow)" fail 1 1000 42 + +# ----- writable paths ------------------------------------------------------- +# Every file or directory the Go services create next to the database must be +# in the migration list, or a non-root service hits a root-owned leftover. +paths=$(helper paths) +go_const() { sed -En "s/^[[:space:]]*(const[[:space:]]+)?$2[[:space:]]*=[[:space:]]*\"([^\"]*)\".*/\2/p" "$ROOT/$1" | head -1; } +# -journal too: the data dir is sticky in this mode, so a root-owned hot +# journal left by a crashed root run could not be removed by SQLite. +expect_paths="meshcore.db meshcore.db-wal meshcore.db-shm meshcore.db-journal +ping_scores_history.db ping_scores_history.db-wal ping_scores_history.db-shm ping_scores_history.db-journal" +for spec in cmd/ingestor/client_rx_delete.go:clientRxSaltFileName \ + cmd/ingestor/client_rx_delete.go:clientRxAuditFileName \ + cmd/ingestor/client_rx_delete_queue.go:clientRxQueueDirName \ + internal/prunequeue/prunequeue.go:QueueDirName \ + internal/mbcapqueue/mbcapqueue.go:QueueDirName \ + internal/channelregistry/queue.go:QueueDirName; do + v=$(go_const "${spec%%:*}" "${spec#*:}") + if [ -n "$v" ]; then ok; else bad "could not read ${spec#*:} from ${spec%%:*}"; fi + expect_paths="$expect_paths $v" +done +assert_eq "ping score history DB name in Go" "ping_scores_history.db" \ + "$(grep -o '"ping_scores_history.db"' "$ROOT/cmd/server/ping_score_history.go" | head -1 | tr -d '"')" +for p in $expect_paths; do + if printf '%s\n' "$paths" | grep -qx "[fd] $p"; then ok; else bad "writable path $p is not in the migration list"; fi +done +assert_eq "migration list is exactly the known paths" \ + "$(printf '%s' "$expect_paths" | tr ' ' '\n' | sort)" "$(printf '%s\n' "$paths" | cut -d' ' -f2 | sort)" + +# ----- image wiring and docs ------------------------------------------------ +for df in Dockerfile Dockerfile.go; do + if grep -qx 'COPY docker/nonroot.sh /usr/local/lib/corescope/nonroot.sh' "$ROOT/$df"; then ok; else bad "$df does not install docker/nonroot.sh"; fi +done +for doc in docker-compose.example.yml docs/deployment.md; do + for v in CORESCOPE_RUN_AS_NONROOT CORESCOPE_UID CORESCOPE_GID; do + if grep -q "$v" "$ROOT/$doc"; then ok; else bad "$doc does not document $v"; fi + done +done +section=$(awk '/^### Running the Go services without root/{on=1;print;next} on&&/^##[^#]/{on=0} on&&/^### /&&!/without root/{on=0} on' "$ROOT/docs/deployment.md") +for want in 'scripts/backup-sqlite.sh' 'Rollback' 'set-owner' 'host' 'nonroot.sh owners' 'restore-owners' \ + 'sticky' 'dedicated' '\[nonroot\] ERROR' 'docker exec -u'; do + if printf '%s\n' "$section" | grep -q -- "$want"; then ok; else bad "deployment.md non-root section lacks '$want'"; fi +done +# The services must not own the data directory or the files root reads, so +# neither the docs nor the compose example may suggest the host owner's IDs. +for doc in docker-compose.example.yml docs/deployment.md; do + if grep -qE 'id -[ug]' "$ROOT/$doc"; then bad "$doc suggests the host owner's IDs (id -u/id -g) for CORESCOPE_UID/GID"; else ok; fi +done + +# The container start path runs in CI with supervisord stubbed. +if grep -q 'bash scripts/test-nonroot-entrypoint.sh' "$ROOT/.github/workflows/deploy.yml"; then ok; else bad "deploy.yml does not run scripts/test-nonroot-entrypoint.sh"; fi + +echo "test-nonroot-helper: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]