From 56170ccde9fc3638b952af4bef7b1ae86ab1630c Mon Sep 17 00:00:00 2001 From: jbergner Date: Mon, 31 Aug 2026 22:17:18 +0200 Subject: [PATCH] v9.3.1 --- .dockerignore | 3 +- .env.example | 6 + .gitignore | 4 +- Dockerfile | 4 +- Makefile | 5 +- README.md | 77 +++++++++- cmd/dockwatch/main.go | 84 +++++++++++ compose.yml | 3 + examples/compose-agent.yml | 8 + examples/compose-host-identity.override.yml | 14 ++ .../compose-host-permissions.override.yml | 10 ++ .../compose-host-user-management.override.yml | 13 ++ examples/compose-master.yml | 8 + go.mod | 19 +-- internal/config/config.go | 68 ++++++--- internal/config/config_test.go | 26 ++++ internal/httpapi/httpapi.go | 141 ++++++++++++++++++ web/app.js | 41 ++++- web/styles.css | 2 + 19 files changed, 485 insertions(+), 51 deletions(-) create mode 100644 cmd/dockwatch/main.go create mode 100644 examples/compose-host-identity.override.yml create mode 100644 examples/compose-host-permissions.override.yml create mode 100644 examples/compose-host-user-management.override.yml 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 `
▱

${esc(st.name||'New compose stack')}

${st.name?esc(nodeName()):'Draft · not deployed'}
${badge(st.status||'new')}
${st.name?``:''}
${composeTab(st)}
`} +function stackDetailHTML(st){const sv=st.services||[];return `
▱

${esc(st.name||'New compose stack')}

${st.name?esc(nodeName()):'Draft · not deployed'}
${badge(st.status||'new')}
${st.name?``:''}
${composeTab(st)}
`} function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'
A local unsaved draft exists for this stack.
':''}
Full Compose Designer parsing…
compose.yaml Source of truth
Visual editor all fields · AST patch mode
Parsing Compose…
Every present Compose value is editable as string, number, boolean, null, map or array.Current spec fields are suggested; x-* and future fields remain editable too.Invalid YAML pauses visual sync without replacing your source.
`} const COMPOSE_SERVICE_FIELDS=['annotations','attach','build','blkio_config','cpu_count','cpu_percent','cpu_shares','cpu_period','cpu_quota','cpu_rt_runtime','cpu_rt_period','cpus','cpuset','cap_add','cap_drop','cgroup','cgroup_parent','command','configs','container_name','credential_spec','depends_on','deploy','develop','device_cgroup_rules','devices','dns','dns_opt','dns_search','domainname','driver_opts','entrypoint','env_file','environment','expose','extends','external_links','extra_hosts','gpus','group_add','healthcheck','hostname','image','init','ipc','isolation','labels','label_file','links','logging','mac_address','mem_limit','mem_reservation','mem_swappiness','memswap_limit','models','network_mode','networks','oom_kill_disable','oom_score_adj','pid','pids_limit','platform','ports','post_start','pre_start','pre_stop','privileged','profiles','provider','pull_policy','read_only','restart','runtime','scale','secrets','security_opt','shm_size','stdin_open','stop_grace_period','stop_signal','storage_opt','sysctls','tmpfs','tty','ulimits','use_api_socket','user','userns_mode','uts','volumes','volumes_from','working_dir']; @@ -82,11 +82,12 @@ function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.mana function servicesTab(st){const sv=st.services||[];if(!sv.length)return'
This stack has no running containers yet.
';return `
${sv.map(v=>`
${esc(v.service||v.name)}${badge(v.state||v.status)}
Image${esc(v.image||'—')}Ports${esc(v.ports||'—')}Command${esc(v.command||'—')}
`).join('')}
`} function logsTab(){return `
idle
Select “Load” or “Follow”.
`} function graphTab(){return `
Load the normalized Compose dependency graph.
`} +function permissionsTab(){return `
Checks expected application UID/GID, host ownership, mode bits and ACL hints.
Run the analysis to review writable bind mounts for every created service.
`} function updatesTab(){return `
Compare installed image digests with registry manifests.
`} function consoleTab(sv){return `
Interactive Docker Exec terminal backed by a real PTY/WebSocket session. Click the terminal and type normally.
Terminal disconnected.
`} function dangerTab(st){return st.name?`
Safe delete is the default. It removes only compose.yaml, .env and Dockwatch-managed secrets/env/config folders. Unrelated bind-mount data beside the stack is preserved.
`:'
Save the stack first.
'} -function wireStackDetail(){const root=$('#stackDetail');root.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x===b));['compose','env','envfiles','secrets','configs','services','graph','updates','logs','console','danger'].forEach(t=>{const e=$(`#tab-${t}`);if(e)e.hidden=t!==b.dataset.tab})});const mark=()=>{setDirty(true);saveDraft()};let composeTimer;root.addEventListener('input',e=>{if(e.target.matches('#composeText,#envText,#stackName,.secName,.secContent,.managedName,.managedContent'))mark();if(e.target.matches('#composeText')){clearTimeout(composeTimer);composeTimer=setTimeout(parseComposeVisual,220)}});$('#addComposeService')?.addEventListener('click',addComposeService);parseComposeVisual();$('#composeExpandAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=true)});$('#composeCollapseAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=false)});$('#addSecret')?.addEventListener('click',()=>{$('#secretList').insertAdjacentHTML('beforeend',secretRow());wireSecretRemovers();mark()});$('#addenvfile')?.addEventListener('click',()=>{$('#envfileList').insertAdjacentHTML('beforeend',managedFileRow('envfile'));wireManagedRemovers();mark()});$('#addconfig')?.addEventListener('click',()=>{$('#configList').insertAdjacentHTML('beforeend',managedFileRow('config'));wireManagedRemovers();mark()});wireSecretRemovers();wireManagedRemovers();$('#saveStack').onclick=saveStack;$$('[data-act]').forEach(b=>b.onclick=()=>stackAction(b.dataset.act));$('#loadLogs')?.addEventListener('click',loadLogs);$('#liveLogs')?.addEventListener('click',startLiveLogs);$('#stopLogs')?.addEventListener('click',stopLiveLogs);$('#pauseLogs')?.addEventListener('click',togglePauseLogs);$('#downloadLogs')?.addEventListener('click',downloadLogs);$('#logAutoScroll')?.addEventListener('change',e=>state.logAutoScroll=e.target.checked);$('#logFilter')?.addEventListener('input',filterLogs);$('#openTerminal')?.addEventListener('click',openTerminal);$('#closeTerminal')?.addEventListener('click',closeTerminal);$('#loadGraph')?.addEventListener('click',loadGraph);$('#checkUpdates')?.addEventListener('click',loadImageUpdates);$('#deleteStack')?.addEventListener('click',()=>deleteStack(false));$('#purgeStack')?.addEventListener('click',()=>deleteStack(true));$('#restoreDraft')?.addEventListener('click',restoreDraft);$('#discardDraft')?.addEventListener('click',()=>{clearDraft(state.stack.name);renderStacks()})} +function wireStackDetail(){const root=$('#stackDetail');root.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x===b));['compose','env','envfiles','secrets','configs','services','permissions','graph','updates','logs','console','danger'].forEach(t=>{const e=$(`#tab-${t}`);if(e)e.hidden=t!==b.dataset.tab})});const mark=()=>{setDirty(true);saveDraft()};let composeTimer;root.addEventListener('input',e=>{if(e.target.matches('#composeText,#envText,#stackName,.secName,.secContent,.managedName,.managedContent'))mark();if(e.target.matches('#composeText')){clearTimeout(composeTimer);composeTimer=setTimeout(parseComposeVisual,220)}});$('#addComposeService')?.addEventListener('click',addComposeService);parseComposeVisual();$('#composeExpandAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=true)});$('#composeCollapseAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=false)});$('#addSecret')?.addEventListener('click',()=>{$('#secretList').insertAdjacentHTML('beforeend',secretRow());wireSecretRemovers();mark()});$('#addenvfile')?.addEventListener('click',()=>{$('#envfileList').insertAdjacentHTML('beforeend',managedFileRow('envfile'));wireManagedRemovers();mark()});$('#addconfig')?.addEventListener('click',()=>{$('#configList').insertAdjacentHTML('beforeend',managedFileRow('config'));wireManagedRemovers();mark()});wireSecretRemovers();wireManagedRemovers();$('#saveStack').onclick=saveStack;$$('[data-act]').forEach(b=>b.onclick=()=>stackAction(b.dataset.act));$('#loadLogs')?.addEventListener('click',loadLogs);$('#liveLogs')?.addEventListener('click',startLiveLogs);$('#stopLogs')?.addEventListener('click',stopLiveLogs);$('#pauseLogs')?.addEventListener('click',togglePauseLogs);$('#downloadLogs')?.addEventListener('click',downloadLogs);$('#logAutoScroll')?.addEventListener('change',e=>state.logAutoScroll=e.target.checked);$('#logFilter')?.addEventListener('input',filterLogs);$('#openTerminal')?.addEventListener('click',openTerminal);$('#closeTerminal')?.addEventListener('click',closeTerminal);$('#loadGraph')?.addEventListener('click',loadGraph);$('#loadPermissions')?.addEventListener('click',loadStackPermissions);$('#checkUpdates')?.addEventListener('click',loadImageUpdates);$('#deleteStack')?.addEventListener('click',()=>deleteStack(false));$('#purgeStack')?.addEventListener('click',()=>deleteStack(true));$('#restoreDraft')?.addEventListener('click',restoreDraft);$('#discardDraft')?.addEventListener('click',()=>{clearDraft(state.stack.name);renderStacks()})} function wireSecretRemovers(){$$('.removeSecret').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})} function wireManagedRemovers(){$$('.removeManaged').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})} function restoreDraft(){try{const d=JSON.parse(localStorage.getItem(draftKey(state.stack.name)));state.stack={...state.stack,...d};setDirty(true);renderStacks()}catch{toast('Draft could not be restored.')}} @@ -130,18 +131,48 @@ function renderNodes(){setCrumb('System / Environments');const local=`

