v9.3.1
release-tag / release-image (push) Failing after 1m15s

This commit is contained in:
2026-08-31 22:17:18 +02:00
parent c6c25d26c3
commit 56170ccde9
19 changed files with 485 additions and 51 deletions
+2 -1
View File
@@ -1,8 +1,9 @@
.git
.gitignore
*.zip
dockwatch
data/
stacks/
.env
.DS_Store
dist/
bin/
+6
View File
@@ -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
+3 -1
View File
@@ -4,4 +4,6 @@ stacks/
*.db
*.db-shm
*.db-wal
dockwatch
bin/
dist/
*.zip
+3 -1
View File
@@ -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
+3 -2
View File
@@ -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
+71 -6
View File
@@ -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`.
+84
View File
@@ -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)
}
}
+3
View File
@@ -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
+8
View File
@@ -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.
@@ -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
@@ -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
@@ -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
+8
View File
@@ -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.
+3 -16
View File
@@ -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
)
+49 -19
View File
@@ -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
+26
View File
@@ -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")
}
}
+141
View File
@@ -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)
+36 -5
View File
@@ -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 `<div class="detailhead"><div class="detailtitle"><span class="cube">▱</span><div><h2>${esc(st.name||'New compose stack')}</h2><small>${st.name?esc(nodeName()):'Draft · not deployed'}</small></div>${badge(st.status||'new')}</div><div class="actions">${st.name?`<button class="btn success" data-act="up">▶ Deploy</button><button class="btn" data-act="pull">↓ Pull</button><button class="btn" data-act="update">↻ Update</button><button class="btn" data-act="restart">⟳ Restart</button><button class="btn" data-act="stop">■ Stop</button>`:''}<button class="btn primary" id="saveStack">Save</button></div></div><div class="tabs"><button class="active" data-tab="compose">Compose</button><button data-tab="env">.env</button><button data-tab="envfiles">Env files</button><button data-tab="secrets">Secrets</button><button data-tab="configs">Configs</button><button data-tab="services">Containers <span class="tag">${sv.length}</span></button><button data-tab="graph">Graph</button><button data-tab="updates">Updates</button><button data-tab="logs">Live logs</button><button data-tab="console">Terminal</button><button data-tab="danger">Danger zone</button></div><div class="tabbody"><div id="tab-compose">${composeTab(st)}</div><div id="tab-env" hidden>${envTab(st)}</div><div id="tab-envfiles" hidden>${managedFilesTab('envfile',st.env_files||[],'Additional env files','Use these from Compose as <code>env_file: ./envs/app.env</code>.')}</div><div id="tab-secrets" hidden>${secretsTab(st)}</div><div id="tab-configs" hidden>${managedFilesTab('config',st.configs||[],'Compose configs','Use these from Compose as <code>configs: ... file: ./configs/name</code>.')}</div><div id="tab-services" hidden>${servicesTab(st)}</div><div id="tab-graph" hidden>${graphTab()}</div><div id="tab-updates" hidden>${updatesTab()}</div><div id="tab-logs" hidden>${logsTab()}</div><div id="tab-console" hidden>${consoleTab(sv)}</div><div id="tab-danger" hidden>${dangerTab(st)}</div><pre id="actionOut" class="terminal" style="min-height:90px;display:none;margin-top:10px"></pre></div>`}
function stackDetailHTML(st){const sv=st.services||[];return `<div class="detailhead"><div class="detailtitle"><span class="cube">▱</span><div><h2>${esc(st.name||'New compose stack')}</h2><small>${st.name?esc(nodeName()):'Draft · not deployed'}</small></div>${badge(st.status||'new')}</div><div class="actions">${st.name?`<button class="btn success" data-act="up">▶ Deploy</button><button class="btn" data-act="pull">↓ Pull</button><button class="btn" data-act="update">↻ Update</button><button class="btn" data-act="restart">⟳ Restart</button><button class="btn" data-act="stop">■ Stop</button>`:''}<button class="btn primary" id="saveStack">Save</button></div></div><div class="tabs"><button class="active" data-tab="compose">Compose</button><button data-tab="env">.env</button><button data-tab="envfiles">Env files</button><button data-tab="secrets">Secrets</button><button data-tab="configs">Configs</button><button data-tab="services">Containers <span class="tag">${sv.length}</span></button><button data-tab="permissions">Permissions</button><button data-tab="graph">Graph</button><button data-tab="updates">Updates</button><button data-tab="logs">Live logs</button><button data-tab="console">Terminal</button><button data-tab="danger">Danger zone</button></div><div class="tabbody"><div id="tab-compose">${composeTab(st)}</div><div id="tab-env" hidden>${envTab(st)}</div><div id="tab-envfiles" hidden>${managedFilesTab('envfile',st.env_files||[],'Additional env files','Use these from Compose as <code>env_file: ./envs/app.env</code>.')}</div><div id="tab-secrets" hidden>${secretsTab(st)}</div><div id="tab-configs" hidden>${managedFilesTab('config',st.configs||[],'Compose configs','Use these from Compose as <code>configs: ... file: ./configs/name</code>.')}</div><div id="tab-services" hidden>${servicesTab(st)}</div><div id="tab-permissions" hidden>${permissionsTab()}</div><div id="tab-graph" hidden>${graphTab()}</div><div id="tab-updates" hidden>${updatesTab()}</div><div id="tab-logs" hidden>${logsTab()}</div><div id="tab-console" hidden>${consoleTab(sv)}</div><div id="tab-danger" hidden>${dangerTab(st)}</div><pre id="actionOut" class="terminal" style="min-height:90px;display:none;margin-top:10px"></pre></div>`}
function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'<div class="draftnotice"><span>A local unsaved draft exists for this stack.</span><span><button class="btn tiny" id="restoreDraft">Restore</button> <button class="btn tiny" id="discardDraft">Discard</button></span></div>':''}<div class="field" style="margin-bottom:8px"><label>Stack name</label><input id="stackName" value="${esc(st.name)}" ${st.name?'disabled':''} placeholder="my-stack"></div><div class="compose-livebar"><div><b>Full Compose Designer</b><span id="composeSyncState" class="muted"> parsing…</span></div><div class="actions"><button class="btn tiny" id="composeExpandAll">Expand all</button><button class="btn tiny" id="composeCollapseAll">Collapse all</button></div></div><div class="compose-livegrid compose-fullgrid"><div class="codebox"><div class="editorlabel">compose.yaml <span class="muted">Source of truth</span></div><textarea id="composeText" spellcheck="false">${esc(st.compose||'')}</textarea></div><div class="visualcompose"><div class="editorlabel">Visual editor <span class="muted">all fields · AST patch mode</span></div><div class="compose-sections" id="composeSections"></div><div id="composeVisual"><div class="empty">Parsing Compose…</div></div></div></div><div class="compose-help"><span>Every present Compose value is editable as string, number, boolean, null, map or array.</span><span>Current spec fields are suggested; x-* and future fields remain editable too.</span><span>Invalid YAML pauses visual sync without replacing your source.</span></div>`}
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'<div class="empty">This stack has no running containers yet.</div>';return `<div class="servicecards">${sv.map(v=>`<div class="servicecard"><div class="itemtop"><b>${esc(v.service||v.name)}</b>${badge(v.state||v.status)}</div><div class="svcmeta"><span class="muted">Image</span><span>${esc(v.image||'—')}</span><span class="muted">Ports</span><span>${esc(v.ports||'—')}</span><span class="muted">Command</span><span>${esc(v.command||'—')}</span></div></div>`).join('')}</div>`}
function logsTab(){return `<div class="consolebar"><button class="btn" id="loadLogs">Load 500</button><button class="btn success" id="liveLogs">▶ Follow</button><button class="btn" id="pauseLogs">Ⅱ Pause</button><button class="btn danger" id="stopLogs">■ Stop</button><input id="logFilter" placeholder="Filter visible logs…"><span class="spacer"></span><label class="switch"><input id="logAutoScroll" type="checkbox" checked> Auto-scroll</label><button class="btn" id="downloadLogs">Download</button><span id="logState" class="logstate">idle</span></div><pre id="logBox" class="terminal">Select “Load” or “Follow”.</pre>`}
function graphTab(){return `<div class="consolebar"><button class="btn primary" id="loadGraph">↻ Build dependency graph</button></div><div id="graphBox" class="graphbox"><div class="empty">Load the normalized Compose dependency graph.</div></div>`}
function permissionsTab(){return `<div class="consolebar"><button class="btn primary" id="loadPermissions">Analyze bind mounts</button><span class="muted">Checks expected application UID/GID, host ownership, mode bits and ACL hints.</span></div><div id="permissionsBox"><div class="empty">Run the analysis to review writable bind mounts for every created service.</div></div>`}
function updatesTab(){return `<div class="consolebar"><button class="btn primary" id="checkUpdates">↻ Check registry updates</button></div><div id="updateBox"><div class="empty">Compare installed image digests with registry manifests.</div></div>`}
function consoleTab(sv){return `<div class="notice" style="margin-bottom:8px">Interactive Docker Exec terminal backed by a real PTY/WebSocket session. Click the terminal and type normally.</div><div class="consolebar"><select id="execService">${sv.map(v=>`<option>${esc(v.service||v.name)}</option>`).join('')}</select><select id="execShell"><option value="sh">sh</option><option value="bash">bash</option><option value="ash">ash</option></select><button class="btn primary" id="openTerminal">Connect</button><button class="btn danger" id="closeTerminal">Disconnect</button></div><div id="execOut" class="terminal interactive" tabindex="0">Terminal disconnected.
</div>`}
function dangerTab(st){return st.name?`<div class="notice"><b>Safe delete is the default.</b> It removes only compose.yaml, .env and Dockwatch-managed secrets/env/config folders. Unrelated bind-mount data beside the stack is preserved.</div><div class="toolbar" style="margin-top:10px"><button class="btn danger" data-act="down">Compose down</button><button class="btn" data-act="recreate">Force recreate</button><button class="btn danger" id="deleteStack">Delete definition</button><button class="btn danger" id="purgeStack">Purge entire folder…</button></div>`:'<div class="empty">Save the stack first.</div>'}
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=`<tr><td><d
function nodeModal(x=null){modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} remote agent</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nodeName" value="${esc(x?.name||'')}" placeholder="server-2"></div><div class="field full"><label>Agent base URL</label><input id="nodeURL" value="${esc(x?.base_url||'')}" placeholder="https://server-2.example.com"></div><div class="field full"><label>Agent token ${x?'<span class="muted">(leer lassen = beibehalten)</span>':''}</label><input id="nodeToken" type="password"></div>${x?`<label class="switch"><input id="nodeEnabled" type="checkbox" ${x.enabled?'checked':''}> Agent enabled</label>`:''}</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveNode">${x?'Save':'Add environment'}</button></div>`);$('#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')}<div class="healthDetail">v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}</div>`}catch(e){el.innerHTML=`${badge('down')}<div class="healthDetail" title="${esc(e.message)}">unreachable</div>`}}))}
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='<button class="btn primary" id="resourceCreate">↓ Pull image</button><button class="btn" id="registryLogin">Registry login</button><button class="btn" id="registryLogout">Logout</button>';if(k==='volumes')create='<button class="btn primary" id="resourceCreate">+ Create volume</button>';if(k==='networks')create='<button class="btn primary" id="resourceCreate">+ Create network</button>'}const prune=roleOK()&&k!=='containers'?'<button class="btn danger" id="resourcePrune">Prune unused</button>':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}<button class="btn" id="invRefresh">↻ Refresh</button>`)}<div class="panel"><div class="resourcebar"><input id="inventorySearch" placeholder="Filter ${n.toLowerCase()}…"><span id="inventoryCount" class="muted"></span></div><div id="inventoryPanel"><div class="empty">Loading ${n.toLowerCase()}…</div></div></div>`;$('#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='<button class="btn" id="identityAudit">Identity audit</button>';if(k==='images')create='<button class="btn primary" id="resourceCreate">↓ Pull image</button><button class="btn" id="registryLogin">Registry login</button><button class="btn" id="registryLogout">Logout</button>';if(k==='volumes')create='<button class="btn primary" id="resourceCreate">+ Create volume</button>';if(k==='networks')create='<button class="btn primary" id="resourceCreate">+ Create network</button>'}const prune=roleOK()&&k!=='containers'?'<button class="btn danger" id="resourcePrune">Prune unused</button>':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}<button class="btn" id="invRefresh">↻ Refresh</button>`)}<div class="panel"><div class="resourcebar"><input id="inventorySearch" placeholder="Filter ${n.toLowerCase()}…"><span id="inventoryCount" class="muted"></span></div><div id="inventoryPanel"><div class="empty">Loading ${n.toLowerCase()}…</div></div></div>`;$('#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=`<div class="empty red">${esc(e.message)}</div>`}}
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='<div class="empty">No matching items found.</div>';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=`<table class="table"><thead><tr>${cols.map(c=>`<th>${esc(c[1])}</th>`).join('')}<th class="right">Actions</th></tr></thead><tbody>${rows.map(r=>{const idx=all.indexOf(r);return `<tr>${cols.map((c,i)=>`<td class="${i?'muted':''}">${i===0?`<div class="namecell"><span class="cube">⬡</span><b>${esc(r[c[0]]||'—')}</b></div>`:esc(r[c[0]]||'—')}</td>`).join('')}<td class="right">${resourceActions(kind,r,idx)}</td></tr>`}).join('')}</tbody></table>`;wireResourceRows(kind)}
function resourceActions(kind,r,idx){const inspect=roleOK()?`<button class="btn tiny" data-inspect="${idx}">Inspect</button>`:'';if(kind==='containers')return `${inspect}${roleOK()?` <button class="btn tiny" data-ract="start" data-row="${idx}">Start</button> <button class="btn tiny" data-ract="restart" data-row="${idx}">Restart</button> <button class="btn tiny" data-ract="stop" data-row="${idx}">Stop</button> <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`:''}`;if(!roleOK())return'';return `${inspect} <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`}
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()?`<button class="btn tiny" data-inspect="${idx}">Inspect</button>`:'';if(kind==='containers')return `${inspect}${roleOK()?` <button class="btn tiny" data-identity="${idx}">Identity</button> <button class="btn tiny" data-ract="start" data-row="${idx}">Start</button> <button class="btn tiny" data-ract="restart" data-row="${idx}">Restart</button> <button class="btn tiny" data-ract="stop" data-row="${idx}">Stop</button> <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`:''}`;if(!roleOK())return'';return `${inspect} <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`}
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(`<div class="modalhead"><h2>Registry login</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div><div class="field"><label>Username</label><input id="regUser"></div><div class="field"><label>Password / token</label><input id="regPass" type="password"></div></div><div class="notice" style="margin-top:10px">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.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="regSave">Login</button></div>`);$('#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(`<div class="modalhead"><h2>Registry logout</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn danger" id="regSave">Logout</button></div>`);$('#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(`<div class="modalhead"><h2>Pull image</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Image reference</label><input id="resName" placeholder="nginx:alpine"></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="resSave">Pull</button></div>`);$('#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(`<div class="modalhead"><h2>Create ${isNet?'network':'volume'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="resName" placeholder="${isNet?'frontend':'app-data'}"></div><div class="field"><label>Driver</label><input id="resDriver" placeholder="${isNet?'bridge':'local'}"></div>${isNet?'<label class="switch"><input id="resInternal" type="checkbox"> Internal network</label><label class="switch"><input id="resAttachable" type="checkbox"> Attachable</label>':''}<div class="field full"><label>Labels (key=value, one per line)</label><textarea id="resLabels" spellcheck="false"></textarea></div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="resSave">Create</button></div>`);$('#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<rows.length;i+=4){const batch=rows.slice(i,i+4);results.push(...await Promise.allSettled(batch.map(r=>{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(`<div class="modalhead"><h2>Container identity audit · ${esc(nodeName())}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="stats"><div class="stat"><small>Containers checked</small><strong>${ok.length}</strong></div><div class="stat"><small>Root PID 1</small><strong class="${rootCount?'amber':''}">${rootCount}</strong></div><div class="stat"><small>Non-root PID 1</small><strong class="green">${nonRoot}</strong></div><div class="stat"><small>Unknown / failed</small><strong>${unknown+failed.length}</strong></div></div><div class="notice" style="margin-top:12px">“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.</div><div class="tablewrap" style="margin-top:12px"><table class="table"><thead><tr><th>Container</th><th>UID:GID</th><th>Assessment</th><th>Host account</th><th>Bind mounts</th></tr></thead><tbody>${ok.map(x=>{const d=x.report;return `<tr><td><b>${esc(d.container_name||x.row.Names||x.row.ID)}</b><div class="muted">${esc(d.image||'')}</div></td><td class="mono"><div>PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')}</div><div class="muted">bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</div></td><td>${esc(identityAssessmentLabel(d.root_assessment))}</td><td>${d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)}</span>`:(d.host_access?.available&&d.bind_uid>0?'<span class="warn">missing</span>':'—')}</td><td>${asArray(d.bind_mounts).length}</td></tr>`}).join('')}${failed.map((x,i)=>`<tr><td colspan="5" class="red">Check ${i+1} failed: ${esc(x.reason?.message||x.reason)}</td></tr>`).join('')}</tbody></table></div></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}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?'<span class="green">writable</span>':v===false?'<span class="red">not writable</span>':'<span class="muted">unknown</span>'}
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=>`<li>${esc(x)}</li>`).join(''),recs=asArray(d.recommendations).map(x=>`<li>${esc(x)}</li>`).join(''),binds=asArray(d.bind_mounts),host=d.host_access||{};
window.__identityBindRows=binds.map(x=>({container:id,mount:x}));
const bindTable=binds.length?`<div class="field"><label>Bind mount permissions · expected ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')} (${esc(d.bind_identity_source||'unknown')})</label><div class="tablewrap"><table class="table"><thead><tr><th>Host path</th><th>Container path</th><th>Mode / owner</th><th>Write access</th><th></th></tr></thead><tbody>${binds.map((x,i)=>`<tr><td class="mono">${esc(x.source)}</td><td class="mono">${esc(x.destination)} ${x.read_only?'<span class="tag">ro</span>':'<span class="tag">rw</span>'}</td><td><code>${esc(x.mode||'—')}</code> · ${x.owner_uid===undefined?'—':`${esc(x.owner_user||x.owner_uid)} (${esc(x.owner_uid)}:${esc(x.owner_gid)})`}${x.acl_detected?'<div class="warn">ACL detected</div>':''}</td><td>${x.read_only?'<span class="muted">read-only</span>':writeState(x.static_writable)}<div class="muted">${esc(x.writable_reason||x.ownership_note||'')}</div></td><td class="right">${!x.read_only?`<button class="btn tiny" data-bindpreview="${i}">Analyze / repair</button>`:''}</td></tr>`).join('')}</tbody></table></div></div>`:'<div class="notice">No bind mounts detected.</div>';
const bindHost=d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)} (${esc(d.bind_host_user.uid)}:${esc(d.bind_host_user.gid)})</span>`:(host.available&&d.bind_uid>0?'<span class="warn">No matching host user</span>':'—');
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(`<div class="modalhead"><h2>Identity & bind permissions · ${esc(d.container_name||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Process identity and storage identity are evaluated separately.</b> PID 1 may run as root while an image writes application data with PUID/PGID. Dockwatch never changes Compose <code>user:</code> automatically.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>PID 1 UID:GID</small><strong>${esc(uid)}:${esc(gid)}</strong></div><div class="stat"><small>Bind UID:GID</small><strong>${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</strong></div><div class="stat"><small>Runs as root</small><strong>${esc(root)}</strong></div><div class="stat"><small>Assessment</small><strong>${esc(identityAssessmentLabel(d.root_assessment))}</strong></div></div><div class="twocol" style="margin-top:12px"><div class="panel inset"><h3>Why</h3><ul class="compactList">${reasons||'<li>No additional reasons.</li>'}</ul></div><div class="panel inset"><h3>Recommendations</h3><ul class="compactList">${recs||'<li>No recommendations.</li>'}</ul></div></div><div class="panel inset" style="margin-top:12px"><div class="row"><div><h3>Host identity</h3><div class="muted">${esc(host.message||'Host access unavailable')}</div></div><div>${bindHost}</div></div>${host.configured?`<div class="meta" style="margin-top:8px">Host root: <code>${esc(host.root||'')}</code> · user creation ${host.management_enabled?'enabled':'off'} · permission repair ${host.permission_management_enabled?'enabled':'off'} · UID mapping ${esc(d.host_id_mapping||'unknown')}</div>`:''}</div>${bindTable}</div><div class="modalfoot">${canCreate?'<button class="btn" id="createMatchingHostUser">Create matching host account…</button>':''}<button class="btn" data-close>Close</button></div>`);
$$('[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(`<div class="modalhead"><h2>Create matching host account</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Host change:</b> Dockwatch re-checks the container and derives the bind UID/GID server-side. Numeric IDs cannot be chosen by the browser.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>Container</small><strong>${esc(d.container_name)}</strong></div><div class="stat"><small>Bind UID:GID</small><strong>${esc(d.bind_uid)}:${esc(d.bind_gid)}</strong></div><div class="stat"><small>Source</small><strong>${esc(d.bind_identity_source||'container identity')}</strong></div></div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Host username</label><input id="hostUsername" value="${esc(suggested)}"></div><div class="field"><label>Host group name</label><input id="hostGroup" value="${esc(suggested)}"></div><label class="switch"><input id="hostHome" type="checkbox"> Create home directory</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn danger" id="confirmHostUser">Create on host</button></div>`);$('#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(`<div class="modalhead"><h2>Bind permission review</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Mount</label><div class="mono">${esc(p.source)} → ${esc(p.destination)}</div></div><div class="field"><label>Expected identity</label><b>${esc(expected)}</b><div class="muted">${esc(p.identity_source||'')}</div></div><div class="field"><label>Current owner / mode</label><b>${esc(owner)} · ${esc(p.mode||'—')}</b></div><div class="field"><label>Host account for expected UID</label>${p.host_user?`<span class="green">${esc(p.host_user.name)}</span>`:'<span class="warn">missing</span>'}<div class="muted">${p.host_group?`group ${esc(p.host_group.name)}`:'numeric GID only'}</div></div><div class="field"><label>Static write check</label>${writeState(p.static_writable)}<div class="muted">${esc(p.writable_reason||'')}</div></div><div class="field"><label>Runtime write check</label>${writeState(p.runtime_writable)}<div class="muted">${esc(p.runtime_note||'')}</div></div><div class="field"><label>Ownership scan</label><b>${esc(p.entries_ownership_mismatch||0)} / ${esc(p.entries_scanned||0)} differ</b><div class="muted">${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</div></div><div class="field"><label>ACL</label>${p.acl_detected?'<span class="warn">extended ACL detected</span>':'<span class="green">no extended ACL detected</span>'}<div class="muted">${esc(p.acl_note||'')}</div></div></div><div class="notice ${p.blocked_reason?'dangerNotice':''}" style="margin-top:12px">${p.blocked_reason?`<b>Automatic repair unavailable:</b> ${esc(p.blocked_reason)}<br>`:''}${esc(p.recommendation||'')}</div><div class="panel inset" style="margin-top:12px"><h3>Re-scan / repair scope</h3><label class="switch"><input id="bindRecursive" type="checkbox" ${p.recursive?'checked':''}> Recursively inspect/chown files below this bind root</label><div class="field" style="margin-top:10px"><label>Optional top-level chmod</label><input id="bindMode" placeholder="e.g. 750 or 775"><div class="muted">Never applied automatically and never recursively. Leave empty to preserve mode bits.</div></div><label class="switch"><input id="bindFixOwner" type="checkbox" checked> Fix owner/group to ${esc(expected)}</label></div></div><div class="modalfoot"><button class="btn" id="rescanBind">Re-scan</button>${canCreateUser?'<button class="btn" id="bindCreateUser">Create host account…</button>':''}${canRepair?'<button class="btn danger" id="repairBind">Repair & verify</button>':''}<button class="btn" data-close>Close</button></div>`);
$('#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='<div class="empty">Inspecting containers and bind mounts…</div>';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='<div class="empty">No created containers or bind mounts found.</div>';return}box.innerHTML=`<div class="tablewrap"><table class="table"><thead><tr><th>Service</th><th>Host → container</th><th>Expected</th><th>Owner / mode</th><th>Write</th><th></th></tr></thead><tbody>${rows.map((x,i)=>x.error?`<tr><td><b>${esc(x.service)}</b></td><td colspan="5" class="red">${esc(x.error)}</td></tr>`:`<tr><td><b>${esc(x.service||x.report?.container_name)}</b></td><td><div class="mono">${esc(x.mount.source)}</div><div class="muted mono">→ ${esc(x.mount.destination)} ${x.mount.read_only?'(ro)':'(rw)'}</div></td><td class="mono">${esc(x.report.bind_uid??'—')}:${esc(x.report.bind_gid??'—')}<div class="muted">${esc(x.report.bind_identity_source||'')}</div></td><td class="mono">${esc(x.mount.owner_uid??'—')}:${esc(x.mount.owner_gid??'—')} · ${esc(x.mount.mode||'—')}</td><td>${x.mount.read_only?'<span class="muted">read-only</span>':writeState(x.mount.static_writable)}</td><td class="right">${!x.mount.read_only?`<button class="btn tiny" data-stackbind="${i}">Analyze / repair</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$$('[data-stackbind]').forEach(b=>b.onclick=()=>{const x=window.__stackBindRows[Number(b.dataset.stackbind)];bindPermissionModal(x.container,x.mount.destination)})}catch(e){box.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
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(`<div class="modalhead"><h2>${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${kind==='containers'?`<div class="stats"><div class="stat"><small>CPU</small><strong>${esc(st.CPUPerc||'—')}</strong></div><div class="stat"><small>Memory</small><strong>${esc(st.MemUsage||'—')}</strong></div><div class="stat"><small>Network I/O</small><strong>${esc(st.NetIO||'—')}</strong></div><div class="stat"><small>Block I/O</small><strong>${esc(st.BlockIO||'—')}</strong></div></div>`:''}<div class="field"><label>Inspect JSON <span class="muted">(operator/admin only; may contain sensitive environment values)</span></label><pre class="terminal" style="max-height:55vh">${esc(JSON.stringify(i,null,2))}</pre></div></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}catch(e){toast(e.message)}}
function showOutput(title,text){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><pre class="terminal" style="max-height:60vh">${esc(text||'OK')}</pre></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}
+2
View File
@@ -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}