diff --git a/.dockerignore b/.dockerignore index cc3ac0f..9cb167f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,9 @@ .git .gitignore *.zip -dockwatch data/ stacks/ .env .DS_Store +dist/ +bin/ diff --git a/.env.example b/.env.example index b25df55..1fcd212 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,9 @@ CHECK_CONCURRENCY=8 CHECK_RETENTION_DAYS=30 HTTP_TIMEOUT_SECONDS=10 AUDIT_RETENTION_DAYS=180 + +# Optional host/container identity inspection. Mount the target host root at this path. +# Keep read-only unless you explicitly enable local host account creation. +HOST_ROOT= +ALLOW_HOST_USER_MANAGEMENT=false +ALLOW_HOST_PERMISSION_MANAGEMENT=false diff --git a/.gitignore b/.gitignore index 6ca567e..97e882c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ stacks/ *.db *.db-shm *.db-wal -dockwatch +bin/ +dist/ +*.zip diff --git a/Dockerfile b/Dockerfile index 0d1732f..e60f88d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,15 @@ ARG BUILD_DATE=unknown COPY go.mod ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download COPY . . +RUN test -f ./cmd/dockwatch/main.go || (echo "ERROR: cmd/dockwatch/main.go missing from Docker build context; check .dockerignore" >&2; exit 1) RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux go build -trimpath \ + -ldflags="-s -w -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Version=${VERSION} -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Commit=${COMMIT} -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Date=${BUILD_DATE}" \ -o /out/dockwatch ./cmd/dockwatch FROM docker:cli ENV DOCKER_CONFIG=/data/docker-config -RUN apk add --no-cache ca-certificates tzdata git openssh-client +RUN apk add --no-cache ca-certificates tzdata git openssh-client acl COPY --from=build /out/dockwatch /usr/local/bin/dockwatch VOLUME ["/data","/stacks"] EXPOSE 8080 diff --git a/Makefile b/Makefile index cab53a7..1a3a74a 100644 --- a/Makefile +++ b/Makefile @@ -9,10 +9,11 @@ fmt: test: go test ./... build: - CGO_ENABLED=0 go build -trimpath -ldflags='$(LDFLAGS)' -o dockwatch ./cmd/dockwatch + mkdir -p bin + CGO_ENABLED=0 go build -trimpath -ldflags='$(LDFLAGS)' -o bin/dockwatch ./cmd/dockwatch docker: docker build --build-arg VERSION='$(VERSION)' --build-arg COMMIT='$(COMMIT)' --build-arg BUILD_DATE='$(BUILD_DATE)' -t dockwatch:local . run: docker compose up -d --build clean: - rm -f dockwatch + rm -rf bin diff --git a/README.md b/README.md index c14d4a8..fcaaea2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,4 @@ - -## v9.1 packaging fix - -v9.1 fixes the v9 source archive packaging. The v9 ZIP accidentally omitted `internal/stacks/`, which could leave an older local copy of that package in place when extracting over an existing checkout and cause method-signature build errors. v9.1 is a complete source archive and includes `internal/stacks/stacks.go` and its tests. Always extract it into a fresh directory. -# Dockwatch v9.1 +# Dockwatch v9.3.1 > Go module: `git.send.nrw/sendnrw/dockwatch` @@ -57,6 +53,64 @@ Symlink stack destinations and symlink paths inside Git-managed writes are rejec - operator-only inspect - one-shot CPU/memory/network/block stats +### Container identity / host UID-GID checks + +Container rows include an **Identity** action. Dockwatch inspects the selected container and reports: + +- configured Compose/image user and the effective runtime UID/GID where resolvable +- whether the process currently runs as UID 0 +- a conservative root assessment based on `privileged`, Docker socket mounts, passed-through devices and added Linux capabilities +- bind-mount sources and their host UID/GID ownership when host access is configured +- single-container detail checks plus a throttled **Identity audit** across all containers in the selected environment +- whether the container UID/GID already maps to a local host account/group + +Dockwatch deliberately does **not** claim that a root container can always be converted to non-root. Application-internal filesystem permissions, entrypoints and image-specific `PUID`/`PGID` conventions cannot be proven from Docker metadata alone. It also never rewrites Compose `user:` automatically. + +A matching local host username is **not required by Docker**. Linux file ownership is numeric; creating a host account can nevertheless make bind-mount ownership, backups and administration easier. + +Host inspection is opt-in. For read-only inspection, set: + +```env +HOST_ROOT=/host +ALLOW_HOST_USER_MANAGEMENT=false +ALLOW_HOST_PERMISSION_MANAGEMENT=false +``` + +and mount the host root read-only at `/host`. `examples/compose-host-identity.override.yml` shows this setup. + +Admins may optionally create a locked/non-login local host account using the container's server-side re-resolved UID/GID. This requires both a writable host-root mount and: + +```env +ALLOW_HOST_USER_MANAGEMENT=true +``` + +See `examples/compose-host-user-management.override.yml`. The browser cannot supply an arbitrary UID/GID: Dockwatch re-inspects the container immediately before the change and derives the IDs itself. Existing numeric users/groups are reused and UID/name collisions are refused. Docker rootless/user-namespace remapping is detected where possible; automatic same-numbered host-account creation is refused when IDs are remapped. This operation is intentionally admin-only and disabled by default. + +### Identity & Bind Mount Permissions + +Dockwatch can now diagnose the actual bind-mount permission problem instead of stopping at "container runs as UID X". The container **Identity** dialog and each stack's **Permissions** tab show, per bind mount: + +- effective PID 1 UID/GID and a separate expected **bind UID/GID** +- `PUID/PGID` or `USER_ID/GROUP_ID` when the image exposes those paired conventions +- host owner UID/GID and POSIX mode bits +- static writeability (`w+x` for directories, `w` for files) +- extended POSIX ACL detection when `getfacl` is available +- an optional non-mutating runtime `test -w` using the expected numeric identity +- read-only mounts, rootless/userns remapping and unsafe symlinked host paths as hard repair blockers + +The repair flow is **Analyze → Preview → Repair → Verify**. For recursive ownership repair Dockwatch scans the tree first and shows how many files/directories differ. Automatic recursive repair is refused above 200,000 entries. Symlinks are never followed or chowned. `:ro` mounts are never repaired automatically. + +Permission repair is a separate high-trust opt-in from host-user creation: + +```env +HOST_ROOT=/host +ALLOW_HOST_PERMISSION_MANAGEMENT=true +``` + +and `/` must intentionally be mounted read-write at `/host`. See `examples/compose-host-permissions.override.yml`. The browser supplies only the container and its mount destination (for example `/config`); the backend re-reads `docker inspect` and resolves the real host source itself. Arbitrary host paths and arbitrary UID/GID values cannot be submitted for repair. + +Ownership repair can operate on only the bind root or recursively. `chmod` is separate, optional, explicit, and only applies to the bind root; Dockwatch never automatically applies `chmod 777` and never recursively rewrites modes. After a repair Dockwatch re-runs the ownership/writeability analysis and reports the result. + **Images** - list/filter @@ -197,6 +251,7 @@ Remote-capable features include: - Compose graph - image update checks - containers/images/networks/volumes +- container identity analysis, bind-mount permission repair and optional host-account creation on the agent host - monitoring probes - Git clone/sync/deploy @@ -215,7 +270,10 @@ The designer is not limited to a hard-coded subset: arbitrary maps, arrays and s Server-side save validation still uses Docker Compose itself after all related `.env`, secret, env-file and config files have been staged. -## Reliability and security work in v9 +## Reliability and security work in v9 / v9.2 + +The v9.2 identity extension is opt-in, admin-gated and preserves the existing least-surprise rule: diagnostics are read-only by default and no container user or host account is changed automatically. + The v9 review includes, among other changes: @@ -262,6 +320,9 @@ CHECK_CONCURRENCY=8 CHECK_RETENTION_DAYS=30 HTTP_TIMEOUT_SECONDS=10 AUDIT_RETENTION_DAYS=180 +HOST_ROOT= +ALLOW_HOST_USER_MANAGEMENT=false +ALLOW_HOST_PERMISSION_MANAGEMENT=false ``` `AUTH_DISABLED=true` is for local development only. Do not expose that configuration publicly. @@ -283,6 +344,10 @@ Runtime mounts normally include: Giving Dockwatch access to the Docker socket grants highly privileged control of that Docker host. Protect the UI and agent endpoint accordingly. +## v9.3.1 build-context fix + +v9.3 accidentally used the broad ignore pattern `dockwatch` in both `.gitignore` and `.dockerignore`. Because patterns without a slash match path components recursively, that could hide `cmd/dockwatch/` from Git and from the Docker build context. v9.3.1 removes that pattern, writes local Makefile builds to `bin/dockwatch`, ignores only `bin/`/`dist/`, and makes the Dockerfile fail early with a clear message if `cmd/dockwatch/main.go` is ever missing from the build context. + ## Build from source The pinned OIDC/OAuth2 releases require **Go 1.25**. The Docker build uses `golang:1.25-alpine`. diff --git a/cmd/dockwatch/main.go b/cmd/dockwatch/main.go new file mode 100644 index 0000000..b2f14f4 --- /dev/null +++ b/cmd/dockwatch/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "errors" + "git.send.nrw/sendnrw/dockwatch/internal/audit" + "git.send.nrw/sendnrw/dockwatch/internal/auth" + "git.send.nrw/sendnrw/dockwatch/internal/buildinfo" + "git.send.nrw/sendnrw/dockwatch/internal/config" + database "git.send.nrw/sendnrw/dockwatch/internal/db" + "git.send.nrw/sendnrw/dockwatch/internal/gitops" + "git.send.nrw/sendnrw/dockwatch/internal/httpapi" + "git.send.nrw/sendnrw/dockwatch/internal/monitor" + "git.send.nrw/sendnrw/dockwatch/internal/nodes" + "git.send.nrw/sendnrw/dockwatch/internal/notify" + "git.send.nrw/sendnrw/dockwatch/internal/stacks" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + cfg, e := config.Load() + if e != nil { + slog.Error("config", "error", e) + os.Exit(1) + } + ctx, c := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer c() + db, e := database.Open(cfg.DBPath()) + if e != nil { + slog.Error("database", "error", e) + os.Exit(1) + } + defer db.Close() + ss, e := stacks.New(cfg.StacksDir) + if e != nil { + slog.Error("stacks", "error", e) + os.Exit(1) + } + ss.ConfigureHostAccess(cfg.HostRoot, cfg.AllowHostUserManagement) + ss.ConfigureHostPermissionManagement(cfg.AllowHostPermissionManagement) + nm := nodes.New(db, cfg.EncryptionKey()) + ms := monitor.New(db, nm, cfg.CheckConcurrency, cfg.RetentionDays) + au := audit.New(db) + nt := notify.New(db, cfg.EncryptionKey()) + gs := gitops.New(db, cfg.EncryptionKey(), ss, nm) + ms.SetEventSink(func(ctx context.Context, ev monitor.Event) { + nt.Broadcast(ctx, notify.Message{Title: "Monitor " + ev.To + ": " + ev.Name, Body: ev.Target + " changed from " + ev.From + " to " + ev.To + ". " + ev.Check.Message, Status: ev.To, MonitorID: ev.MonitorID}) + _ = au.Log(ctx, audit.Entry{Actor: "monitor", Action: "monitor.transition", Resource: ev.Name, Detail: map[string]any{"monitor_id": ev.MonitorID, "from": ev.From, "to": ev.To, "latency_ms": ev.Check.LatencyMS}, Status: 200}) + }) + as, e := auth.New(ctx, cfg, db) + if e != nil { + slog.Error("auth", "error", e) + os.Exit(1) + } + if cfg.Mode != config.ModeAgent { + go ms.Run(ctx) + go au.Run(ctx, cfg.AuditRetentionDays) + } + srv := &http.Server{ + Addr: cfg.ListenAddr, + Handler: httpapi.New(cfg, as, ss, nm, ms, au, nt, gs), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, + MaxHeaderBytes: 1 << 20, + } + go func() { + <-ctx.Done() + x, k := context.WithTimeout(context.Background(), 10*time.Second) + defer k() + _ = srv.Shutdown(x) + }() + bi := buildinfo.Current() + slog.Info("started", "mode", cfg.Mode, "listen", cfg.ListenAddr, "version", bi.Version, "commit", bi.Commit) + if e = srv.ListenAndServe(); e != nil && !errors.Is(e, http.ErrServerClosed) { + slog.Error("http server", "error", e) + os.Exit(1) + } +} diff --git a/compose.yml b/compose.yml index 44f6793..cdefcbd 100644 --- a/compose.yml +++ b/compose.yml @@ -17,6 +17,9 @@ services: CHECK_RETENTION_DAYS: "${CHECK_RETENTION_DAYS:-30}" HTTP_TIMEOUT_SECONDS: "${HTTP_TIMEOUT_SECONDS:-10}" AUDIT_RETENTION_DAYS: "${AUDIT_RETENTION_DAYS:-180}" + HOST_ROOT: "${HOST_ROOT:-}" + ALLOW_HOST_USER_MANAGEMENT: "${ALLOW_HOST_USER_MANAGEMENT:-false}" + ALLOW_HOST_PERMISSION_MANAGEMENT: "${ALLOW_HOST_PERMISSION_MANAGEMENT:-false}" volumes: - ./data:/data - ./stacks:/stacks diff --git a/examples/compose-agent.yml b/examples/compose-agent.yml index 99e3db1..ce0559e 100644 --- a/examples/compose-agent.yml +++ b/examples/compose-agent.yml @@ -7,7 +7,15 @@ services: APP_MODE: agent AGENT_TOKEN: "replace-with-a-random-token-at-least-24-characters" HTTP_TIMEOUT_SECONDS: "10" + # Optional: set HOST_ROOT=/host and mount /:/host:ro for identity checks. + HOST_ROOT: "" + ALLOW_HOST_USER_MANAGEMENT: "false" + ALLOW_HOST_PERMISSION_MANAGEMENT: "false" volumes: - ./agent-data:/data - ./agent-stacks:/stacks - /var/run/docker.sock:/var/run/docker.sock + # For read-only host UID/GID + bind ownership checks: + # - /:/host:ro + # For explicit admin host-user creation only: use /:/host:rw and set + # ALLOW_HOST_USER_MANAGEMENT=true. diff --git a/examples/compose-host-identity.override.yml b/examples/compose-host-identity.override.yml new file mode 100644 index 0000000..836f39c --- /dev/null +++ b/examples/compose-host-identity.override.yml @@ -0,0 +1,14 @@ +# Optional read-only host identity inspection for a local/agent Dockwatch instance. +# Usage: +# docker compose -f compose.yml -f examples/compose-host-identity.override.yml up -d +# +# This lets Dockwatch map container UID/GID to host accounts and inspect ownership +# of bind-mount sources. It does NOT allow Dockwatch to create host users. +services: + dockwatch: + environment: + HOST_ROOT: /host + ALLOW_HOST_USER_MANAGEMENT: "false" + ALLOW_HOST_PERMISSION_MANAGEMENT: "false" + volumes: + - /:/host:ro diff --git a/examples/compose-host-permissions.override.yml b/examples/compose-host-permissions.override.yml new file mode 100644 index 0000000..dc72d36 --- /dev/null +++ b/examples/compose-host-permissions.override.yml @@ -0,0 +1,10 @@ +# High-trust opt-in for host bind-mount ownership/mode repair. +# Dockwatch can chown host files through /host, so use only on trusted machines. +services: + dockwatch: + environment: + HOST_ROOT: /host + ALLOW_HOST_USER_MANAGEMENT: "true" + ALLOW_HOST_PERMISSION_MANAGEMENT: "true" + volumes: + - /:/host:rw diff --git a/examples/compose-host-user-management.override.yml b/examples/compose-host-user-management.override.yml new file mode 100644 index 0000000..4710cc6 --- /dev/null +++ b/examples/compose-host-user-management.override.yml @@ -0,0 +1,13 @@ +# DANGEROUS / EXPLICIT OPT-IN: +# This gives Dockwatch write access to the host root so an administrator can create +# a local user/group matching a container's effective UID/GID. The Docker socket +# already grants broad host control, but this mount increases direct filesystem +# exposure. Use only on trusted hosts and keep ALLOW_HOST_USER_MANAGEMENT=false by default. +services: + dockwatch: + environment: + HOST_ROOT: /host + ALLOW_HOST_USER_MANAGEMENT: "true" + ALLOW_HOST_PERMISSION_MANAGEMENT: "false" + volumes: + - /:/host:rw diff --git a/examples/compose-master.yml b/examples/compose-master.yml index f48f9d7..ce00e3c 100644 --- a/examples/compose-master.yml +++ b/examples/compose-master.yml @@ -16,8 +16,16 @@ services: CHECK_CONCURRENCY: "8" CHECK_RETENTION_DAYS: "30" HTTP_TIMEOUT_SECONDS: "10" + # Optional: set HOST_ROOT=/host and mount /:/host:ro for identity checks. + HOST_ROOT: "" + ALLOW_HOST_USER_MANAGEMENT: "false" + ALLOW_HOST_PERMISSION_MANAGEMENT: "false" AUDIT_RETENTION_DAYS: "180" volumes: - ./master-data:/data - ./master-stacks:/stacks - /var/run/docker.sock:/var/run/docker.sock + # For read-only host UID/GID + bind ownership checks: + # - /:/host:ro + # For explicit admin host-user creation only: use /:/host:rw and set + # ALLOW_HOST_USER_MANAGEMENT=true. diff --git a/go.mod b/go.mod index d20a3d4..67ae0d3 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,12 @@ module git.send.nrw/sendnrw/dockwatch -go 1.25.0 +go 1.25 require ( - github.com/coreos/go-oidc/v3 v3.20.0 github.com/creack/pty v1.1.24 github.com/gorilla/websocket v1.5.3 + github.com/coreos/go-oidc/v3 v3.20.0 golang.org/x/oauth2 v0.36.0 - gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.57.0 -) - -require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.24 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.47.0 // indirect - modernc.org/libc v1.74.4 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/internal/config/config.go b/internal/config/config.go index 64adf67..d07c0e9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "strconv" "strings" "time" @@ -25,7 +26,8 @@ type Config struct { ListenAddr, BaseURL, DataDir, StacksDir, AppSecret string SecureCookies, AuthDisabled bool OIDCIssuer, OIDCClientID, OIDCClientSecret, OIDCRedirectURL, OIDCAdminGroup, OIDCOperatorGroup string - AgentToken string + AgentToken, HostRoot string + AllowHostUserManagement, AllowHostPermissionManagement bool CheckConcurrency, RetentionDays, AuditRetentionDays int HTTPTimeout time.Duration } @@ -51,25 +53,36 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + allowHostUserManagement, err := envBoolStrict("ALLOW_HOST_USER_MANAGEMENT", false) + if err != nil { + return Config{}, err + } + allowHostPermissionManagement, err := envBoolStrict("ALLOW_HOST_PERMISSION_MANAGEMENT", false) + if err != nil { + return Config{}, err + } c := Config{ - Mode: Mode(env("APP_MODE", "standalone")), - ListenAddr: env("LISTEN_ADDR", ":8080"), - BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"), - DataDir: env("DATA_DIR", "/data"), - StacksDir: env("STACKS_DIR", "/stacks"), - AppSecret: os.Getenv("APP_SECRET"), - AuthDisabled: authDisabled, - OIDCIssuer: strings.TrimRight(os.Getenv("OIDC_ISSUER"), "/"), - OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), - OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), - OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"), - OIDCAdminGroup: env("OIDC_ADMIN_GROUP", "dockwatch-admins"), - OIDCOperatorGroup: env("OIDC_OPERATOR_GROUP", "dockwatch-operators"), - AgentToken: os.Getenv("AGENT_TOKEN"), - CheckConcurrency: checkConcurrency, - RetentionDays: retentionDays, - AuditRetentionDays: auditRetentionDays, - HTTPTimeout: time.Duration(httpTimeoutSeconds) * time.Second, + Mode: Mode(env("APP_MODE", "standalone")), + ListenAddr: env("LISTEN_ADDR", ":8080"), + BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"), + DataDir: env("DATA_DIR", "/data"), + StacksDir: env("STACKS_DIR", "/stacks"), + AppSecret: os.Getenv("APP_SECRET"), + AuthDisabled: authDisabled, + OIDCIssuer: strings.TrimRight(os.Getenv("OIDC_ISSUER"), "/"), + OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), + OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + OIDCAdminGroup: env("OIDC_ADMIN_GROUP", "dockwatch-admins"), + OIDCOperatorGroup: env("OIDC_OPERATOR_GROUP", "dockwatch-operators"), + AgentToken: os.Getenv("AGENT_TOKEN"), + HostRoot: cleanOptionalPath(os.Getenv("HOST_ROOT")), + AllowHostUserManagement: allowHostUserManagement, + AllowHostPermissionManagement: allowHostPermissionManagement, + CheckConcurrency: checkConcurrency, + RetentionDays: retentionDays, + AuditRetentionDays: auditRetentionDays, + HTTPTimeout: time.Duration(httpTimeoutSeconds) * time.Second, } c.SecureCookies = strings.HasPrefix(c.BaseURL, "https://") if c.OIDCRedirectURL == "" { @@ -98,6 +111,15 @@ func Load() (Config, error) { return c, errors.New("BASE_URL must be an absolute http(s) URL without credentials, query or fragment") } } + if c.HostRoot != "" && !filepath.IsAbs(c.HostRoot) { + return c, errors.New("HOST_ROOT must be an absolute path") + } + if c.AllowHostUserManagement && c.HostRoot == "" { + return c, errors.New("ALLOW_HOST_USER_MANAGEMENT=true requires HOST_ROOT") + } + if c.AllowHostPermissionManagement && c.HostRoot == "" { + return c, errors.New("ALLOW_HOST_PERMISSION_MANAGEMENT=true requires HOST_ROOT") + } if c.Mode == ModeAgent { if len(c.AgentToken) < 24 { return c, errors.New("AGENT_TOKEN must be at least 24 characters in agent mode") @@ -118,6 +140,14 @@ func (c Config) SecretFingerprint() string { h := sha256.Sum256([]byte(c.AppSecret)) return base64.RawURLEncoding.EncodeToString(h[:6]) } +func cleanOptionalPath(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + return filepath.Clean(v) +} + func env(k, f string) string { if v := os.Getenv(k); v != "" { return v diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f655a6b..eaf0110 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -16,3 +16,29 @@ func TestStrictBooleanParsing(t *testing.T) { t.Fatal("expected invalid AUTH_DISABLED to fail") } } + +func TestHostUserManagementRequiresHostRoot(t *testing.T) { + t.Setenv("APP_MODE", "agent") + t.Setenv("AGENT_TOKEN", "123456789012345678901234") + t.Setenv("ALLOW_HOST_USER_MANAGEMENT", "true") + t.Setenv("HOST_ROOT", "") + if _, err := Load(); err == nil { + t.Fatal("expected host user management without HOST_ROOT to fail") + } +} + +func TestCleanOptionalHostRootPreservesFilesystemRoot(t *testing.T) { + if got := cleanOptionalPath("/"); got != "/" { + t.Fatalf("cleanOptionalPath(/) = %q", got) + } +} + +func TestHostPermissionManagementRequiresHostRoot(t *testing.T) { + t.Setenv("AUTH_DISABLED", "true") + t.Setenv("APP_SECRET", "01234567890123456789012345678901") + t.Setenv("ALLOW_HOST_PERMISSION_MANAGEMENT", "true") + t.Setenv("HOST_ROOT", "") + if _, err := Load(); err == nil { + t.Fatal("expected host permission management without HOST_ROOT to fail") + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 735ff08..7ef6ec4 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -95,6 +95,10 @@ func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager, api.HandleFunc("GET /api/docker/{kind}", s.dockerInventory) api.Handle("POST /api/docker/{kind}/actions/{action}", auth.RequireRole("operator", http.HandlerFunc(s.dockerAction))) api.Handle("GET /api/docker/{kind}/{id}/inspect", auth.RequireRole("operator", http.HandlerFunc(s.dockerInspect))) + api.Handle("GET /api/docker/containers/{id}/identity", auth.RequireRole("operator", http.HandlerFunc(s.containerIdentity))) + api.Handle("POST /api/docker/containers/{id}/bind-permissions/preview", auth.RequireRole("operator", http.HandlerFunc(s.bindPermissionPreview))) + api.Handle("POST /api/host/bind-permissions/repair", auth.RequireRole("admin", http.HandlerFunc(s.repairBindPermissions))) + api.Handle("POST /api/host/users", auth.RequireRole("admin", http.HandlerFunc(s.createHostUser))) api.HandleFunc("GET /api/stacks", s.listStacks) api.Handle("GET /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.getStack))) api.HandleFunc("POST /api/compose/parse", s.composeParse) @@ -105,6 +109,7 @@ func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager, api.Handle("GET /api/stacks/{name}/logs", auth.RequireRole("operator", http.HandlerFunc(s.logs))) api.Handle("DELETE /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.deleteStack))) api.HandleFunc("GET /api/stacks/{name}/graph", s.stackGraph) + api.Handle("GET /api/stacks/{name}/bind-permissions", auth.RequireRole("operator", http.HandlerFunc(s.stackBindPermissions))) api.HandleFunc("GET /api/stacks/{name}/image-updates", s.stackImageUpdates) api.Handle("GET /api/stacks/{name}/terminal", auth.RequireRole("operator", http.HandlerFunc(s.stackTerminal))) api.Handle("GET /api/activity", auth.RequireRole("admin", http.HandlerFunc(s.activity))) @@ -140,6 +145,10 @@ func (s *Server) agent(m *http.ServeMux) { a.HandleFunc("GET /agent/v1/docker/{kind}", s.localDockerInventory) a.HandleFunc("POST /agent/v1/docker/{kind}/actions/{action}", s.localDockerAction) a.HandleFunc("GET /agent/v1/docker/{kind}/{id}/inspect", s.localDockerInspect) + a.HandleFunc("GET /agent/v1/docker/containers/{id}/identity", s.localContainerIdentity) + a.HandleFunc("POST /agent/v1/docker/containers/{id}/bind-permissions/preview", s.localBindPermissionPreview) + a.HandleFunc("POST /agent/v1/host/bind-permissions/repair", s.localRepairBindPermissions) + a.HandleFunc("POST /agent/v1/host/users", s.localCreateHostUser) a.HandleFunc("GET /agent/v1/stacks", s.localList) a.HandleFunc("GET /agent/v1/stacks/{name}", s.localGet) a.HandleFunc("PUT /agent/v1/stacks/{name}", s.localSave) @@ -149,6 +158,7 @@ func (s *Server) agent(m *http.ServeMux) { a.HandleFunc("POST /agent/v1/git/sync", s.localGitSync) a.HandleFunc("DELETE /agent/v1/stacks/{name}", s.localDelete) a.HandleFunc("GET /agent/v1/stacks/{name}/graph", s.localGraph) + a.HandleFunc("GET /agent/v1/stacks/{name}/bind-permissions", s.localStackBindPermissions) a.HandleFunc("GET /agent/v1/stacks/{name}/image-updates", s.localImageUpdates) a.HandleFunc("GET /agent/v1/stacks/{name}/terminal", s.localTerminal) a.HandleFunc("POST /agent/v1/probe", func(w http.ResponseWriter, r *http.Request) { @@ -612,6 +622,137 @@ func (s *Server) localDockerInspect(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, v) } +func (s *Server) containerIdentity(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if node := nodeID(r); node > 0 { + s.relay(w, r, node, "GET", "/agent/v1/docker/containers/"+url.PathEscape(id)+"/identity", nil) + return + } + s.localContainerIdentity(w, r) +} + +func (s *Server) localContainerIdentity(w http.ResponseWriter, r *http.Request) { + v, e := s.stacks.ContainerIdentity(r.Context(), r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) bindPermissionPreview(w http.ResponseWriter, r *http.Request) { + var in stacks.BindPermissionPreviewInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + in.ContainerID = r.PathValue("id") + if node := nodeID(r); node > 0 { + s.relay(w, r, node, "POST", "/agent/v1/docker/containers/"+url.PathEscape(in.ContainerID)+"/bind-permissions/preview", in) + return + } + s.bindPermissionPreviewLocal(w, r, in) +} + +func (s *Server) localBindPermissionPreview(w http.ResponseWriter, r *http.Request) { + var in stacks.BindPermissionPreviewInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + in.ContainerID = r.PathValue("id") + s.bindPermissionPreviewLocal(w, r, in) +} + +func (s *Server) bindPermissionPreviewLocal(w http.ResponseWriter, r *http.Request, in stacks.BindPermissionPreviewInput) { + v, e := s.stacks.BindPermissionPreview(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) repairBindPermissions(w http.ResponseWriter, r *http.Request) { + var in stacks.RepairBindPermissionsInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + if node := nodeID(r); node > 0 { + s.relay(w, r, node, "POST", "/agent/v1/host/bind-permissions/repair", in) + return + } + s.repairBindPermissionsLocal(w, r, in) +} + +func (s *Server) localRepairBindPermissions(w http.ResponseWriter, r *http.Request) { + var in stacks.RepairBindPermissionsInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + s.repairBindPermissionsLocal(w, r, in) +} + +func (s *Server) repairBindPermissionsLocal(w http.ResponseWriter, r *http.Request, in stacks.RepairBindPermissionsInput) { + v, e := s.stacks.RepairBindPermissions(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) stackBindPermissions(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if node := nodeID(r); node > 0 { + s.relay(w, r, node, "GET", "/agent/v1/stacks/"+url.PathEscape(name)+"/bind-permissions", nil) + return + } + s.localStackBindPermissions(w, r) +} + +func (s *Server) localStackBindPermissions(w http.ResponseWriter, r *http.Request) { + v, e := s.stacks.StackBindPermissions(r.Context(), r.PathValue("name")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) createHostUser(w http.ResponseWriter, r *http.Request) { + var in stacks.CreateHostUserInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + if node := nodeID(r); node > 0 { + s.relay(w, r, node, "POST", "/agent/v1/host/users", in) + return + } + s.createHostUserLocal(w, r, in) +} + +func (s *Server) localCreateHostUser(w http.ResponseWriter, r *http.Request) { + var in stacks.CreateHostUserInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + s.createHostUserLocal(w, r, in) +} + +func (s *Server) createHostUserLocal(w http.ResponseWriter, r *http.Request, in stacks.CreateHostUserInput) { + v, e := s.stacks.CreateHostUser(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + func (s *Server) listStacks(w http.ResponseWriter, r *http.Request) { if id := nodeID(r); id > 0 { s.relay(w, r, id, "GET", "/agent/v1/stacks", nil) diff --git a/web/app.js b/web/app.js index ae2af98..9787e54 100644 --- a/web/app.js +++ b/web/app.js @@ -42,7 +42,7 @@ function newStack(){if(state.dirty&&!confirm('Aktuellen Entwurf verwerfen?'))ret image: nginx:alpine restart: unless-stopped `,env:'',secrets:[],env_files:[],configs:[]};if(draft){try{const d=JSON.parse(draft);if(confirm('Gespeicherten lokalen Stack-Entwurf wiederherstellen?'))st={...st,...d}}catch{}}state.stack=st;setDirty(false);renderStacks()} -function stackDetailHTML(st){const sv=st.services||[];return `
env_file: ./envs/app.env.')}configs: ... file: ./configs/name.')}env_file: ./envs/app.env.')}configs: ... file: ./configs/name.')}Select “Load” or “Follow”.`} function graphTab(){return `
| ${esc(c[1])} | `).join('')}Actions |
|---|---|
| ${i===0?` ⬡${esc(r[c[0]]||'—')} `:esc(r[c[0]]||'—')} | `).join('')}${resourceActions(kind,r,idx)} |
| Container | UID:GID | Assessment | Host account | Bind mounts |
|---|---|---|---|---|
| ${esc(d.container_name||x.row.Names||x.row.ID)} ${esc(d.image||'')} | PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')} bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')} | ${esc(identityAssessmentLabel(d.root_assessment))} | ${d.bind_host_user?`${esc(d.bind_host_user.name)}`:(d.host_access?.available&&d.bind_uid>0?'missing':'—')} | ${asArray(d.bind_mounts).length} |
| Check ${i+1} failed: ${esc(x.reason?.message||x.reason)} | ||||
| Host path | Container path | Mode / owner | Write access | |
|---|---|---|---|---|
| ${esc(x.source)} | ${esc(x.destination)} ${x.read_only?'ro':'rw'} | ${esc(x.mode||'—')} · ${x.owner_uid===undefined?'—':`${esc(x.owner_user||x.owner_uid)} (${esc(x.owner_uid)}:${esc(x.owner_gid)})`}${x.acl_detected?'ACL detected ':''} | ${x.read_only?'read-only':writeState(x.static_writable)} ${esc(x.writable_reason||x.ownership_note||'')} | ${!x.read_only?``:''} |
user: automatically.| Service | Host → container | Expected | Owner / mode | Write | |
|---|---|---|---|---|---|
| ${esc(x.service)} | ${esc(x.error)} | ||||
| ${esc(x.service||x.report?.container_name)} | ${esc(x.mount.source)} → ${esc(x.mount.destination)} ${x.mount.read_only?'(ro)':'(rw)'} | ${esc(x.report.bind_uid??'—')}:${esc(x.report.bind_gid??'—')} ${esc(x.report.bind_identity_source||'')} | ${esc(x.mount.owner_uid??'—')}:${esc(x.mount.owner_gid??'—')} · ${esc(x.mount.mode||'—')} | ${x.mount.read_only?'read-only':writeState(x.mount.static_writable)} | ${!x.mount.read_only?``:''} |
${esc(JSON.stringify(i,null,2))}${esc(text||'OK')}