${x?'Edit':'Add'} remote agent

${x?``:''}
`);$('#saveNode').onclick=async()=>{try{const body={name:$('#nodeName').value,base_url:$('#nodeURL').value,token:$('#nodeToken').value};if(x)body.enabled=$('#nodeEnabled').checked;await api(x?`/api/nodes/${x.id}`:'/api/nodes',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();const rows=await api('/api/nodes');state.nodes=Array.isArray(rows)?rows:[];renderNodePicker();renderNodes()}catch(e){toast(e.message)}}} async function loadNodeHealth(){await Promise.all(state.nodes.filter(n=>n.enabled).map(async n=>{const el=$(`#nodeHealth-${n.id}`);if(!el)return;try{const h=await api(`/api/nodes/${n.id}/health`);state.nodeHealth[n.id]=h;el.innerHTML=`${badge('up')}
v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}
`}catch(e){el.innerHTML=`${badge('down')}
unreachable
`}}))} function resourceTitle(k){return({containers:'Containers',images:'Images',volumes:'Volumes',networks:'Networks'})[k]||k} -function renderDockerResource(){const k=state.view,n=resourceTitle(k);setCrumb(`Docker / ${n}`);let create='';if(roleOK()){if(k==='images')create='';if(k==='volumes')create='';if(k==='networks')create=''}const prune=roleOK()&&k!=='containers'?'':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}`)}
Loading ${n.toLowerCase()}…
`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)} +function renderDockerResource(){const k=state.view,n=resourceTitle(k);setCrumb(`Docker / ${n}`);let create='';if(roleOK()){if(k==='containers')create='';if(k==='images')create='';if(k==='volumes')create='';if(k==='networks')create=''}const prune=roleOK()&&k!=='containers'?'':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}`)}
Loading ${n.toLowerCase()}…
`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#identityAudit')?.addEventListener('click',containerIdentityAudit);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)} function dockerID(r){return r.ID||r.Id||r.ImageID||r.Name||''} async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`
${esc(e.message)}
`}} function renderInventoryRows(kind,all){const p=$('#inventoryPanel');if(!p)return;const q=$('#inventorySearch')?.value.toLowerCase()||'',rows=q?all.filter(r=>JSON.stringify(r).toLowerCase().includes(q)):all;const count=$('#inventoryCount');if(count)count.textContent=`${rows.length} / ${all.length}`;if(!rows.length){p.innerHTML='
No matching items found.
';return}const defs={containers:[['Names','Name'],['Image','Image'],['State','State'],['Status','Status'],['Ports','Ports']],images:[['Repository','Repository'],['Tag','Tag'],['ID','Image ID'],['Size','Size'],['CreatedSince','Created']],volumes:[['Name','Name'],['Driver','Driver'],['Mountpoint','Mountpoint'],['Labels','Labels']],networks:[['Name','Name'],['Driver','Driver'],['Scope','Scope'],['IPv6','IPv6'],['Internal','Internal']]};const cols=defs[kind]||Object.keys(rows[0]).slice(0,5).map(k=>[k,k]);p.innerHTML=`${cols.map(c=>``).join('')}${rows.map(r=>{const idx=all.indexOf(r);return `${cols.map((c,i)=>``).join('')}`}).join('')}
${esc(c[1])}Actions
${i===0?`
⬡${esc(r[c[0]]||'—')}
`:esc(r[c[0]]||'—')}
${resourceActions(kind,r,idx)}
`;wireResourceRows(kind)} -function resourceActions(kind,r,idx){const inspect=roleOK()?``:'';if(kind==='containers')return `${inspect}${roleOK()?` `:''}`;if(!roleOK())return'';return `${inspect} `} -function wireResourceRows(kind){$$('[data-ract]').forEach(b=>b.onclick=()=>{const r=window.__inventoryRows[Number(b.dataset.row)]||{},a=b.dataset.ract;let payload={};if(kind==='containers')payload={id:r.ID||r.Names||r.Name,force:a==='remove'};else if(kind==='images')payload={id:r.ID,name:[r.Repository,r.Tag].filter(Boolean).join(':')};else payload={name:r.Name};dockerResourceAction(kind,a,payload,a==='remove')});$$('[data-inspect]').forEach(b=>b.onclick=()=>inspectDockerResource(kind,window.__inventoryRows[Number(b.dataset.inspect)]))} +function resourceActions(kind,r,idx){const inspect=roleOK()?``:'';if(kind==='containers')return `${inspect}${roleOK()?` `:''}`;if(!roleOK())return'';return `${inspect} `} +function wireResourceRows(kind){$$('[data-ract]').forEach(b=>b.onclick=()=>{const r=window.__inventoryRows[Number(b.dataset.row)]||{},a=b.dataset.ract;let payload={};if(kind==='containers')payload={id:r.ID||r.Names||r.Name,force:a==='remove'};else if(kind==='images')payload={id:r.ID,name:[r.Repository,r.Tag].filter(Boolean).join(':')};else payload={name:r.Name};dockerResourceAction(kind,a,payload,a==='remove')});$$('[data-inspect]').forEach(b=>b.onclick=()=>inspectDockerResource(kind,window.__inventoryRows[Number(b.dataset.inspect)]));$$('[data-identity]').forEach(b=>b.onclick=()=>inspectContainerIdentity(window.__inventoryRows[Number(b.dataset.identity)]))} async function dockerResourceAction(kind,action,payload,confirmFirst=false){if(confirmFirst&&!confirm(`${action} ${kind}? This can delete Docker resources.`))return;try{const r=await api(`/api/docker/${kind}/actions/${action}${qnode()}`,{method:'POST',body:JSON.stringify(payload||{})});toast(`${resourceTitle(kind)}: ${action} completed`);if(r?.output)showOutput(`${resourceTitle(kind)} · ${action}`,r.output);await loadInventory(kind)}catch(e){toast(e.message)}} function registryLoginModal(){modal(`

Registry login

Credentials are written by Docker CLI to the persistent Docker config on this environment. The password is passed through stdin, not a command-line argument.
`);$('#regSave').onclick=async()=>{const body={registry:$('#regHost').value.trim(),username:$('#regUser').value.trim(),password:$('#regPass').value};if(!body.registry||!body.username||!body.password)return toast('Registry, username and password required.');closeModal();await dockerResourceAction('images','login',body)}} function registryLogoutModal(){modal(`

Registry logout

`);$('#regSave').onclick=async()=>{const registry=$('#regHost').value.trim();if(!registry)return toast('Registry required.');closeModal();await dockerResourceAction('images','logout',{registry})}} function resourceCreateModal(kind){if(kind==='images'){modal(`

Pull image

`);$('#resSave').onclick=async()=>{const name=$('#resName').value.trim();if(!name)return toast('Image reference required.');closeModal();await dockerResourceAction('images','pull',{name})};return}const isNet=kind==='networks';modal(`

Create ${isNet?'network':'volume'}

${isNet?'':''}
`);$('#resSave').onclick=async()=>{const labels={};($('#resLabels').value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean).forEach(x=>{const i=x.indexOf('=');if(i<0)labels[x]='';else labels[x.slice(0,i).trim()]=x.slice(i+1).trim()});const body={name:$('#resName').value.trim(),driver:$('#resDriver').value.trim(),labels};if(isNet){body.internal=$('#resInternal').checked;body.attachable=$('#resAttachable').checked}if(!body.name)return toast('Name required.');closeModal();await dockerResourceAction(kind,'create',body)}} +async function containerIdentityAudit(){const rows=asArray(window.__inventoryRows);if(!rows.length)return toast('No containers to inspect.');const btn=$('#identityAudit');setBusy(btn,true,'Scanning…');try{const results=[];for(let i=0;i{const id=r.ID||r.Names||r.Name;return api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`).then(v=>({row:r,report:v}))})))}const ok=results.filter(x=>x.status==='fulfilled').map(x=>x.value),failed=results.filter(x=>x.status==='rejected');const rootCount=ok.filter(x=>x.report.runs_as_root===true).length,nonRoot=ok.filter(x=>x.report.runs_as_root===false).length,unknown=ok.length-rootCount-nonRoot;modal(`

Container identity audit · ${esc(nodeName())}

Containers checked${ok.length}
Root PID 1${rootCount}
Non-root PID 1${nonRoot}
Unknown / failed${unknown+failed.length}
“Root” means the effective UID of PID 1 where Dockwatch could read it. This is a review signal, not proof that the application can safely be converted to non-root.
${ok.map(x=>{const d=x.report;return ``}).join('')}${failed.map((x,i)=>``).join('')}
ContainerUID:GIDAssessmentHost accountBind 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)}
`)}catch(e){toast(e.message)}finally{setBusy(btn,false)}} +function hostAccountSuggestion(name){let n=String(name||'container').toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-+|-+$/g,'');if(!/^[a-z_]/.test(n))n='c-'+n;return('dockwatch-'+n).slice(0,31).replace(/-+$/,'')||'dockwatch-app'} +function identityAssessmentLabel(v){return ({'non-root':'Non-root','root-high-privilege':'Root + privileged','root-review-docker-socket':'Root · Docker socket','root-review-devices':'Root · devices','root-not-obviously-required':'Root · review recommended','unknown':'Unknown'})[v]||v||'Unknown'} +function writeState(v){return v===true?'writable':v===false?'not writable':'unknown'} +async function inspectContainerIdentity(r){ + const id=r.ID||r.Names||r.Name;if(!id)return; + try{ + const d=await api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`),uid=d.effective_uid??'—',gid=d.effective_gid??'—'; + const root=d.runs_as_root===true?'Yes':d.runs_as_root===false?'No':'Unknown',reasons=asArray(d.root_reasons).map(x=>`
  • ${esc(x)}
  • `).join(''),recs=asArray(d.recommendations).map(x=>`
  • ${esc(x)}
  • `).join(''),binds=asArray(d.bind_mounts),host=d.host_access||{}; + window.__identityBindRows=binds.map(x=>({container:id,mount:x})); + const bindTable=binds.length?`
    ${binds.map((x,i)=>``).join('')}
    Host pathContainer pathMode / ownerWrite 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?``:''}
    `:'
    No bind mounts detected.
    '; + const bindHost=d.bind_host_user?`${esc(d.bind_host_user.name)} (${esc(d.bind_host_user.uid)}:${esc(d.bind_host_user.gid)})`:(host.available&&d.bind_uid>0?'No matching host user':'—'); + const canCreate=state.me.role==='admin'&&host.available&&host.management_enabled&&d.host_id_mapping!=='remapped'&&Number.isInteger(d.bind_uid)&&d.bind_uid>0&&!d.bind_host_user; + modal(`

    Identity & bind permissions · ${esc(d.container_name||id)}

    Process identity and storage identity are evaluated separately. PID 1 may run as root while an image writes application data with PUID/PGID. Dockwatch never changes Compose user: automatically.
    PID 1 UID:GID${esc(uid)}:${esc(gid)}
    Bind UID:GID${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}
    Runs as root${esc(root)}
    Assessment${esc(identityAssessmentLabel(d.root_assessment))}

    Why

      ${reasons||'
    • No additional reasons.
    • '}

    Recommendations

      ${recs||'
    • No recommendations.
    • '}

    Host identity

    ${esc(host.message||'Host access unavailable')}
    ${bindHost}
    ${host.configured?`
    Host root: ${esc(host.root||'')} · user creation ${host.management_enabled?'enabled':'off'} · permission repair ${host.permission_management_enabled?'enabled':'off'} · UID mapping ${esc(d.host_id_mapping||'unknown')}
    `:''}
    ${bindTable}
    ${canCreate?'':''}
    `); + $$('[data-bindpreview]').forEach(b=>b.onclick=()=>{const x=window.__identityBindRows[Number(b.dataset.bindpreview)];bindPermissionModal(x.container,x.mount.destination)}); + if(canCreate)$('#createMatchingHostUser').onclick=()=>hostUserModal(id,d); + }catch(e){toast(e.message)} +} +function hostUserModal(containerID,d){const suggested=hostAccountSuggestion(d.container_name);modal(`

    Create matching host account

    Host change: Dockwatch re-checks the container and derives the bind UID/GID server-side. Numeric IDs cannot be chosen by the browser.
    Container${esc(d.container_name)}
    Bind UID:GID${esc(d.bind_uid)}:${esc(d.bind_gid)}
    Source${esc(d.bind_identity_source||'container identity')}
    `);$('#confirmHostUser').onclick=async()=>{if(!confirm(`Create a local account on ${nodeName()} matching bind identity ${d.bind_uid}:${d.bind_gid}?`))return;const btn=$('#confirmHostUser');setBusy(btn,true,'Creating…');try{const out=await api(`/api/host/users${qnode()}`,{method:'POST',body:JSON.stringify({container_id:containerID,username:$('#hostUsername').value.trim(),group_name:$('#hostGroup').value.trim(),create_home:$('#hostHome').checked})});toast(out.message||'Host account created');closeModal();await inspectContainerIdentity({ID:containerID})}catch(e){toast(e.message);setBusy(btn,false)}}} +async function bindPermissionModal(containerID,destination,recursive=false){ + try{const p=await api(`/api/docker/containers/${encodeURIComponent(containerID)}/bind-permissions/preview${qnode()}`,{method:'POST',body:JSON.stringify({destination,recursive})});showBindPermissionPreview(p)}catch(e){toast(e.message)} +} +function showBindPermissionPreview(p){ + const canRepair=state.me.role==='admin'&&p.can_repair&&!p.scan_truncated,owner=`${p.owner_uid??'—'}:${p.owner_gid??'—'}`,expected=`${p.expected_uid??'—'}:${p.expected_gid??'—'}`,canCreateUser=state.me.role==='admin'&&p.host_access?.management_enabled&&p.expected_uid>0&&!p.host_user; + modal(`

    Bind permission review

    ${esc(p.source)} → ${esc(p.destination)}
    ${esc(expected)}
    ${esc(p.identity_source||'')}
    ${esc(owner)} · ${esc(p.mode||'—')}
    ${p.host_user?`${esc(p.host_user.name)}`:'missing'}
    ${p.host_group?`group ${esc(p.host_group.name)}`:'numeric GID only'}
    ${writeState(p.static_writable)}
    ${esc(p.writable_reason||'')}
    ${writeState(p.runtime_writable)}
    ${esc(p.runtime_note||'')}
    ${esc(p.entries_ownership_mismatch||0)} / ${esc(p.entries_scanned||0)} differ
    ${esc(p.files_scanned||0)} files · ${esc(p.directories_scanned||0)} dirs · ${esc(p.symlinks_skipped||0)} symlinks · ${esc(p.cross_filesystem_skipped||0)} nested filesystems skipped
    ${p.acl_detected?'extended ACL detected':'no extended ACL detected'}
    ${esc(p.acl_note||'')}
    ${p.blocked_reason?`Automatic repair unavailable: ${esc(p.blocked_reason)}
    `:''}${esc(p.recommendation||'')}

    Re-scan / repair scope

    Never applied automatically and never recursively. Leave empty to preserve mode bits.
    ${canCreateUser?'':''}${canRepair?'':''}
    `); + $('#rescanBind').onclick=()=>bindPermissionModal(p.container_id,p.destination,$('#bindRecursive').checked); + if(canCreateUser)$('#bindCreateUser').onclick=()=>hostUserModal(p.container_id,{container_name:p.container_name,bind_uid:p.expected_uid,bind_gid:p.expected_gid,bind_identity_source:p.identity_source,bind_host_user:p.host_user}); + if(canRepair)$('#repairBind').onclick=async()=>{const recursive=$('#bindRecursive').checked,fix=$('#bindFixOwner').checked,mode=$('#bindMode').value.trim();if(recursive&&!p.recursive)return toast('Run a recursive re-scan first so the affected file count is known.');const what=recursive?`${p.entries_ownership_mismatch} entries recursively`:'the bind root only';if(!confirm(`Repair ${what} on ${nodeName()} to ${expected}${mode?` and set top-level mode ${mode}`:''}?`))return;const btn=$('#repairBind');setBusy(btn,true,'Repairing…');try{const out=await api(`/api/host/bind-permissions/repair${qnode()}`,{method:'POST',body:JSON.stringify({container_id:p.container_id,destination:p.destination,recursive,fix_ownership:fix,mode})});toast(out.message||'Bind mount repaired');showBindPermissionPreview(out.after);if(state.stack?.name)setTimeout(loadStackPermissions,0)}catch(e){toast(e.message);setBusy(btn,false)}} +} +async function loadStackPermissions(){const box=$('#permissionsBox');if(!box||!state.stack?.name)return;box.innerHTML='
    Inspecting containers and bind mounts…
    ';try{const d=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/bind-permissions${qnode()}`),rows=[];asArray(d.containers).forEach(c=>{if(c.error){rows.push({service:c.service,error:c.error});return}asArray(c.report?.bind_mounts).forEach(m=>rows.push({service:c.service,container:c.container_id,report:c.report,mount:m}))});window.__stackBindRows=rows;if(!rows.length){box.innerHTML='
    No created containers or bind mounts found.
    ';return}box.innerHTML=`
    ${rows.map((x,i)=>x.error?``:``).join('')}
    ServiceHost → containerExpectedOwner / modeWrite
    ${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?``:''}
    `;$$('[data-stackbind]').forEach(b=>b.onclick=()=>{const x=window.__stackBindRows[Number(b.dataset.stackbind)];bindPermissionModal(x.container,x.mount.destination)})}catch(e){box.innerHTML=`
    ${esc(e.message)}
    `}} async function inspectDockerResource(kind,r){const id=kind==='containers'?(r.ID||r.Names||r.Name):kind==='images'?(r.ID||([r.Repository,r.Tag].filter(Boolean).join(':'))):r.Name;if(!id)return;try{const d=await api(`/api/docker/${kind}/${encodeURIComponent(id)}/inspect${qnode()}`),i=d.inspect||{},st=d.stats||{};modal(`

    ${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}

    ${kind==='containers'?`
    CPU${esc(st.CPUPerc||'—')}
    Memory${esc(st.MemUsage||'—')}
    Network I/O${esc(st.NetIO||'—')}
    Block I/O${esc(st.BlockIO||'—')}
    `:''}
    ${esc(JSON.stringify(i,null,2))}
    `)}catch(e){toast(e.message)}} function showOutput(title,text){modal(`

    ${esc(title)}

    ${esc(text||'OK')}
    `)} diff --git a/web/styles.css b/web/styles.css index afb3a7e..0c99fb8 100644 --- a/web/styles.css +++ b/web/styles.css @@ -18,3 +18,5 @@ body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collap @media(max-width:860px){#shell{display:block}.sidebar{transform:translateX(-100%);transition:transform .18s;width:218px;box-shadow:var(--shadow)}body.sidebar-mobile-open .sidebar{transform:translateX(0)}main{grid-column:auto}.topbar{padding-left:50px}.topbar:before{content:"☰";position:absolute;left:14px;font-size:19px;color:var(--muted);cursor:pointer}.splitview{grid-template-columns:1fr}.listpanel{max-height:300px}.twocol{grid-template-columns:1fr}.compose-livegrid{grid-template-columns:1fr!important}} .topLeft{display:flex;align-items:center;gap:8px}.mobileMenu{display:none}@media(max-width:860px){.topbar{padding-left:12px}.topbar:before{display:none}.mobileMenu{display:inline-grid;place-items:center}} .serviceProbeList{max-height:320px;overflow:auto;border:1px solid var(--line);border-radius:7px;padding:6px;background:var(--panel2)}.serviceProbeList .switch{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;padding:7px;border-radius:5px}.serviceProbeList .switch:hover{background:var(--panel3)} +/* v9.2 host/container identity diagnostics */ +.mono{font:11px/1.45 "Cascadia Code","SFMono-Regular",Consolas,monospace}.warn{color:var(--amber)}.compactList{margin:8px 0 0;padding-left:18px;color:var(--muted)}.compactList li{margin:6px 0}.panel.inset{padding:12px;background:var(--panel2);overflow:visible}.panel.inset h3{margin:0;font-size:12px}.dangerNotice{border-color:#592733!important;background:#2b171e!important;color:#f1bcc5!important}.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:7px}.tablewrap .table{min-width:720px}