diff --git a/.dockerignore b/.dockerignore index 9cb167f..b4adc45 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,9 @@ .git .gitignore *.zip -data/ -stacks/ -.env +/data/ +/stacks/ +/.env .DS_Store -dist/ -bin/ +/dist/ +/bin/ diff --git a/.gitignore b/.gitignore index 97e882c..e9b4d5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ -.env -data/ -stacks/ -*.db -*.db-shm -*.db-wal -bin/ -dist/ +/.env +/data/ +/stacks/ +/*.db +/*.db-shm +/*.db-wal +/bin/ +/dist/ *.zip diff --git a/Dockerfile b/Dockerfile index 962920d..c1e375b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ 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 test -f ./cmd/dockwatch/main.go && test -f ./internal/stacks/stacks.go && test -f ./web/embed.go || \ + (echo "ERROR: required source files are missing from Docker build context; check .dockerignore" >&2; exit 1) # Keep the module graph in sync with the actual source tree. This is required for # Go 1.17+ module graph pruning when transitive dependencies must be recorded as # indirect requirements in go.mod. The project intentionally has no vendored deps. diff --git a/README.md b/README.md index ab3c93b..299a055 100644 --- a/README.md +++ b/README.md @@ -410,3 +410,7 @@ CI can use `make verify` to fail when `go.mod`/`go.sum` are not committed in tid ## v9.3.2 module-build fix v9.3.2 fixes Docker/CI builds that stopped at `go: updates to go.mod needed; to update it: go mod tidy`. The builder now runs `go mod tidy` after the complete source tree has been copied and before `go build`, so indirect requirements required by Go module graph pruning are materialized in the build stage. The Makefile also includes `tidy` and `verify` targets for maintaining committed `go.mod`/`go.sum` files. + +### Build-context note (v9.3.3) + +Runtime directories in `.dockerignore` and `.gitignore` are root-anchored (`/stacks/`, `/data/`, `/bin/`, `/dist/`). This is intentional: unanchored patterns such as `stacks/` also match the source package `internal/stacks/` and can make Go try to resolve the project's own internal package as a remote module during `go mod tidy`. diff --git a/internal/stacks/identity.go b/internal/stacks/identity.go new file mode 100644 index 0000000..c7e728a --- /dev/null +++ b/internal/stacks/identity.go @@ -0,0 +1,1405 @@ +package stacks + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "strconv" + "strings" + "time" +) + +var validHostAccountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}\$?$`) + +type HostAccount struct { + Name string `json:"name"` + UID int `json:"uid"` + GID int `json:"gid"` + Home string `json:"home,omitempty"` + Shell string `json:"shell,omitempty"` +} + +type HostGroup struct { + Name string `json:"name"` + GID int `json:"gid"` +} + +type HostAccessStatus struct { + Configured bool `json:"configured"` + Available bool `json:"available"` + ManagementEnabled bool `json:"management_enabled"` + PermissionManagementEnabled bool `json:"permission_management_enabled"` + Root string `json:"root,omitempty"` + Message string `json:"message,omitempty"` +} + +type BindMountIdentity struct { + Source string `json:"source"` + Destination string `json:"destination"` + ReadOnly bool `json:"read_only"` + OwnerUID *int `json:"owner_uid,omitempty"` + OwnerGID *int `json:"owner_gid,omitempty"` + OwnerUser string `json:"owner_user,omitempty"` + OwnerGroup string `json:"owner_group,omitempty"` + MatchesUID *bool `json:"matches_uid,omitempty"` + MatchesGID *bool `json:"matches_gid,omitempty"` + OwnershipNote string `json:"ownership_note,omitempty"` + Mode string `json:"mode,omitempty"` + StaticWritable *bool `json:"static_writable,omitempty"` + WritableReason string `json:"writable_reason,omitempty"` + RuntimeWritable *bool `json:"runtime_writable,omitempty"` + RuntimeNote string `json:"runtime_note,omitempty"` + ACLDetected bool `json:"acl_detected,omitempty"` + ACLNote string `json:"acl_note,omitempty"` +} + +type ContainerIdentityReport struct { + ContainerID string `json:"container_id"` + ContainerName string `json:"container_name"` + Image string `json:"image"` + Running bool `json:"running"` + ConfiguredUser string `json:"configured_user"` + EffectiveUID *int `json:"effective_uid,omitempty"` + EffectiveGID *int `json:"effective_gid,omitempty"` + EffectiveGroups []int `json:"effective_groups,omitempty"` + BindUID *int `json:"bind_uid,omitempty"` + BindGID *int `json:"bind_gid,omitempty"` + BindIdentitySource string `json:"bind_identity_source,omitempty"` + BindHostUser *HostAccount `json:"bind_host_user,omitempty"` + BindHostGroup *HostGroup `json:"bind_host_group,omitempty"` + IdentitySource string `json:"identity_source"` + RunsAsRoot *bool `json:"runs_as_root,omitempty"` + RootAssessment string `json:"root_assessment"` + RootReasons []string `json:"root_reasons"` + Recommendations []string `json:"recommendations"` + Privileged bool `json:"privileged"` + DockerSocketMounted bool `json:"docker_socket_mounted"` + AddedCapabilities []string `json:"added_capabilities"` + DeviceCount int `json:"device_count"` + HostAccess HostAccessStatus `json:"host_access"` + HostIDMapping string `json:"host_id_mapping"` + HostUser *HostAccount `json:"host_user,omitempty"` + HostGroup *HostGroup `json:"host_group,omitempty"` + BindMounts []BindMountIdentity `json:"bind_mounts"` +} + +type CreateHostUserInput struct { + ContainerID string `json:"container_id"` + Username string `json:"username"` + GroupName string `json:"group_name"` + CreateHome bool `json:"create_home"` +} + +type CreateHostUserResult struct { + CreatedUser bool `json:"created_user"` + CreatedGroup bool `json:"created_group"` + User HostAccount `json:"user"` + Group HostGroup `json:"group"` + Message string `json:"message"` +} + +func mapObj(v any) map[string]any { + m, _ := v.(map[string]any) + return m +} + +func boolVal(v any) bool { + b, _ := v.(bool) + return b +} + +func stringVal(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprint(v) +} + +func stringSlice(v any) []string { + a, _ := v.([]any) + out := make([]string, 0, len(a)) + for _, x := range a { + if s := strings.TrimSpace(stringVal(x)); s != "" { + out = append(out, s) + } + } + return out +} + +func parseNumericUserSpec(spec string) (uid, gid *int) { + spec = strings.TrimSpace(spec) + if spec == "" { + u, g := 0, 0 + return &u, &g + } + parts := strings.SplitN(spec, ":", 2) + if n, err := strconv.Atoi(parts[0]); err == nil && n >= 0 { + u := n + uid = &u + } + if len(parts) == 2 { + if n, err := strconv.Atoi(parts[1]); err == nil && n >= 0 { + g := n + gid = &g + } + } + if strings.EqualFold(parts[0], "root") { + u := 0 + uid = &u + } + if len(parts) == 2 && strings.EqualFold(parts[1], "root") { + g := 0 + gid = &g + } + return uid, gid +} + +func dockerProc1Identity(ctx context.Context, id string) (uid, gid *int, groups []int, err error) { + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, e := exec.CommandContext(cctx, "docker", "exec", id, "cat", "/proc/1/status").CombinedOutput() + if e != nil { + return nil, nil, nil, fmt.Errorf("read container PID 1 status: %s", fallbackOutput(out, e)) + } + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + switch strings.TrimSuffix(fields[0], ":") { + case "Uid": + if len(fields) < 3 { + continue + } + n, e := strconv.Atoi(fields[2]) // effective UID + if e == nil && n >= 0 { + v := n + uid = &v + } + case "Gid": + if len(fields) < 3 { + continue + } + n, e := strconv.Atoi(fields[2]) // effective GID + if e == nil && n >= 0 { + v := n + gid = &v + } + case "Groups": + for _, f := range fields[1:] { + if n, e := strconv.Atoi(f); e == nil && n >= 0 { + groups = append(groups, n) + } + } + } + } + if uid == nil || gid == nil { + return nil, nil, groups, errors.New("PID 1 status did not contain numeric effective UID/GID") + } + return uid, gid, groups, nil +} + +func dockerExecID(ctx context.Context, id, flag string) (*int, error) { + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := exec.CommandContext(cctx, "docker", "exec", id, "id", flag).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("docker exec id %s: %s", flag, fallbackOutput(out, err)) + } + n, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil || n < 0 { + return nil, errors.New("container returned an invalid numeric identity") + } + return &n, nil +} + +func (s *Service) hostAccessStatus() HostAccessStatus { + root := strings.TrimSpace(s.hostRoot) + st := HostAccessStatus{Configured: root != "", ManagementEnabled: s.allowHostUserManagement, PermissionManagementEnabled: s.allowHostPermissionManagement, Root: root} + if root == "" { + st.Message = "HOST_ROOT is not configured; host account and bind-mount ownership lookup is unavailable" + return st + } + fi, err := os.Stat(filepath.Join(root, "etc", "passwd")) + if err != nil || fi.IsDir() { + st.Message = "configured HOST_ROOT does not expose a readable /etc/passwd" + return st + } + st.Available = true + switch { + case s.allowHostUserManagement && s.allowHostPermissionManagement: + st.Message = "host identity inspection, explicitly approved user creation and bind-mount permission repair are enabled" + case s.allowHostUserManagement: + st.Message = "host identity inspection and explicitly approved user creation are enabled; bind-mount permission repair is disabled" + case s.allowHostPermissionManagement: + st.Message = "host identity inspection and explicitly approved bind-mount permission repair are enabled; user creation is disabled" + default: + st.Message = "host identity inspection is read-only; host mutations are disabled" + } + return st +} + +func readHostAccounts(root string) ([]HostAccount, []HostGroup, error) { + pf, err := os.Open(filepath.Join(root, "etc", "passwd")) + if err != nil { + return nil, nil, err + } + defer pf.Close() + users := []HostAccount{} + sc := bufio.NewScanner(pf) + for sc.Scan() { + line := sc.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + p := strings.Split(line, ":") + if len(p) < 7 { + continue + } + uid, e1 := strconv.Atoi(p[2]) + gid, e2 := strconv.Atoi(p[3]) + if e1 != nil || e2 != nil { + continue + } + users = append(users, HostAccount{Name: p[0], UID: uid, GID: gid, Home: p[5], Shell: p[6]}) + } + if err := sc.Err(); err != nil { + return nil, nil, err + } + gf, err := os.Open(filepath.Join(root, "etc", "group")) + if err != nil { + return nil, nil, err + } + defer gf.Close() + groups := []HostGroup{} + sc = bufio.NewScanner(gf) + for sc.Scan() { + p := strings.Split(sc.Text(), ":") + if len(p) < 3 { + continue + } + gid, err := strconv.Atoi(p[2]) + if err == nil { + groups = append(groups, HostGroup{Name: p[0], GID: gid}) + } + } + return users, groups, sc.Err() +} + +func accountByUID(users []HostAccount, uid int) *HostAccount { + for i := range users { + if users[i].UID == uid { + u := users[i] + return &u + } + } + return nil +} +func accountByName(users []HostAccount, name string) *HostAccount { + for i := range users { + if users[i].Name == name { + u := users[i] + return &u + } + } + return nil +} +func groupByGID(groups []HostGroup, gid int) *HostGroup { + for i := range groups { + if groups[i].GID == gid { + g := groups[i] + return &g + } + } + return nil +} +func groupByName(groups []HostGroup, name string) *HostGroup { + for i := range groups { + if groups[i].Name == name { + g := groups[i] + return &g + } + } + return nil +} + +func numericEnvPair(cfg map[string]any) (uid, gid *int, source string) { + vals := map[string]int{} + if envs, ok := cfg["Env"].([]any); ok { + for _, raw := range envs { + part := strings.SplitN(stringVal(raw), "=", 2) + if len(part) != 2 { + continue + } + n, err := strconv.Atoi(strings.TrimSpace(part[1])) + if err == nil && n >= 0 { + vals[strings.ToUpper(strings.TrimSpace(part[0]))] = n + } + } + } + for _, pair := range [][3]string{{"PUID", "PGID", "PUID/PGID"}, {"USER_ID", "GROUP_ID", "USER_ID/GROUP_ID"}} { + u, uok := vals[pair[0]] + g, gok := vals[pair[1]] + if uok && gok { + uv, gv := u, g + return &uv, &gv, pair[2] + " environment" + } + } + return nil, nil, "" +} + +func fileOwnerIDs(fi os.FileInfo) (*int, *int) { + if fi == nil || fi.Sys() == nil { + return nil, nil + } + v := reflect.ValueOf(fi.Sys()) + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return nil, nil + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil, nil + } + read := func(name string) *int { + f := v.FieldByName(name) + if !f.IsValid() { + return nil + } + var n uint64 + switch f.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + n = f.Uint() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + x := f.Int() + if x < 0 { + return nil + } + n = uint64(x) + default: + return nil + } + if n > uint64(^uint(0)>>1) { + return nil + } + x := int(n) + return &x + } + return read("Uid"), read("Gid") +} + +func fileDeviceID(fi os.FileInfo) *uint64 { + if fi == nil || fi.Sys() == nil { + return nil + } + v := reflect.ValueOf(fi.Sys()) + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return nil + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil + } + f := v.FieldByName("Dev") + if !f.IsValid() { + return nil + } + var n uint64 + switch f.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + n = f.Uint() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + x := f.Int() + if x < 0 { + return nil + } + n = uint64(x) + default: + return nil + } + return &n +} + +func staticWriteAccess(fi os.FileInfo, ownerUID, ownerGID, uid, gid *int, groups []int) (*bool, string) { + if fi == nil || uid == nil || gid == nil || ownerUID == nil || ownerGID == nil { + return nil, "insufficient numeric identity or ownership information" + } + if *uid == 0 { + v := true + return &v, "UID 0 normally bypasses discretionary write bits; read-only mounts and LSM policies can still deny writes" + } + perm := fi.Mode().Perm() + need := func(writeBit, execBit os.FileMode) (bool, string) { + if fi.IsDir() { + ok := perm&writeBit != 0 && perm&execBit != 0 + return ok, map[bool]string{true: "directory write+execute bits are set", false: "directory requires both write and execute permission"}[ok] + } + ok := perm&writeBit != 0 + return ok, map[bool]string{true: "file write bit is set", false: "file write bit is not set"}[ok] + } + if *uid == *ownerUID { + v, reason := need(0200, 0100) + return &v, "owner class: " + reason + } + groupMatch := *gid == *ownerGID + if !groupMatch { + for _, g := range groups { + if g == *ownerGID { + groupMatch = true + break + } + } + } + if groupMatch { + v, reason := need(0020, 0010) + return &v, "group class: " + reason + } + v, reason := need(0002, 0001) + return &v, "other class: " + reason +} + +func aclInfo(ctx context.Context, path string) (bool, string) { + tool, err := exec.LookPath("getfacl") + if err != nil { + return false, "ACL inspection unavailable (getfacl not installed)" + } + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + out, err := exec.CommandContext(cctx, tool, "-cpn", "--", path).CombinedOutput() + if err != nil { + return false, "ACL inspection failed" + } + extended := false + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "mask::") || regexp.MustCompile(`^(user|group):[0-9]+:`).MatchString(line) { + extended = true + break + } + } + if extended { + return true, "extended POSIX ACL detected; mode-bit write analysis is advisory" + } + return false, "no extended POSIX ACL detected" +} + +func secureHostMappedPath(root, source string) (string, error) { + if strings.TrimSpace(root) == "" { + return "", errors.New("HOST_ROOT is not configured") + } + if !filepath.IsAbs(source) { + return "", errors.New("Docker bind source is not an absolute host path") + } + root = filepath.Clean(root) + rootInfo, err := os.Lstat(root) + if err != nil || !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { + return "", errors.New("HOST_ROOT must be an existing real directory, not a symlink") + } + clean := filepath.Clean(source) + if clean == string(filepath.Separator) { + return "", errors.New("refusing to manage the host root directory as a bind mount") + } + candidate := hostMappedPath(root, clean) + rel, err := filepath.Rel(root, candidate) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", errors.New("bind source escapes HOST_ROOT") + } + cur := root + for _, part := range strings.Split(rel, string(filepath.Separator)) { + if part == "" || part == "." { + continue + } + cur = filepath.Join(cur, part) + fi, err := os.Lstat(cur) + if err != nil { + return "", fmt.Errorf("host bind path unavailable: %w", err) + } + if fi.Mode()&os.ModeSymlink != 0 { + return "", errors.New("refusing host permission management through symlinked bind paths") + } + } + return candidate, nil +} + +func statHostOwner(ctx context.Context, path string) (*int, *int, error) { + select { + case <-ctx.Done(): + return nil, nil, ctx.Err() + default: + } + fi, err := os.Lstat(path) + if err != nil { + return nil, nil, err + } + u, g := fileOwnerIDs(fi) + if u == nil || g == nil { + return nil, nil, errors.New("host filesystem does not expose numeric UID/GID") + } + return u, g, nil +} + +func hostMappedPath(root, source string) string { + clean := filepath.Clean(source) + if filepath.IsAbs(clean) { + clean = strings.TrimLeft(clean, `/\\`) + } + return filepath.Join(root, clean) +} + +func (s *Service) dockerHostIDMapping(ctx context.Context, hostCfg map[string]any) string { + if mode := strings.TrimSpace(stringVal(hostCfg["UsernsMode"])); mode != "" && mode != "host" { + return "remapped" + } + s.hostMappingMu.Lock() + if s.hostMapping != "" && time.Since(s.hostMappingAt) < 30*time.Second { + v := s.hostMapping + s.hostMappingMu.Unlock() + return v + } + s.hostMappingMu.Unlock() + cctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + mapping := "unknown" + out, err := exec.CommandContext(cctx, "docker", "info", "--format", "{{json .SecurityOptions}}").CombinedOutput() + if err == nil { + var opts []string + if json.Unmarshal(out, &opts) == nil { + mapping = "direct" + for _, opt := range opts { + x := strings.ToLower(opt) + if strings.Contains(x, "rootless") || strings.Contains(x, "userns") { + mapping = "remapped" + break + } + } + } + } + s.hostMappingMu.Lock() + s.hostMapping, s.hostMappingAt = mapping, time.Now() + s.hostMappingMu.Unlock() + return mapping +} + +func (s *Service) ContainerIdentity(ctx context.Context, id string) (ContainerIdentityReport, error) { + var err error + id, err = safeDockerPositional(id, "container id/name") + if err != nil { + return ContainerIdentityReport{}, err + } + raw, err := runDocker(ctx, "inspect", id) + if err != nil { + return ContainerIdentityReport{}, err + } + var arr []map[string]any + if err := json.Unmarshal([]byte(raw), &arr); err != nil || len(arr) == 0 { + return ContainerIdentityReport{}, errors.New("invalid docker inspect response") + } + obj := arr[0] + cfg := mapObj(obj["Config"]) + hostCfg := mapObj(obj["HostConfig"]) + state := mapObj(obj["State"]) + report := ContainerIdentityReport{ + ContainerID: stringVal(obj["Id"]), + ContainerName: strings.TrimPrefix(stringVal(obj["Name"]), "/"), + Image: stringVal(cfg["Image"]), + Running: boolVal(state["Running"]), + ConfiguredUser: strings.TrimSpace(stringVal(cfg["User"])), + Privileged: boolVal(hostCfg["Privileged"]), + AddedCapabilities: stringSlice(hostCfg["CapAdd"]), + RootReasons: []string{}, + Recommendations: []string{}, + BindMounts: []BindMountIdentity{}, + HostAccess: s.hostAccessStatus(), + IdentitySource: "container configuration", + } + if devs, ok := hostCfg["Devices"].([]any); ok { + report.DeviceCount = len(devs) + } + report.HostIDMapping = s.dockerHostIDMapping(ctx, hostCfg) + if report.HostIDMapping == "remapped" { + report.Recommendations = append(report.Recommendations, "Docker user-namespace remapping or rootless mode is active. Container UID/GID values do not map directly to the same host IDs; automatic matching host-account creation is disabled.") + } else if report.HostIDMapping == "unknown" { + report.Recommendations = append(report.Recommendations, "Dockwatch could not confirm Docker's host UID mapping mode. Treat host UID/GID comparisons as advisory.") + } + + uid, gid := parseNumericUserSpec(report.ConfiguredUser) + if report.Running { + if u, g, groups, e := dockerProc1Identity(ctx, id); e == nil { + uid, gid = u, g + report.EffectiveGroups = groups + report.IdentitySource = "running PID 1 effective UID/GID (/proc/1/status)" + } else { + resolved := false + if u, e := dockerExecID(ctx, id, "-u"); e == nil { + uid = u + resolved = true + } + if g, e := dockerExecID(ctx, id, "-g"); e == nil { + gid = g + resolved = true + } + if resolved { + report.IdentitySource = "configured exec identity fallback (PID 1 identity unavailable)" + report.Recommendations = append(report.Recommendations, "PID 1 effective UID/GID could not be read; the reported UID/GID falls back to the container's configured exec identity and may differ if the entrypoint drops privileges itself.") + } + } + } + report.EffectiveUID, report.EffectiveGID = uid, gid + if uid != nil { + r := *uid == 0 + report.RunsAsRoot = &r + } + // Bind-mount ownership can intentionally differ from PID 1. Images such as + // LinuxServer.io commonly keep PID 1 privileged while PUID/PGID select the + // identity used for application data. Prefer only well-known paired hints; + // otherwise fall back to the effective process identity. + if bu, bg, src := numericEnvPair(cfg); bu != nil && bg != nil { + report.BindUID, report.BindGID, report.BindIdentitySource = bu, bg, src + } else { + report.BindUID, report.BindGID = uid, gid + report.BindIdentitySource = report.IdentitySource + } + + var users []HostAccount + var groups []HostGroup + if report.HostAccess.Available { + users, groups, _ = readHostAccounts(s.hostRoot) + if uid != nil { + report.HostUser = accountByUID(users, *uid) + } + if gid != nil { + report.HostGroup = groupByGID(groups, *gid) + } + if report.BindUID != nil { + report.BindHostUser = accountByUID(users, *report.BindUID) + } + if report.BindGID != nil { + report.BindHostGroup = groupByGID(groups, *report.BindGID) + } + } + + if mounts, ok := obj["Mounts"].([]any); ok { + for _, mv := range mounts { + m := mapObj(mv) + if strings.ToLower(stringVal(m["Type"])) != "bind" { + continue + } + source := stringVal(m["Source"]) + dest := stringVal(m["Destination"]) + bm := BindMountIdentity{Source: source, Destination: dest, ReadOnly: !boolVal(m["RW"])} + if dest == "/var/run/docker.sock" || source == "/var/run/docker.sock" || strings.HasSuffix(source, "/docker.sock") { + report.DockerSocketMounted = true + } + if report.HostAccess.Available && source != "" { + hp, safeErr := secureHostMappedPath(s.hostRoot, source) + if safeErr == nil { + fi, statErr := os.Lstat(hp) + ou, og := fileOwnerIDs(fi) + if statErr == nil && ou != nil && og != nil { + bm.OwnerUID, bm.OwnerGID = ou, og + bm.Mode = fmt.Sprintf("%04o", fi.Mode().Perm()) + if u := accountByUID(users, *ou); u != nil { + bm.OwnerUser = u.Name + } + if g := groupByGID(groups, *og); g != nil { + bm.OwnerGroup = g.Name + } + if report.BindUID != nil { + x := *report.BindUID == *ou + bm.MatchesUID = &x + } + if report.BindGID != nil { + x := *report.BindGID == *og + bm.MatchesGID = &x + } + bindGroups := report.EffectiveGroups + if report.BindUID != nil && report.EffectiveUID != nil && *report.BindUID != *report.EffectiveUID { + bindGroups = nil + } + bm.StaticWritable, bm.WritableReason = staticWriteAccess(fi, ou, og, report.BindUID, report.BindGID, bindGroups) + bm.ACLDetected, bm.ACLNote = aclInfo(ctx, hp) + if bm.ReadOnly { + v := false + bm.StaticWritable = &v + bm.WritableReason = "Docker mount is read-only" + } + if bm.MatchesUID != nil && !*bm.MatchesUID && !bm.ReadOnly { + bm.OwnershipNote = "writable bind mount owner differs from the expected application UID; verify mode/ACL or repair ownership" + } + } else { + bm.OwnershipNote = "host path ownership could not be read" + } + } else { + bm.OwnershipNote = safeErr.Error() + } + } + report.BindMounts = append(report.BindMounts, bm) + } + } + + switch { + case report.RunsAsRoot == nil: + report.RootAssessment = "unknown" + report.RootReasons = append(report.RootReasons, "The effective UID could not be resolved. Distroless/stopped containers with a named USER may require manual review.") + case !*report.RunsAsRoot: + report.RootAssessment = "non-root" + report.RootReasons = append(report.RootReasons, fmt.Sprintf("Container runs with UID %d instead of UID 0.", *uid)) + case report.Privileged: + report.RootAssessment = "root-high-privilege" + report.RootReasons = append(report.RootReasons, "Container is privileged. Removing root without redesigning privileges is likely to break the current setup.") + case report.DockerSocketMounted: + report.RootAssessment = "root-review-docker-socket" + report.RootReasons = append(report.RootReasons, "Docker socket is bind-mounted. Root is often used for socket access, but matching the socket GID can allow a non-root process to connect.") + case report.DeviceCount > 0: + report.RootAssessment = "root-review-devices" + report.RootReasons = append(report.RootReasons, "Host devices are passed through. Device groups/permissions and capabilities should be checked before dropping root.") + default: + report.RootAssessment = "root-not-obviously-required" + report.RootReasons = append(report.RootReasons, "No privileged mode, Docker socket, or passed-through host devices were detected. Static inspection cannot prove that the application itself supports non-root operation.") + } + if len(report.AddedCapabilities) > 0 { + report.RootReasons = append(report.RootReasons, "Explicit Linux capabilities are configured: "+strings.Join(report.AddedCapabilities, ", ")+". These may replace some reasons for running as root, depending on the application.") + } + if envs, ok := cfg["Env"].([]any); ok { + hints := []string{} + for _, raw := range envs { + part := strings.SplitN(stringVal(raw), "=", 2) + if len(part) != 2 { + continue + } + switch strings.ToUpper(part[0]) { + case "PUID", "PGID", "UID", "GID", "USER_ID", "GROUP_ID": + if _, e := strconv.Atoi(strings.TrimSpace(part[1])); e == nil { + hints = append(hints, part[0]+"="+part[1]) + } + } + } + if len(hints) > 0 { + report.Recommendations = append(report.Recommendations, "The container declares numeric identity-related environment variables ("+strings.Join(hints, ", ")+"). Check the image documentation: it may provide an image-specific PUID/PGID-style non-root configuration.") + } + } + if report.BindUID != nil && *report.BindUID > 0 { + if report.HostAccess.Available { + if report.BindHostUser == nil { + report.Recommendations = append(report.Recommendations, fmt.Sprintf("No local host account currently maps to the expected bind-mount UID %d. Docker only needs numeric ownership, but a matching host account can make administration clearer.", *report.BindUID)) + } else { + report.Recommendations = append(report.Recommendations, fmt.Sprintf("Bind-mount UID %d is already mapped to local user %q.", *report.BindUID, report.BindHostUser.Name)) + } + } else { + report.Recommendations = append(report.Recommendations, "Configure HOST_ROOT read-only to compare application UID/GID with host accounts and bind-mount ownership.") + } + } + if report.RunsAsRoot != nil && *report.RunsAsRoot { + report.Recommendations = append(report.Recommendations, "Do not change Compose user automatically. Test the image with an explicit user/PUID/PGID setting and verify writable paths, capabilities and healthchecks first.") + } + return report, nil +} + +type BindPermissionPreviewInput struct { + ContainerID string `json:"container_id"` + Destination string `json:"destination"` + Recursive bool `json:"recursive"` +} + +type BindPermissionPreview struct { + ContainerID string `json:"container_id"` + ContainerName string `json:"container_name"` + Source string `json:"source"` + Destination string `json:"destination"` + ReadOnly bool `json:"read_only"` + ExpectedUID *int `json:"expected_uid,omitempty"` + ExpectedGID *int `json:"expected_gid,omitempty"` + IdentitySource string `json:"identity_source"` + HostAccess HostAccessStatus `json:"host_access"` + HostUser *HostAccount `json:"host_user,omitempty"` + HostGroup *HostGroup `json:"host_group,omitempty"` + OwnerUID *int `json:"owner_uid,omitempty"` + OwnerGID *int `json:"owner_gid,omitempty"` + Mode string `json:"mode,omitempty"` + StaticWritable *bool `json:"static_writable,omitempty"` + WritableReason string `json:"writable_reason,omitempty"` + RuntimeWritable *bool `json:"runtime_writable,omitempty"` + RuntimeNote string `json:"runtime_note,omitempty"` + ACLDetected bool `json:"acl_detected,omitempty"` + ACLNote string `json:"acl_note,omitempty"` + Recursive bool `json:"recursive"` + EntriesScanned int64 `json:"entries_scanned"` + FilesScanned int64 `json:"files_scanned"` + DirectoriesScanned int64 `json:"directories_scanned"` + SymlinksSkipped int64 `json:"symlinks_skipped"` + CrossFilesystemSkipped int64 `json:"cross_filesystem_skipped"` + EntriesOwnershipMismatch int64 `json:"entries_ownership_mismatch"` + ScanTruncated bool `json:"scan_truncated"` + CanRepair bool `json:"can_repair"` + BlockedReason string `json:"blocked_reason,omitempty"` + Recommendation string `json:"recommendation,omitempty"` +} + +type RepairBindPermissionsInput struct { + ContainerID string `json:"container_id"` + Destination string `json:"destination"` + Recursive bool `json:"recursive"` + FixOwnership bool `json:"fix_ownership"` + Mode string `json:"mode,omitempty"` // optional top-level chmod, e.g. 0750 +} + +type RepairBindPermissionsResult struct { + ChangedOwnership int64 `json:"changed_ownership"` + ChangedMode bool `json:"changed_mode"` + Before BindPermissionPreview `json:"before"` + After BindPermissionPreview `json:"after"` + Message string `json:"message"` +} + +type StackBindPermissionContainer struct { + Service string `json:"service"` + ContainerID string `json:"container_id"` + Report *ContainerIdentityReport `json:"report,omitempty"` + Error string `json:"error,omitempty"` +} + +type StackBindPermissionsReport struct { + Stack string `json:"stack"` + Containers []StackBindPermissionContainer `json:"containers"` +} + +func findBindMount(report ContainerIdentityReport, destination string) (*BindMountIdentity, error) { + destination = filepath.Clean(strings.TrimSpace(destination)) + if destination == "." || destination == "" { + return nil, errors.New("bind destination is required") + } + for i := range report.BindMounts { + if filepath.Clean(report.BindMounts[i].Destination) == destination { + m := report.BindMounts[i] + return &m, nil + } + } + return nil, errors.New("requested destination is not a bind mount of this container") +} + +func dockerRuntimeWritable(ctx context.Context, id, destination string, uid, gid int) (*bool, string) { + cctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + cmd := exec.CommandContext(cctx, "docker", "exec", "--user", fmt.Sprintf("%d:%d", uid, gid), id, "sh", "-c", `test -w "$1"`, "dockwatch", destination) + out, err := cmd.CombinedOutput() + if err == nil { + v := true + return &v, "docker exec test -w succeeded for the expected numeric UID:GID (supplementary groups may differ from the application process)" + } + msg := strings.TrimSpace(string(out)) + var ee *exec.ExitError + if errors.As(err, &ee) && ee.ExitCode() == 1 && msg == "" { + v := false + return &v, "docker exec test -w reported the mount is not writable for the expected numeric UID:GID (supplementary groups may differ from the application process)" + } + if msg == "" { + msg = err.Error() + } + return nil, "runtime write check unavailable: " + msg +} + +var errBindScanLimit = errors.New("bind permission scan limit reached") + +const bindScanLimit = int64(200000) + +func scanBindOwnership(ctx context.Context, path string, uid, gid int, recursive bool) (entries, files, dirs, symlinks, crossFS, mismatch int64, truncated bool, err error) { + rootInfo, err := os.Lstat(path) + if err != nil { + return 0, 0, 0, 0, 0, 0, false, err + } + rootDev := fileDeviceID(rootInfo) + check := func(p string, fi os.FileInfo) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if fi.Mode()&os.ModeSymlink != 0 { + symlinks++ + return nil + } + entries++ + if entries > bindScanLimit { + return errBindScanLimit + } + if fi.IsDir() { + dirs++ + } else { + files++ + } + u, g := fileOwnerIDs(fi) + if u == nil || g == nil { + return errors.New("host filesystem does not expose numeric UID/GID") + } + if *u != uid || *g != gid { + mismatch++ + } + return nil + } + if !recursive { + e := check(path, rootInfo) + return entries, files, dirs, symlinks, crossFS, mismatch, false, e + } + err = filepath.Walk(path, func(p string, fi os.FileInfo, e error) error { + if e != nil { + return e + } + if p != path && rootDev != nil { + if dev := fileDeviceID(fi); dev != nil && *dev != *rootDev { + crossFS++ + if fi.IsDir() { + return filepath.SkipDir + } + return nil + } + } + if fi.Mode()&os.ModeSymlink != 0 { + symlinks++ + return nil + } + return check(p, fi) + }) + if errors.Is(err, errBindScanLimit) { + return entries - 1, files, dirs, symlinks, crossFS, mismatch, true, nil + } + return entries, files, dirs, symlinks, crossFS, mismatch, false, err +} + +func parsePermissionMode(v string) (os.FileMode, error) { + v = strings.TrimSpace(v) + if v == "" { + return 0, nil + } + if len(v) == 4 && v[0] == '0' { + v = v[1:] + } + if len(v) != 3 { + return 0, errors.New("mode must be a three-digit octal value such as 750 or 775") + } + n, err := strconv.ParseUint(v, 8, 32) + if err != nil || n > 0777 { + return 0, errors.New("mode must be an octal value between 000 and 777") + } + return os.FileMode(n), nil +} + +func (s *Service) BindPermissionPreview(ctx context.Context, in BindPermissionPreviewInput) (BindPermissionPreview, error) { + report, err := s.ContainerIdentity(ctx, in.ContainerID) + if err != nil { + return BindPermissionPreview{}, err + } + mount, err := findBindMount(report, in.Destination) + if err != nil { + return BindPermissionPreview{}, err + } + out := BindPermissionPreview{ + ContainerID: report.ContainerID, ContainerName: report.ContainerName, + Source: mount.Source, Destination: mount.Destination, ReadOnly: mount.ReadOnly, + ExpectedUID: report.BindUID, ExpectedGID: report.BindGID, IdentitySource: report.BindIdentitySource, + HostAccess: report.HostAccess, HostUser: report.BindHostUser, HostGroup: report.BindHostGroup, + OwnerUID: mount.OwnerUID, OwnerGID: mount.OwnerGID, Mode: mount.Mode, + StaticWritable: mount.StaticWritable, WritableReason: mount.WritableReason, + ACLDetected: mount.ACLDetected, ACLNote: mount.ACLNote, Recursive: in.Recursive, + } + if !report.HostAccess.Available { + out.BlockedReason = "HOST_ROOT is not available; host ownership cannot be inspected" + out.Recommendation = "Mount the host root read-only at HOST_ROOT for analysis." + return out, nil + } + if report.BindUID == nil || report.BindGID == nil { + out.BlockedReason = "expected bind-mount UID/GID could not be resolved" + return out, nil + } + hp, err := secureHostMappedPath(s.hostRoot, mount.Source) + if err != nil { + out.BlockedReason = err.Error() + return out, nil + } + fi, err := os.Lstat(hp) + if err != nil { + return out, err + } + out.Mode = fmt.Sprintf("%04o", fi.Mode().Perm()) + out.OwnerUID, out.OwnerGID = fileOwnerIDs(fi) + bindGroups := report.EffectiveGroups + if report.EffectiveUID == nil || *report.BindUID != *report.EffectiveUID { + bindGroups = nil + } + out.StaticWritable, out.WritableReason = staticWriteAccess(fi, out.OwnerUID, out.OwnerGID, report.BindUID, report.BindGID, bindGroups) + out.ACLDetected, out.ACLNote = aclInfo(ctx, hp) + if mount.ReadOnly { + v := false + out.StaticWritable = &v + out.WritableReason = "Docker mount is read-only" + } + out.EntriesScanned, out.FilesScanned, out.DirectoriesScanned, out.SymlinksSkipped, out.CrossFilesystemSkipped, out.EntriesOwnershipMismatch, out.ScanTruncated, err = scanBindOwnership(ctx, hp, *report.BindUID, *report.BindGID, in.Recursive) + if err != nil { + return out, err + } + if out.ScanTruncated { + out.BlockedReason = "recursive ownership scan exceeded the 200000-entry safety limit" + out.Recommendation = "Narrow the bind mount or repair this very large tree deliberately on the host. Dockwatch will not perform a blind recursive chown." + } + if report.Running { + out.RuntimeWritable, out.RuntimeNote = dockerRuntimeWritable(ctx, in.ContainerID, mount.Destination, *report.BindUID, *report.BindGID) + } else { + out.RuntimeNote = "container is stopped; runtime write check was skipped" + } + switch { + case out.ScanTruncated: + // Keep the safety block set above. + case mount.ReadOnly: + out.BlockedReason = "mount is read-only; Dockwatch never repairs ownership for :ro mounts" + out.Recommendation = "No ownership repair is recommended for a read-only bind mount." + case report.HostIDMapping == "remapped": + out.BlockedReason = "Docker rootless/userns remapping is active; container IDs do not map directly to host IDs" + out.Recommendation = "Repair ownership using the host's mapped UID/GID policy instead of same-numbered container IDs." + case report.HostIDMapping != "direct": + out.BlockedReason = "Dockwatch could not confirm direct container-to-host UID/GID mapping" + out.Recommendation = "Resolve the Docker security-options check before allowing automatic host ownership changes." + case !report.HostAccess.PermissionManagementEnabled: + out.BlockedReason = "host permission management is disabled" + out.Recommendation = "Set ALLOW_HOST_PERMISSION_MANAGEMENT=true and mount HOST_ROOT read-write only on trusted hosts." + default: + out.CanRepair = true + if out.RuntimeWritable != nil && *out.RuntimeWritable { + out.Recommendation = "The expected application identity can currently write this mount; ownership changes are not required for write access." + } else if out.ACLDetected { + out.Recommendation = "Extended ACLs are present. Review ACLs before changing ownership because mode bits alone are incomplete." + } else if out.EntriesOwnershipMismatch > 0 && out.StaticWritable != nil && !*out.StaticWritable { + out.Recommendation = "Ownership differs and POSIX mode bits do not grant write access. A reviewed ownership repair is likely appropriate." + } else if out.EntriesOwnershipMismatch > 0 { + out.Recommendation = "Ownership differs, but write access may still come from group/other bits. Change ownership only if the application requires ownership semantics." + } else { + out.Recommendation = "Ownership already matches the expected application UID/GID." + } + } + return out, nil +} + +func repairOwnership(ctx context.Context, path string, uid, gid int, recursive bool) (int64, error) { + var changed int64 + rootInfo, err := os.Lstat(path) + if err != nil { + return 0, err + } + rootDev := fileDeviceID(rootInfo) + apply := func(p string, fi os.FileInfo) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if fi.Mode()&os.ModeSymlink != 0 { + return nil + } + u, g := fileOwnerIDs(fi) + if u == nil || g == nil { + return errors.New("host filesystem does not expose numeric UID/GID") + } + if *u == uid && *g == gid { + return nil + } + if changed >= bindScanLimit { + return errors.New("refusing to change more than 200000 entries in one operation") + } + if err := os.Chown(p, uid, gid); err != nil { + return err + } + changed++ + return nil + } + if !recursive { + if err := apply(path, rootInfo); err != nil { + return changed, err + } + return changed, nil + } + err = filepath.Walk(path, func(p string, fi os.FileInfo, err error) error { + if err != nil { + return err + } + if p != path && rootDev != nil { + if dev := fileDeviceID(fi); dev != nil && *dev != *rootDev { + if fi.IsDir() { + return filepath.SkipDir + } + return nil + } + } + return apply(p, fi) + }) + return changed, err +} + +func (s *Service) RepairBindPermissions(ctx context.Context, in RepairBindPermissionsInput) (RepairBindPermissionsResult, error) { + s.hostIdentityMu.Lock() + defer s.hostIdentityMu.Unlock() + if !s.allowHostPermissionManagement { + return RepairBindPermissionsResult{}, errors.New("host permission management is disabled; set ALLOW_HOST_PERMISSION_MANAGEMENT=true explicitly") + } + before, err := s.BindPermissionPreview(ctx, BindPermissionPreviewInput{ContainerID: in.ContainerID, Destination: in.Destination, Recursive: in.Recursive}) + if err != nil { + return RepairBindPermissionsResult{}, err + } + if !before.CanRepair { + return RepairBindPermissionsResult{}, errors.New(before.BlockedReason) + } + if before.ExpectedUID == nil || before.ExpectedGID == nil { + return RepairBindPermissionsResult{}, errors.New("expected bind-mount UID/GID could not be resolved") + } + mode, err := parsePermissionMode(in.Mode) + if err != nil { + return RepairBindPermissionsResult{}, err + } + if !in.FixOwnership && strings.TrimSpace(in.Mode) == "" { + return RepairBindPermissionsResult{}, errors.New("select ownership repair and/or an explicit top-level mode") + } + hp, err := secureHostMappedPath(s.hostRoot, before.Source) + if err != nil { + return RepairBindPermissionsResult{}, err + } + result := RepairBindPermissionsResult{Before: before} + if in.FixOwnership { + result.ChangedOwnership, err = repairOwnership(ctx, hp, *before.ExpectedUID, *before.ExpectedGID, in.Recursive) + if err != nil { + return result, fmt.Errorf("repair ownership: %w", err) + } + } + if strings.TrimSpace(in.Mode) != "" { + if err := os.Chmod(hp, mode); err != nil { + return result, fmt.Errorf("chmod bind root: %w", err) + } + result.ChangedMode = true + } + result.After, err = s.BindPermissionPreview(ctx, BindPermissionPreviewInput{ContainerID: in.ContainerID, Destination: in.Destination, Recursive: in.Recursive}) + if err != nil { + return result, err + } + result.Message = fmt.Sprintf("bind mount repaired: %d ownership changes", result.ChangedOwnership) + if result.ChangedMode { + result.Message += "; top-level mode updated" + } + return result, nil +} + +func (s *Service) stackPSAll(ctx context.Context, name string) ([]ServiceInfo, error) { + out, err := s.run(ctx, name, "ps", "--all", "--format", "json") + if err != nil { + return nil, err + } + var arr []map[string]any + if json.Unmarshal(out, &arr) == nil { + services := make([]ServiceInfo, 0, len(arr)) + for _, m := range arr { + services = append(services, mapService(m)) + } + return services, nil + } + services := []ServiceInfo{} + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var m map[string]any + if json.Unmarshal([]byte(line), &m) == nil { + services = append(services, mapService(m)) + } + } + if len(services) == 0 && len(strings.TrimSpace(string(out))) > 0 { + return nil, errors.New("docker compose ps returned invalid JSON") + } + return services, nil +} + +func (s *Service) StackBindPermissions(ctx context.Context, name string) (StackBindPermissionsReport, error) { + services, err := s.stackPSAll(ctx, name) + if err != nil { + return StackBindPermissionsReport{}, err + } + out := StackBindPermissionsReport{Stack: name, Containers: []StackBindPermissionContainer{}} + for _, service := range services { + item := StackBindPermissionContainer{Service: service.Service, ContainerID: service.ID} + if item.ContainerID == "" { + item.Error = "container is not created" + out.Containers = append(out.Containers, item) + continue + } + report, err := s.ContainerIdentity(ctx, item.ContainerID) + if err != nil { + item.Error = err.Error() + } else { + item.Report = &report + } + out.Containers = append(out.Containers, item) + } + return out, nil +} + +func findHostTool(root string, candidates ...string) string { + for _, c := range candidates { + if fi, err := os.Stat(filepath.Join(root, strings.TrimPrefix(c, "/"))); err == nil && !fi.IsDir() { + return c + } + } + return "" +} + +func runChroot(ctx context.Context, root, tool string, args ...string) error { + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + name := "chroot" + all := append([]string{root, tool}, args...) + if _, err := exec.LookPath(name); err != nil { + if _, busyErr := exec.LookPath("busybox"); busyErr != nil { + return errors.New("host user management requires chroot or busybox in the Dockwatch runtime") + } + name = "busybox" + all = append([]string{"chroot", root, tool}, args...) + } + out, err := exec.CommandContext(cctx, name, all...).CombinedOutput() + if err != nil { + return fmt.Errorf("host command %s: %s", tool, fallbackOutput(out, err)) + } + return nil +} + +func (s *Service) CreateHostUser(ctx context.Context, in CreateHostUserInput) (CreateHostUserResult, error) { + s.hostIdentityMu.Lock() + defer s.hostIdentityMu.Unlock() + if !s.allowHostUserManagement { + return CreateHostUserResult{}, errors.New("host user management is disabled; set ALLOW_HOST_USER_MANAGEMENT=true explicitly") + } + if !s.hostAccessStatus().Available { + return CreateHostUserResult{}, errors.New("HOST_ROOT is not available") + } + in.Username = strings.TrimSpace(in.Username) + in.GroupName = strings.TrimSpace(in.GroupName) + if !validHostAccountName.MatchString(in.Username) { + return CreateHostUserResult{}, errors.New("username must be a conservative local Unix account name (lowercase letters, digits, _ and -)") + } + if in.GroupName != "" && !validHostAccountName.MatchString(in.GroupName) { + return CreateHostUserResult{}, errors.New("invalid group name") + } + report, err := s.ContainerIdentity(ctx, in.ContainerID) + if err != nil { + return CreateHostUserResult{}, err + } + if report.HostIDMapping == "remapped" { + return CreateHostUserResult{}, errors.New("Docker uses user-namespace remapping/rootless mode; refusing to create a same-numbered host account") + } + if report.HostIDMapping != "direct" { + return CreateHostUserResult{}, errors.New("Dockwatch could not confirm direct container-to-host UID/GID mapping; refusing to create a host account") + } + if report.BindUID == nil || report.BindGID == nil { + return CreateHostUserResult{}, errors.New("bind-mount UID/GID could not be resolved; refusing to guess host identity") + } + uid, gid := *report.BindUID, *report.BindGID + if uid == 0 { + return CreateHostUserResult{}, errors.New("expected bind-mount UID is 0; host root already exists and a matching account must not be created") + } + if uid < 0 || gid < 0 || uid > 2147483646 || gid > 2147483646 { + return CreateHostUserResult{}, errors.New("container UID/GID outside supported host account range") + } + users, groups, err := readHostAccounts(s.hostRoot) + if err != nil { + return CreateHostUserResult{}, err + } + if existing := accountByUID(users, uid); existing != nil { + g := HostGroup{GID: existing.GID} + if x := groupByGID(groups, existing.GID); x != nil { + g = *x + } + return CreateHostUserResult{User: *existing, Group: g, Message: fmt.Sprintf("host UID %d already belongs to %s; nothing changed", uid, existing.Name)}, nil + } + if existing := accountByName(users, in.Username); existing != nil { + return CreateHostUserResult{}, fmt.Errorf("host username %q already exists with UID %d", in.Username, existing.UID) + } + group := groupByGID(groups, gid) + createdGroup := false + if group == nil { + if in.GroupName == "" { + in.GroupName = in.Username + } + if existing := groupByName(groups, in.GroupName); existing != nil { + return CreateHostUserResult{}, fmt.Errorf("host group %q already exists with GID %d", in.GroupName, existing.GID) + } + groupadd := findHostTool(s.hostRoot, "/usr/sbin/groupadd", "/sbin/groupadd", "/usr/bin/groupadd", "/bin/groupadd") + addgroup := findHostTool(s.hostRoot, "/usr/sbin/addgroup", "/sbin/addgroup", "/usr/bin/addgroup", "/bin/addgroup") + switch { + case groupadd != "": + err = runChroot(ctx, s.hostRoot, groupadd, "-g", strconv.Itoa(gid), in.GroupName) + case addgroup != "": + err = runChroot(ctx, s.hostRoot, addgroup, "-g", strconv.Itoa(gid), "-S", in.GroupName) + default: + err = errors.New("host provides neither groupadd nor addgroup") + } + if err != nil { + return CreateHostUserResult{}, err + } + createdGroup = true + group = &HostGroup{Name: in.GroupName, GID: gid} + } + useradd := findHostTool(s.hostRoot, "/usr/sbin/useradd", "/sbin/useradd", "/usr/bin/useradd", "/bin/useradd") + adduser := findHostTool(s.hostRoot, "/usr/sbin/adduser", "/sbin/adduser", "/usr/bin/adduser", "/bin/adduser") + nologin := findHostTool(s.hostRoot, "/usr/sbin/nologin", "/sbin/nologin", "/bin/false") + if nologin == "" { + nologin = "/bin/false" + } + switch { + case useradd != "": + args := []string{"-u", strconv.Itoa(uid), "-g", strconv.Itoa(gid), "-s", nologin, "-p", "!"} + if in.CreateHome { + args = append(args, "-m") + } else { + args = append(args, "-M") + } + args = append(args, in.Username) + err = runChroot(ctx, s.hostRoot, useradd, args...) + case adduser != "": + args := []string{"-D", "-u", strconv.Itoa(uid), "-G", group.Name, "-s", nologin} + if !in.CreateHome { + args = append(args, "-H") + } + args = append(args, in.Username) + err = runChroot(ctx, s.hostRoot, adduser, args...) + default: + err = errors.New("host provides neither useradd nor adduser") + } + if err != nil { + if createdGroup { + groupdel := findHostTool(s.hostRoot, "/usr/sbin/groupdel", "/sbin/groupdel", "/usr/bin/groupdel", "/bin/groupdel") + delgroup := findHostTool(s.hostRoot, "/usr/sbin/delgroup", "/sbin/delgroup", "/usr/bin/delgroup", "/bin/delgroup") + if groupdel != "" { + _ = runChroot(context.Background(), s.hostRoot, groupdel, group.Name) + } else if delgroup != "" { + _ = runChroot(context.Background(), s.hostRoot, delgroup, group.Name) + } + } + return CreateHostUserResult{}, err + } + users, groups, err = readHostAccounts(s.hostRoot) + if err != nil { + return CreateHostUserResult{}, err + } + user := accountByUID(users, uid) + group = groupByGID(groups, gid) + if user == nil || group == nil { + return CreateHostUserResult{}, errors.New("host account command succeeded but the new UID/GID could not be verified") + } + return CreateHostUserResult{CreatedUser: true, CreatedGroup: createdGroup, User: *user, Group: *group, Message: fmt.Sprintf("created local host user %s (%d:%d) to match container identity", user.Name, uid, gid)}, nil +} diff --git a/internal/stacks/identity_test.go b/internal/stacks/identity_test.go new file mode 100644 index 0000000..a798c8c --- /dev/null +++ b/internal/stacks/identity_test.go @@ -0,0 +1,270 @@ +package stacks + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestParseNumericUserSpec(t *testing.T) { + tests := []struct { + spec string + uid, gid int + wantUID bool + wantGID bool + }{ + {"", 0, 0, true, true}, + {"1000:1001", 1000, 1001, true, true}, + {"1000", 1000, 0, true, false}, + {"root:root", 0, 0, true, true}, + {"app:app", 0, 0, false, false}, + } + for _, tt := range tests { + u, g := parseNumericUserSpec(tt.spec) + if (u != nil) != tt.wantUID || (g != nil) != tt.wantGID { + t.Fatalf("%q presence got uid=%v gid=%v", tt.spec, u, g) + } + if u != nil && *u != tt.uid { + t.Fatalf("%q uid=%d want %d", tt.spec, *u, tt.uid) + } + if g != nil && *g != tt.gid { + t.Fatalf("%q gid=%d want %d", tt.spec, *g, tt.gid) + } + } +} + +func TestReadHostAccountsAndAccessStatus(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte("root:x:0:0:root:/root:/bin/sh\napp:x:1234:2345::/nonexistent:/usr/sbin/nologin\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte("root:x:0:\napp:x:2345:\n"), 0644); err != nil { + t.Fatal(err) + } + users, groups, err := readHostAccounts(root) + if err != nil { + t.Fatal(err) + } + if u := accountByUID(users, 1234); u == nil || u.Name != "app" || u.GID != 2345 { + t.Fatalf("unexpected user lookup: %#v", u) + } + if g := groupByGID(groups, 2345); g == nil || g.Name != "app" { + t.Fatalf("unexpected group lookup: %#v", g) + } + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + s.ConfigureHostAccess(root, false) + st := s.hostAccessStatus() + if !st.Configured || !st.Available || st.ManagementEnabled { + t.Fatalf("unexpected host access status: %#v", st) + } +} + +func TestCreateHostUserRequiresExplicitOptIn(t *testing.T) { + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateHostUser(context.Background(), CreateHostUserInput{ContainerID: "demo", Username: "demo"}); err == nil { + t.Fatal("expected host user creation to be disabled by default") + } +} + +func TestHostAccountNameValidation(t *testing.T) { + for _, name := range []string{"dockwatch-app", "app_1", "_svc"} { + if !validHostAccountName.MatchString(name) { + t.Fatalf("valid account name rejected: %q", name) + } + } + for _, name := range []string{"Root", "../root", "app user", "-root", ""} { + if validHostAccountName.MatchString(name) { + t.Fatalf("invalid account name accepted: %q", name) + } + } +} + +func TestContainerIdentityUsesPID1AndMapsHostAccount(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "srv", "data"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte("root:x:0:0:root:/root:/bin/sh\napp:x:1000:1000::/nonexistent:/usr/sbin/nologin\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte("root:x:0:\napp:x:1000:\n"), 0644); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + docker := filepath.Join(bin, "docker") + script := `#!/bin/sh +set -eu +if [ "$1" = "inspect" ]; then +cat <<'JSON' +[{"Id":"abc","Name":"/demo","Config":{"Image":"demo:latest","User":"","Env":["PUID=1000","PGID=1000"]},"State":{"Running":true},"HostConfig":{"Privileged":false,"CapAdd":[],"Devices":[]},"Mounts":[{"Type":"bind","Source":"/srv/data","Destination":"/data","RW":true}]}] +JSON +exit 0 +fi +if [ "$1" = "exec" ] && [ "$3" = "cat" ]; then +printf 'Name:\tdemo\nUid:\t1000\t1000\t1000\t1000\nGid:\t1000\t1000\t1000\t1000\n' +exit 0 +fi +exit 2 +` + if err := os.WriteFile(docker, []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + s.ConfigureHostAccess(root, false) + r, err := s.ContainerIdentity(context.Background(), "demo") + if err != nil { + t.Fatal(err) + } + if r.EffectiveUID == nil || *r.EffectiveUID != 1000 || r.EffectiveGID == nil || *r.EffectiveGID != 1000 { + t.Fatalf("unexpected identity: uid=%v gid=%v", r.EffectiveUID, r.EffectiveGID) + } + if r.RunsAsRoot == nil || *r.RunsAsRoot || r.RootAssessment != "non-root" { + t.Fatalf("unexpected root assessment: %#v", r) + } + if r.HostUser == nil || r.HostUser.Name != "app" { + t.Fatalf("expected host UID mapping, got %#v", r.HostUser) + } + if len(r.BindMounts) != 1 || r.BindMounts[0].Source != "/srv/data" { + t.Fatalf("unexpected bind mounts: %#v", r.BindMounts) + } +} + +func TestNumericEnvPairPrefersPUIDPGID(t *testing.T) { + cfg := map[string]any{"Env": []any{"USER_ID=2000", "GROUP_ID=2001", "PUID=1000", "PGID=1001"}} + u, g, src := numericEnvPair(cfg) + if u == nil || g == nil || *u != 1000 || *g != 1001 || src != "PUID/PGID environment" { + t.Fatalf("unexpected bind identity: uid=%v gid=%v source=%q", u, g, src) + } +} + +func TestStaticWriteAccess(t *testing.T) { + root := t.TempDir() + p := filepath.Join(root, "data") + if err := os.WriteFile(p, []byte("x"), 0640); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + u, g := fileOwnerIDs(fi) + if u == nil || g == nil { + t.Skip("platform does not expose Unix UID/GID") + } + ownerUID, ownerGID := *u, *g + ok, _ := staticWriteAccess(fi, &ownerUID, &ownerGID, &ownerUID, &ownerGID, nil) + if ok == nil || !*ok { + t.Fatal("expected owner write access") + } + otherUID, otherGID := ownerUID+10000, ownerGID+10000 + ok, _ = staticWriteAccess(fi, &ownerUID, &ownerGID, &otherUID, &otherGID, nil) + if ok == nil || *ok { + t.Fatal("expected other identity to lack write access") + } +} + +func TestSecureHostMappedPathRejectsSymlink(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "srv"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/tmp", filepath.Join(root, "srv", "link")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := secureHostMappedPath(root, "/srv/link"); err == nil { + t.Fatal("expected symlinked host path to be rejected") + } +} + +func TestParsePermissionMode(t *testing.T) { + for in, want := range map[string]os.FileMode{"750": 0750, "0755": 0755, "000": 0} { + got, err := parsePermissionMode(in) + if err != nil || got != want { + t.Fatalf("parsePermissionMode(%q)=%#o,%v want %#o", in, got, err, want) + } + } + for _, in := range []string{"7777", "888", "75", "abc"} { + if _, err := parsePermissionMode(in); err == nil { + t.Fatalf("expected %q to be rejected", in) + } + } +} + +func TestBindPermissionPreviewUsesBindIdentityAndOptIn(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "srv", "data"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte("root:x:0:0:root:/root:/bin/sh\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte("root:x:0:\n"), 0644); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + docker := filepath.Join(bin, "docker") + script := `#!/bin/sh +set -eu +if [ "$1" = "inspect" ]; then +cat <<'JSON' +[{"Id":"abc","Name":"/demo","Config":{"Image":"demo:latest","User":"","Env":["PUID=4242","PGID=4343"]},"State":{"Running":false},"HostConfig":{"Privileged":false,"CapAdd":[],"Devices":[],"UsernsMode":"host"},"Mounts":[{"Type":"bind","Source":"/srv/data","Destination":"/data","RW":true}]}] +JSON +exit 0 +fi +if [ "$1" = "info" ]; then +printf '["name=seccomp"]' +exit 0 +fi +exit 2 +` + if err := os.WriteFile(docker, []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + svc, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + svc.ConfigureHostAccess(root, false) + p, err := svc.BindPermissionPreview(context.Background(), BindPermissionPreviewInput{ContainerID: "demo", Destination: "/data"}) + if err != nil { + t.Fatal(err) + } + if p.ExpectedUID == nil || *p.ExpectedUID != 4242 || p.ExpectedGID == nil || *p.ExpectedGID != 4343 { + t.Fatalf("unexpected expected bind identity: %#v", p) + } + if p.CanRepair || p.BlockedReason == "" { + t.Fatalf("repair should be blocked without explicit opt-in: %#v", p) + } + svc.ConfigureHostPermissionManagement(true) + p, err = svc.BindPermissionPreview(context.Background(), BindPermissionPreviewInput{ContainerID: "demo", Destination: "/data"}) + if err != nil { + t.Fatal(err) + } + if !p.CanRepair { + t.Fatalf("expected repair to be enabled after opt-in: %#v", p) + } + if p.Source != "/srv/data" || p.Destination != "/data" { + t.Fatalf("unexpected mount: %#v", p) + } +} diff --git a/internal/stacks/stacks.go b/internal/stacks/stacks.go new file mode 100644 index 0000000..27ad60e --- /dev/null +++ b/internal/stacks/stacks.go @@ -0,0 +1,1473 @@ +package stacks + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/creack/pty" + "github.com/gorilla/websocket" +) + +var validName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) +var validSecretName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) +var validServiceName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + +type Stack struct { + Name string `json:"name"` + Compose string `json:"compose,omitempty"` + Env string `json:"env,omitempty"` + Secrets []SecretFile `json:"secrets,omitempty"` + EnvFiles []SecretFile `json:"env_files,omitempty"` + Configs []SecretFile `json:"configs,omitempty"` + Services []ServiceInfo `json:"services,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +type SecretFile struct { + Name string `json:"name"` + Content string `json:"content,omitempty"` + Size int64 `json:"size,omitempty"` +} + +type ServiceInfo struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Service string `json:"service,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status,omitempty"` + Image string `json:"image,omitempty"` + Command string `json:"command,omitempty"` + Ports string `json:"ports,omitempty"` +} + +type ExecInput struct { + Service string `json:"service"` + Command string `json:"command"` +} + +type SaveInput struct { + Compose string `json:"compose"` + Env string `json:"env"` + Secrets []SecretFile `json:"secrets"` + EnvFiles []SecretFile `json:"env_files"` + Configs []SecretFile `json:"configs"` +} + +type Service struct { + root string + hostRoot string + allowHostUserManagement bool + allowHostPermissionManagement bool + hostIdentityMu sync.Mutex + hostMappingMu sync.Mutex + hostMapping string + hostMappingAt time.Time + locks sync.Map +} + +func New(root string) (*Service, error) { + if err := os.MkdirAll(root, 0750); err != nil { + return nil, err + } + return &Service{root: root}, nil +} + +func (s *Service) Root() string { return s.root } + +// ConfigureHostAccess enables optional host identity inspection. HostRoot is expected +// to point at a deliberately mounted host root (for example /host). User creation is +// disabled unless allowManagement is explicitly true. +func (s *Service) ConfigureHostAccess(hostRoot string, allowManagement bool) { + hostRoot = strings.TrimSpace(hostRoot) + if hostRoot != "" { + hostRoot = filepath.Clean(hostRoot) + } + s.hostRoot = hostRoot + s.allowHostUserManagement = allowManagement +} + +// ConfigureHostPermissionManagement enables explicit host bind-mount ownership/mode repairs. +// This is intentionally independent from host user creation so operators can opt in to one +// class of host mutation without granting the other. +func (s *Service) ConfigureHostPermissionManagement(allow bool) { + s.allowHostPermissionManagement = allow +} + +func (s *Service) lockFor(name string) *sync.Mutex { + v, _ := s.locks.LoadOrStore(name, &sync.Mutex{}) + return v.(*sync.Mutex) +} + +func (s *Service) stackDir(name string) (string, error) { + if !validName.MatchString(name) { + return "", errors.New("invalid stack name") + } + dir := filepath.Join(s.root, name) + info, err := os.Lstat(dir) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "", errors.New("stack path must be a real directory, not a symlink or file") + } + } else if !os.IsNotExist(err) { + return "", err + } + return dir, nil +} + +func (s *Service) path(name string) (string, error) { + dir, err := s.stackDir(name) + if err != nil { + return "", err + } + return filepath.Join(dir, "compose.yaml"), nil +} +func (s *Service) envPath(name string) (string, error) { + dir, err := s.stackDir(name) + if err != nil { + return "", err + } + return filepath.Join(dir, ".env"), nil +} +func (s *Service) secretsDir(name string) (string, error) { + dir, err := s.stackDir(name) + if err != nil { + return "", err + } + return filepath.Join(dir, "secrets"), nil +} + +func (s *Service) List(ctx context.Context) ([]Stack, error) { + ents, err := os.ReadDir(s.root) + if err != nil { + return nil, err + } + out := []Stack{} + for _, e := range ents { + if !e.IsDir() || !validName.MatchString(e.Name()) { + continue + } + p, _ := s.path(e.Name()) + if _, err := os.Stat(p); err != nil { + continue + } + st := Stack{Name: e.Name(), Status: "unknown"} + if services, err := s.PS(ctx, e.Name()); err != nil { + st.Error = err.Error() + } else { + st.Services = services + st.Status = summarizeStatus(services) + } + out = append(out, st) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func (s *Service) Get(ctx context.Context, name string) (Stack, error) { + p, err := s.path(name) + if err != nil { + return Stack{}, err + } + b, err := os.ReadFile(p) + if err != nil { + return Stack{}, err + } + st := Stack{Name: name, Compose: string(b), Status: "unknown"} + if eb, err := os.ReadFile(filepath.Join(filepath.Dir(p), ".env")); err == nil { + st.Env = string(eb) + } + st.Secrets, _ = s.ReadSecrets(name, true) + st.EnvFiles, _ = s.readManagedFiles(name, "envs", true) + st.Configs, _ = s.readManagedFiles(name, "configs", true) + if services, err := s.PS(ctx, name); err == nil { + st.Services = services + st.Status = summarizeStatus(services) + } else { + st.Error = err.Error() + } + return st, nil +} + +func (s *Service) Save(ctx context.Context, name string, in SaveInput) error { + mu := s.lockFor(name) + mu.Lock() + defer mu.Unlock() + if len(in.Compose) == 0 || len(in.Compose) > 2<<20 { + return errors.New("compose file must be 1 byte to 2 MiB") + } + p, err := s.path(name) + if err != nil { + return err + } + finalDir := filepath.Dir(p) + if err := os.MkdirAll(s.root, 0750); err != nil { + return err + } + stageDir, err := os.MkdirTemp(s.root, ".dockwatch-stage-"+name+"-") + if err != nil { + return err + } + defer os.RemoveAll(stageDir) + + composePath := filepath.Join(stageDir, "compose.yaml") + if err := os.WriteFile(composePath, []byte(in.Compose), 0640); err != nil { + return err + } + if strings.TrimSpace(in.Env) != "" { + if len(in.Env) > 512<<10 { + return errors.New(".env too large") + } + if err := os.WriteFile(filepath.Join(stageDir, ".env"), []byte(in.Env), 0640); err != nil { + return err + } + } + if err := writeFilesToDir(filepath.Join(stageDir, "secrets"), in.Secrets, 0600); err != nil { + return err + } + if err := writeFilesToDir(filepath.Join(stageDir, "envs"), in.EnvFiles, 0640); err != nil { + return err + } + if err := writeFilesToDir(filepath.Join(stageDir, "configs"), in.Configs, 0640); err != nil { + return err + } + if err := s.validateStaged(ctx, name, stageDir, composePath); err != nil { + return err + } + + if err := os.MkdirAll(finalDir, 0750); err != nil { + return err + } + + // Snapshot only the files Dockwatch owns. A save is applied as one logical + // operation and is rolled back when any copy/remove step fails. Unrelated + // bind-mount data beside compose.yaml is never part of the snapshot. + backupDir, err := os.MkdirTemp(s.root, ".dockwatch-backup-"+name+"-") + if err != nil { + return err + } + defer os.RemoveAll(backupDir) + if err := snapshotManaged(finalDir, backupDir); err != nil { + return fmt.Errorf("snapshot current stack: %w", err) + } + rollback := func(cause error) error { + if restoreErr := restoreManaged(finalDir, backupDir); restoreErr != nil { + return fmt.Errorf("%w (rollback failed: %v)", cause, restoreErr) + } + return cause + } + + // Replace managed files only after validation succeeded. + if err := copyFile(composePath, filepath.Join(finalDir, "compose.yaml"), 0640); err != nil { + return rollback(err) + } + stageEnv := filepath.Join(stageDir, ".env") + if _, err := os.Stat(stageEnv); err == nil { + if err := copyFile(stageEnv, filepath.Join(finalDir, ".env"), 0640); err != nil { + return rollback(err) + } + } else if os.IsNotExist(err) { + if err := os.Remove(filepath.Join(finalDir, ".env")); err != nil && !os.IsNotExist(err) { + return rollback(err) + } + } else { + return rollback(err) + } + + if err := replaceManagedDir(stageDir, finalDir, "secrets", in.Secrets, 0600); err != nil { + return rollback(err) + } + if err := replaceManagedDir(stageDir, finalDir, "envs", in.EnvFiles, 0640); err != nil { + return rollback(err) + } + if err := replaceManagedDir(stageDir, finalDir, "configs", in.Configs, 0640); err != nil { + return rollback(err) + } + return nil +} + +func (s *Service) ValidateProject(ctx context.Context, name, projectDir, composeFile string) error { + if !validName.MatchString(name) { + return errors.New("invalid stack name") + } + projectDir = filepath.Clean(projectDir) + composeFile = filepath.Clean(composeFile) + if projectDir == "." || filepath.IsAbs(composeFile) || strings.HasPrefix(composeFile, "..") { + return errors.New("invalid compose project path") + } + return s.validateStaged(ctx, name, projectDir, filepath.Join(projectDir, composeFile)) +} + +func (s *Service) validateStaged(ctx context.Context, name, projectDir, composeFile string) error { + cctx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() + args := []string{"compose", "--project-name", name, "--project-directory", projectDir, "-f", composeFile} + if _, err := os.Stat(filepath.Join(projectDir, ".env")); err == nil { + args = append(args, "--env-file", filepath.Join(projectDir, ".env")) + } + args = append(args, "config", "--quiet") + cmd := exec.CommandContext(cctx, "docker", args...) + cmd.Dir = projectDir + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + if errors.Is(cctx.Err(), context.DeadlineExceeded) { + msg = "validation timed out after 25s" + } + return fmt.Errorf("compose validation failed: %s", msg) +} + +var managedStackEntries = []string{"compose.yaml", ".env", "secrets", "envs", "configs"} + +func snapshotManaged(srcDir, backupDir string) error { + for _, name := range managedStackEntries { + src := filepath.Join(srcDir, name) + if _, err := os.Lstat(src); os.IsNotExist(err) { + continue + } else if err != nil { + return err + } + if err := copyTree(src, filepath.Join(backupDir, name)); err != nil { + return err + } + } + return nil +} + +func restoreManaged(dstDir, backupDir string) error { + for _, name := range managedStackEntries { + if err := os.RemoveAll(filepath.Join(dstDir, name)); err != nil { + return err + } + } + for _, name := range managedStackEntries { + src := filepath.Join(backupDir, name) + if _, err := os.Lstat(src); os.IsNotExist(err) { + continue + } else if err != nil { + return err + } + if err := copyTree(src, filepath.Join(dstDir, name)); err != nil { + return err + } + } + return nil +} + +func copyTree(src, dst string) error { + info, err := os.Lstat(src) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing symlink in managed stack data: %s", src) + } + if !info.IsDir() { + return copyFile(src, dst, info.Mode().Perm()) + } + if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + if err := copyTree(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { + return err + } + } + return nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + b, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(dst), ".dockwatch-write-*") + if err != nil { + return err + } + tmp := f.Name() + defer os.Remove(tmp) + if err := f.Chmod(mode); err != nil { + _ = f.Close() + return err + } + if _, err := f.Write(b); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, dst) +} +func copyDir(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(dst, 0750); err != nil { + return err + } + ents, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range ents { + if e.IsDir() { + continue + } + if err := copyFile(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name()), mode); err != nil { + return err + } + } + return nil +} +func writeFilesToDir(dir string, files []SecretFile, mode os.FileMode) error { + if len(files) == 0 { + return nil + } + if err := os.MkdirAll(dir, 0750); err != nil { + return err + } + seen := map[string]bool{} + for _, f := range files { + f.Name = strings.TrimSpace(f.Name) + if !validSecretName.MatchString(f.Name) { + return fmt.Errorf("invalid managed file name %q", f.Name) + } + if seen[f.Name] { + return fmt.Errorf("duplicate managed file name %q", f.Name) + } + seen[f.Name] = true + if len(f.Content) > 512<<10 { + return fmt.Errorf("managed file %s too large", f.Name) + } + if err := os.WriteFile(filepath.Join(dir, f.Name), []byte(f.Content), mode); err != nil { + return err + } + } + return nil +} +func replaceManagedDir(stageDir, finalDir, sub string, files []SecretFile, mode os.FileMode) error { + dst := filepath.Join(finalDir, sub) + if len(files) == 0 { + return os.RemoveAll(dst) + } + if err := os.RemoveAll(dst); err != nil { + return err + } + return copyDir(filepath.Join(stageDir, sub), dst, mode) +} + +// SaveCompose preserves backwards compatibility with the first scaffold. +func (s *Service) SaveCompose(ctx context.Context, name, compose string) error { + return s.Save(ctx, name, SaveInput{Compose: compose}) +} + +func (s *Service) writeEnv(name, env string) error { + p, err := s.envPath(name) + if err != nil { + return err + } + if strings.TrimSpace(env) == "" { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + if len(env) > 512<<10 { + return errors.New(".env too large") + } + return os.WriteFile(p, []byte(env), 0640) +} +func (s *Service) writeSecrets(name string, secrets []SecretFile) error { + dir, err := s.secretsDir(name) + if err != nil { + return err + } + if len(secrets) == 0 { + return nil + } + if err := os.MkdirAll(dir, 0750); err != nil { + return err + } + for _, sec := range secrets { + sec.Name = strings.TrimSpace(sec.Name) + if !validSecretName.MatchString(sec.Name) { + return fmt.Errorf("invalid secret name %q", sec.Name) + } + if len(sec.Content) > 512<<10 { + return fmt.Errorf("secret %s too large", sec.Name) + } + if err := os.WriteFile(filepath.Join(dir, sec.Name), []byte(sec.Content), 0600); err != nil { + return err + } + } + return nil +} +func (s *Service) ReadSecrets(name string, includeContent bool) ([]SecretFile, error) { + dir, err := s.secretsDir(name) + if err != nil { + return nil, err + } + ents, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return []SecretFile{}, nil + } + if err != nil { + return nil, err + } + out := []SecretFile{} + for _, e := range ents { + if e.IsDir() || !validSecretName.MatchString(e.Name()) { + continue + } + info, _ := e.Info() + sf := SecretFile{Name: e.Name()} + if info != nil { + sf.Size = info.Size() + } + if includeContent { + b, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err == nil { + sf.Content = string(b) + } + } + out = append(out, sf) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func (s *Service) readManagedFiles(name, sub string, includeContent bool) ([]SecretFile, error) { + if !validName.MatchString(name) { + return nil, errors.New("invalid stack name") + } + dir := filepath.Join(s.root, name, sub) + ents, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return []SecretFile{}, nil + } + if err != nil { + return nil, err + } + out := []SecretFile{} + for _, e := range ents { + if e.IsDir() || !validSecretName.MatchString(e.Name()) { + continue + } + info, _ := e.Info() + f := SecretFile{Name: e.Name()} + if info != nil { + f.Size = info.Size() + } + if includeContent { + if b, er := os.ReadFile(filepath.Join(dir, e.Name())); er == nil { + f.Content = string(b) + } + } + out = append(out, f) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func (s *Service) Action(ctx context.Context, name, action string) (string, error) { + mu := s.lockFor(name) + mu.Lock() + defer mu.Unlock() + return s.actionUnlocked(ctx, name, action) +} + +func (s *Service) actionUnlocked(ctx context.Context, name, action string) (string, error) { + switch action { + case "up": + return s.runString(ctx, name, "up", "-d", "--remove-orphans") + case "down": + return s.runString(ctx, name, "down") + case "restart": + return s.runString(ctx, name, "restart") + case "stop": + return s.runString(ctx, name, "stop") + case "start": + return s.runString(ctx, name, "start") + case "pull": + return s.runString(ctx, name, "pull") + case "update": + a, e1 := s.runString(ctx, name, "pull") + b, e2 := s.runString(ctx, name, "up", "-d", "--remove-orphans") + if e1 != nil { + return a + "\n" + b, e1 + } + return a + "\n" + b, e2 + case "recreate": + return s.runString(ctx, name, "up", "-d", "--force-recreate", "--remove-orphans") + default: + return "", errors.New("unsupported action") + } +} +func (s *Service) Logs(ctx context.Context, name string, tail int) (string, error) { + if tail < 1 { + tail = 200 + } + if tail > 5000 { + tail = 5000 + } + return s.runString(ctx, name, "logs", "--no-color", "--tail", fmt.Sprint(tail)) +} +func (s *Service) StreamLogs(ctx context.Context, name string, tail int, w http.ResponseWriter) error { + if tail < 1 { + tail = 200 + } + if tail > 2000 { + tail = 2000 + } + p, err := s.path(name) + if err != nil { + return err + } + if _, err := os.Stat(p); err != nil { + return err + } + args := append(s.baseArgs(name), "logs", "--follow", "--no-color", "--tail", fmt.Sprint(tail)) + cmd := exec.CommandContext(ctx, "docker", args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + defer func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + }() + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-store") + w.Header().Set("X-Accel-Buffering", "no") + fl, _ := w.(http.Flusher) + lines := make(chan string, 256) + errCh := make(chan error, 3) + var wg sync.WaitGroup + scan := func(r io.Reader) { + defer wg.Done() + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 64<<10), 1<<20) + for sc.Scan() { + select { + case lines <- sc.Text(): + case <-ctx.Done(): + return + } + } + if err := sc.Err(); err != nil { + select { + case errCh <- err: + default: + } + } + } + wg.Add(2) + go scan(stdout) + go scan(stderr) + go func() { wg.Wait(); close(lines) }() + enc := json.NewEncoder(w) + for { + select { + case <-ctx.Done(): + _ = cmd.Wait() + return nil + case err := <-errCh: + if err != nil { + _ = cmd.Wait() + return err + } + case line, ok := <-lines: + if !ok { + err := cmd.Wait() + if err != nil && ctx.Err() == nil { + return err + } + return nil + } + fmt.Fprint(w, "data: ") + _ = enc.Encode(line) + fmt.Fprint(w, "\n") + if fl != nil { + fl.Flush() + } + } + } +} +func (s *Service) Exec(ctx context.Context, name string, in ExecInput) (string, error) { + in.Service = strings.TrimSpace(in.Service) + in.Command = strings.TrimSpace(in.Command) + if !validServiceName.MatchString(in.Service) || in.Command == "" { + return "", errors.New("valid service and command required") + } + if len(in.Command) > 4000 { + return "", errors.New("command too long") + } + return s.runString(ctx, name, "exec", "-T", in.Service, "sh", "-lc", in.Command) +} +func (s *Service) Delete(ctx context.Context, name string, down, purge bool) error { + mu := s.lockFor(name) + mu.Lock() + defer mu.Unlock() + p, err := s.path(name) + if err != nil { + return err + } + dir := filepath.Dir(p) + if down { + if _, err := s.actionUnlocked(ctx, name, "down"); err != nil { + return err + } + } + if purge { + return os.RemoveAll(dir) + } + // Safe delete removes only files managed by Dockwatch. Arbitrary bind-mount + // data living next to compose.yaml is never recursively deleted by default. + for _, entry := range []string{"compose.yaml", ".env", "secrets", "envs", "configs"} { + if err := os.RemoveAll(filepath.Join(dir, entry)); err != nil { + return err + } + } + // Git-backed stacks keep only Dockwatch's own manifest under .dockwatch. + // Never remove the whole directory because a repository/user may keep other + // metadata there. + manifest := filepath.Join(dir, ".dockwatch", "git-manifest.json") + if err := os.Remove(manifest); err != nil && !os.IsNotExist(err) { + return err + } + _ = os.Remove(filepath.Dir(manifest)) // succeeds only when empty + if ents, err := os.ReadDir(dir); err == nil && len(ents) == 0 { + _ = os.Remove(dir) + } + return nil +} +func (s *Service) PS(ctx context.Context, name string) ([]ServiceInfo, error) { + out, err := s.run(ctx, name, "ps", "--format", "json") + if err != nil { + return nil, err + } + var arr []map[string]any + dec := json.NewDecoder(bytes.NewReader(out)) + if err := dec.Decode(&arr); err != nil { + // Some docker compose versions output one JSON object per line. + var services []ServiceInfo + sc := bufio.NewScanner(bytes.NewReader(out)) + for sc.Scan() { + var m map[string]any + if json.Unmarshal(sc.Bytes(), &m) == nil { + services = append(services, mapService(m)) + } + } + if len(services) > 0 { + return services, nil + } + return nil, err + } + services := make([]ServiceInfo, 0, len(arr)) + for _, m := range arr { + services = append(services, mapService(m)) + } + return services, nil +} +func mapService(m map[string]any) ServiceInfo { + return ServiceInfo{ID: str(m, "ID", "Id"), Name: str(m, "Name"), Service: str(m, "Service"), State: strings.ToLower(str(m, "State")), Status: str(m, "Status"), Image: str(m, "Image"), Command: str(m, "Command"), Ports: str(m, "Publishers", "Ports")} +} +func str(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + switch x := v.(type) { + case string: + return x + default: + b, _ := json.Marshal(x) + return string(b) + } + } + } + return "" +} +func summarizeStatus(sv []ServiceInfo) string { + if len(sv) == 0 { + return "stopped" + } + running := 0 + bad := 0 + for _, s := range sv { + st := strings.ToLower(s.State + " " + s.Status) + if strings.Contains(st, "running") || strings.Contains(st, "up") { + running++ + } + if strings.Contains(st, "exit") || strings.Contains(st, "dead") || strings.Contains(st, "error") { + bad++ + } + } + if bad > 0 { + return "degraded" + } + if running == len(sv) { + return "running" + } + if running > 0 { + return "partial" + } + return "stopped" +} +func (s *Service) runString(ctx context.Context, name string, args ...string) (string, error) { + out, err := s.run(ctx, name, args...) + return string(out), err +} +func (s *Service) run(ctx context.Context, name string, args ...string) ([]byte, error) { + p, err := s.path(name) + if err != nil { + return nil, err + } + if _, err := os.Stat(p); err != nil { + return nil, err + } + cctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + cmd := exec.CommandContext(cctx, "docker", append(s.baseArgs(name), args...)...) + var b bytes.Buffer + cmd.Stdout = &b + cmd.Stderr = &b + err = cmd.Run() + if err != nil { + return b.Bytes(), fmt.Errorf("docker compose: %w: %s", err, strings.TrimSpace(b.String())) + } + return b.Bytes(), nil +} +func (s *Service) baseArgs(name string) []string { + p, _ := s.path(name) + args := []string{"compose", "--project-name", name, "-f", p} + if ep, err := s.envPath(name); err == nil { + if _, stat := os.Stat(ep); stat == nil { + args = append(args, "--env-file", ep) + } + } + return args +} + +// DockerInventory returns lightweight Docker CLI inventory records. It intentionally +// keeps Docker-specific fields as strings so different Engine versions remain compatible. +func (s *Service) DockerInventory(ctx context.Context, kind string) ([]map[string]string, error) { + var args []string + switch kind { + case "containers": + args = []string{"ps", "-a", "--format", "{{json .}}"} + case "images": + args = []string{"image", "ls", "--format", "{{json .}}"} + case "volumes": + args = []string{"volume", "ls", "--format", "{{json .}}"} + case "networks": + args = []string{"network", "ls", "--format", "{{json .}}"} + default: + return nil, errors.New("unsupported inventory kind") + } + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + out, err := exec.CommandContext(cctx, "docker", args...).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("docker inventory: %s", strings.TrimSpace(string(out))) + } + items := []map[string]string{} + sc := bufio.NewScanner(bytes.NewReader(out)) + for sc.Scan() { + var v map[string]string + if json.Unmarshal(sc.Bytes(), &v) == nil { + items = append(items, v) + } + } + return items, sc.Err() +} + +type DockerActionInput struct { + Name string `json:"name"` + Registry string `json:"registry"` + Username string `json:"username"` + Password string `json:"password"` + ID string `json:"id"` + Driver string `json:"driver"` + Force bool `json:"force"` + Internal bool `json:"internal"` + Attachable bool `json:"attachable"` + Labels map[string]string `json:"labels"` +} + +func safeDockerPositional(v, label string) (string, error) { + v = strings.TrimSpace(v) + if v == "" { + return "", fmt.Errorf("%s required", label) + } + if strings.HasPrefix(v, "-") || strings.ContainsAny(v, "\r\n\x00") || len(v) > 4096 { + return "", fmt.Errorf("invalid %s", label) + } + return v, nil +} + +func (s *Service) DockerAction(ctx context.Context, kind, action string, in DockerActionInput) (string, error) { + name := strings.TrimSpace(in.Name) + id := strings.TrimSpace(in.ID) + var args []string + switch kind { + case "containers": + target := id + if target == "" { + target = name + } + var err error + if target, err = safeDockerPositional(target, "container id/name"); err != nil { + return "", err + } + switch action { + case "start", "stop", "restart": + args = []string{action, target} + case "remove": + args = []string{"rm"} + if in.Force { + args = append(args, "-f") + } + args = append(args, target) + default: + return "", errors.New("unsupported container action") + } + case "images": + switch action { + case "login": + registry, err := safeDockerPositional(in.Registry, "registry") + user := strings.TrimSpace(in.Username) + if err != nil || user == "" || strings.ContainsAny(user, "\r\n\x00") || in.Password == "" { + return "", errors.New("valid registry, username and password required") + } + cctx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + cmd := exec.CommandContext(cctx, "docker", "login", registry, "--username", user, "--password-stdin") + cmd.Stdin = strings.NewReader(in.Password) + out, err := cmd.CombinedOutput() + msg := strings.TrimSpace(string(out)) + if err != nil { + return msg, fmt.Errorf("docker login: %s", fallbackOutput(out, err)) + } + return msg, nil + case "logout": + registry, err := safeDockerPositional(in.Registry, "registry") + if err != nil { + return "", err + } + return runDocker(ctx, "logout", registry) + case "pull": + var err error + if name, err = safeDockerPositional(name, "image reference"); err != nil { + return "", err + } + args = []string{"pull", name} + case "remove": + target := id + if target == "" { + target = name + } + var err error + if target, err = safeDockerPositional(target, "image id/reference"); err != nil { + return "", err + } + args = []string{"image", "rm"} + if in.Force { + args = append(args, "-f") + } + args = append(args, target) + case "prune": + args = []string{"image", "prune", "-f"} + default: + return "", errors.New("unsupported image action") + } + case "volumes": + switch action { + case "create": + var err error + if name, err = safeDockerPositional(name, "volume name"); err != nil { + return "", err + } + args = []string{"volume", "create"} + if in.Driver != "" { + args = append(args, "--driver", in.Driver) + } + args = appendLabels(args, in.Labels) + args = append(args, name) + case "remove": + target := name + if target == "" { + target = id + } + var err error + if target, err = safeDockerPositional(target, "volume name"); err != nil { + return "", err + } + args = []string{"volume", "rm"} + if in.Force { + args = append(args, "-f") + } + args = append(args, target) + case "prune": + args = []string{"volume", "prune", "-f"} + default: + return "", errors.New("unsupported volume action") + } + case "networks": + switch action { + case "create": + var err error + if name, err = safeDockerPositional(name, "network name"); err != nil { + return "", err + } + args = []string{"network", "create"} + if in.Driver != "" { + args = append(args, "--driver", in.Driver) + } + if in.Internal { + args = append(args, "--internal") + } + if in.Attachable { + args = append(args, "--attachable") + } + args = appendLabels(args, in.Labels) + args = append(args, name) + case "remove": + target := name + if target == "" { + target = id + } + var err error + if target, err = safeDockerPositional(target, "network name"); err != nil { + return "", err + } + args = []string{"network", "rm", target} + case "prune": + args = []string{"network", "prune", "-f"} + default: + return "", errors.New("unsupported network action") + } + default: + return "", errors.New("unsupported docker resource kind") + } + return runDocker(ctx, args...) +} +func appendLabels(args []string, labels map[string]string) []string { + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := labels[k] + if v == "" { + args = append(args, "--label", k) + } else { + args = append(args, "--label", k+"="+v) + } + } + return args +} +func runDocker(ctx context.Context, args ...string) (string, error) { + cctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + out, err := exec.CommandContext(cctx, "docker", args...).CombinedOutput() + msg := strings.TrimSpace(string(out)) + if err != nil { + if msg == "" { + msg = err.Error() + } + return msg, fmt.Errorf("docker %s: %s", strings.Join(args, " "), msg) + } + return msg, nil +} +func (s *Service) DockerInspect(ctx context.Context, kind, id string) (map[string]any, error) { + var err error + if id, err = safeDockerPositional(id, "docker resource id/name"); err != nil { + return nil, err + } + var args []string + switch kind { + case "containers": + args = []string{"inspect", id} + case "images": + args = []string{"image", "inspect", id} + case "volumes": + args = []string{"volume", "inspect", id} + case "networks": + args = []string{"network", "inspect", id} + default: + return nil, errors.New("unsupported inspect kind") + } + raw, err := runDocker(ctx, args...) + if err != nil { + return nil, err + } + var arr []map[string]any + if err := json.Unmarshal([]byte(raw), &arr); err != nil || len(arr) == 0 { + return nil, errors.New("invalid docker inspect response") + } + out := map[string]any{"inspect": arr[0]} + if kind == "containers" { + statsRaw, _ := runDocker(ctx, "stats", "--no-stream", "--format", "{{json .}}", id) + stats := map[string]string{} + _ = json.Unmarshal([]byte(statsRaw), &stats) + out["stats"] = stats + } + return out, nil +} + +type GraphNode struct { + ID string `json:"id"` + Kind string `json:"kind"` + Label string `json:"label"` + Image string `json:"image,omitempty"` + Running bool `json:"running,omitempty"` +} +type GraphEdge struct { + From string `json:"from"` + To string `json:"to"` + Kind string `json:"kind"` +} +type ServiceGraph struct { + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` +} + +// Graph is based on Docker Compose's normalized JSON output, so profiles, +// interpolation and long/short syntax are interpreted by Compose itself. +func (s *Service) Graph(ctx context.Context, name string) (ServiceGraph, error) { + out, err := s.run(ctx, name, "config", "--format", "json") + if err != nil { + return ServiceGraph{}, err + } + var cfg struct { + Services map[string]struct { + Image string `json:"image"` + DependsOn map[string]any `json:"depends_on"` + Networks map[string]any `json:"networks"` + Volumes []struct { + Source string `json:"source"` + Target string `json:"target"` + Type string `json:"type"` + } `json:"volumes"` + } `json:"services"` + Networks map[string]any `json:"networks"` + Volumes map[string]any `json:"volumes"` + } + if err := json.Unmarshal(out, &cfg); err != nil { + return ServiceGraph{}, fmt.Errorf("decode compose config: %w", err) + } + ps, _ := s.PS(ctx, name) + running := map[string]bool{} + for _, p := range ps { + if strings.Contains(strings.ToLower(p.State+" "+p.Status), "running") || strings.Contains(strings.ToLower(p.Status), "up") { + running[p.Service] = true + } + } + g := ServiceGraph{} + serviceNames := make([]string, 0, len(cfg.Services)) + for n := range cfg.Services { + serviceNames = append(serviceNames, n) + } + sort.Strings(serviceNames) + for _, n := range serviceNames { + sv := cfg.Services[n] + g.Nodes = append(g.Nodes, GraphNode{ID: "service:" + n, Kind: "service", Label: n, Image: sv.Image, Running: running[n]}) + deps := make([]string, 0, len(sv.DependsOn)) + for d := range sv.DependsOn { + deps = append(deps, d) + } + sort.Strings(deps) + for _, d := range deps { + g.Edges = append(g.Edges, GraphEdge{From: "service:" + n, To: "service:" + d, Kind: "depends_on"}) + } + for netName := range sv.Networks { + g.Edges = append(g.Edges, GraphEdge{From: "service:" + n, To: "network:" + netName, Kind: "network"}) + } + for _, v := range sv.Volumes { + if v.Type == "volume" && v.Source != "" { + g.Edges = append(g.Edges, GraphEdge{From: "service:" + n, To: "volume:" + v.Source, Kind: "volume"}) + } + } + } + nets := make([]string, 0, len(cfg.Networks)) + for n := range cfg.Networks { + nets = append(nets, n) + } + sort.Strings(nets) + for _, n := range nets { + g.Nodes = append(g.Nodes, GraphNode{ID: "network:" + n, Kind: "network", Label: n}) + } + vols := make([]string, 0, len(cfg.Volumes)) + for n := range cfg.Volumes { + vols = append(vols, n) + } + sort.Strings(vols) + for _, n := range vols { + g.Nodes = append(g.Nodes, GraphNode{ID: "volume:" + n, Kind: "volume", Label: n}) + } + return g, nil +} + +type ImageUpdate struct { + Service string `json:"service"` + Image string `json:"image"` + LocalDigest string `json:"local_digest,omitempty"` + RemoteDigest string `json:"remote_digest,omitempty"` + Update bool `json:"update"` + Error string `json:"error,omitempty"` + CheckedAt int64 `json:"checked_at"` +} + +func (s *Service) ImageUpdates(ctx context.Context, name string) ([]ImageUpdate, error) { + out, err := s.run(ctx, name, "config", "--format", "json") + if err != nil { + return nil, err + } + var cfg struct { + Services map[string]struct { + Image string `json:"image"` + } `json:"services"` + } + if err = json.Unmarshal(out, &cfg); err != nil { + return nil, err + } + names := make([]string, 0, len(cfg.Services)) + for n := range cfg.Services { + names = append(names, n) + } + sort.Strings(names) + res := make([]ImageUpdate, 0, len(names)) + for _, svc := range names { + img := strings.TrimSpace(cfg.Services[svc].Image) + r := ImageUpdate{Service: svc, Image: img, CheckedAt: time.Now().Unix()} + if img == "" { + r.Error = "service has no image" + res = append(res, r) + continue + } + if strings.Contains(img, "@sha256:") { + r.LocalDigest = strings.SplitN(img, "@", 2)[1] + r.RemoteDigest = r.LocalDigest + res = append(res, r) + continue + } + r.LocalDigest, _ = localImageDigest(ctx, img) + r.RemoteDigest, err = remoteImageDigest(ctx, img) + if err != nil { + r.Error = err.Error() + } else if r.LocalDigest != "" && r.RemoteDigest != "" { + r.Update = r.LocalDigest != r.RemoteDigest + } + res = append(res, r) + } + return res, nil +} +func localImageDigest(ctx context.Context, image string) (string, error) { + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + out, err := exec.CommandContext(cctx, "docker", "image", "inspect", "--format", "{{json .RepoDigests}}", image).CombinedOutput() + if err != nil { + return "", fmt.Errorf("local inspect: %s", strings.TrimSpace(string(out))) + } + var ds []string + if json.Unmarshal(bytes.TrimSpace(out), &ds) != nil || len(ds) == 0 { + return "", nil + } + for _, d := range ds { + if i := strings.LastIndex(d, "@sha256:"); i >= 0 { + return strings.TrimPrefix(d[i+1:], "@"), nil + } + } + return "", nil +} +func remoteImageDigest(ctx context.Context, image string) (string, error) { + cctx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + // buildx imagetools reports the top-level registry digest. This is important + // for multi-arch tags because a platform descriptor digest is not the same + // value as the RepoDigest stored by Docker for the manifest index. + if out, err := exec.CommandContext(cctx, "docker", "buildx", "imagetools", "inspect", image).CombinedOutput(); err == nil { + re := regexp.MustCompile(`(?m)^Digest:\s*(sha256:[a-fA-F0-9]{64})\s*$`) + if m := re.FindSubmatch(out); len(m) == 2 { + return string(m[1]), nil + } + } + out, err := exec.CommandContext(cctx, "docker", "manifest", "inspect", "--verbose", image).CombinedOutput() + if err != nil { + return "", fmt.Errorf("manifest inspect: %s", fallbackOutput(out, err)) + } + var v any + if e := json.Unmarshal(out, &v); e != nil { + return "", fmt.Errorf("manifest decode: %w", e) + } + if d := descriptorDigest(v); d != "" { + return d, nil + } + return "", errors.New("registry manifest did not expose a digest") +} +func descriptorDigest(v any) string { + switch x := v.(type) { + case map[string]any: + if d, ok := x["Descriptor"].(map[string]any); ok { + if s, ok := d["digest"].(string); ok { + return s + } + } + if s, ok := x["digest"].(string); ok && strings.HasPrefix(s, "sha256:") { + return s + } + for _, k := range []string{"descriptor", "manifests"} { + if z, ok := x[k]; ok { + if d := descriptorDigest(z); d != "" { + return d + } + } + } + case []any: + for _, z := range x { + if d := descriptorDigest(z); d != "" { + return d + } + } + } + return "" +} +func fallbackOutput(out []byte, err error) string { + v := strings.TrimSpace(string(out)) + if v == "" { + return err.Error() + } + return v +} + +type TerminalMessage struct { + Type string `json:"type"` + Data string `json:"data,omitempty"` + Cols uint16 `json:"cols,omitempty"` + Rows uint16 `json:"rows,omitempty"` + Code int `json:"code,omitempty"` +} + +func allowedShell(v string) string { + v = strings.TrimSpace(v) + switch v { + case "bash", "/bin/bash", "sh", "/bin/sh", "ash", "/bin/ash", "zsh", "/bin/zsh": + return v + default: + return "sh" + } +} + +// Terminal runs docker compose exec inside a real PTY and bridges the PTY to +// a WebSocket. Client messages are JSON {type:input,data:"..."} or +// {type:resize,cols:120,rows:40}. Server output uses {type:output,data:"..."}. +func (s *Service) Terminal(ctx context.Context, name, service, shell string, ws *websocket.Conn) error { + service = strings.TrimSpace(service) + if !validServiceName.MatchString(service) { + return errors.New("valid service required") + } + p, err := s.path(name) + if err != nil { + return err + } + if _, err := os.Stat(p); err != nil { + return err + } + args := append(s.baseArgs(name), "exec", service, allowedShell(shell)) + cmd := exec.CommandContext(ctx, "docker", args...) + ptmx, err := pty.Start(cmd) + if err != nil { + return fmt.Errorf("start terminal: %w", err) + } + defer func() { + _ = ptmx.Close() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }() + _ = pty.Setsize(ptmx, &pty.Winsize{Cols: 120, Rows: 32}) + writeMu := sync.Mutex{} + writeJSON := func(v TerminalMessage) error { writeMu.Lock(); defer writeMu.Unlock(); return ws.WriteJSON(v) } + done := make(chan error, 2) + go func() { + buf := make([]byte, 16<<10) + for { + n, e := ptmx.Read(buf) + if n > 0 { + if werr := writeJSON(TerminalMessage{Type: "output", Data: string(buf[:n])}); werr != nil { + done <- werr + return + } + } + if e != nil { + done <- e + return + } + } + }() + go func() { + for { + var m TerminalMessage + if e := ws.ReadJSON(&m); e != nil { + done <- e + return + } + switch m.Type { + case "input": + if _, e := io.WriteString(ptmx, m.Data); e != nil { + done <- e + return + } + case "resize": + if m.Cols > 0 && m.Rows > 0 { + _ = pty.Setsize(ptmx, &pty.Winsize{Cols: m.Cols, Rows: m.Rows}) + } + } + } + }() + select { + case <-ctx.Done(): + return nil + case e := <-done: + _ = writeJSON(TerminalMessage{Type: "exit"}) + return e + } +} diff --git a/internal/stacks/stacks_test.go b/internal/stacks/stacks_test.go new file mode 100644 index 0000000..b0c7048 --- /dev/null +++ b/internal/stacks/stacks_test.go @@ -0,0 +1,141 @@ +package stacks + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPathRejectsTraversal(t *testing.T) { + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"../x", "/tmp/x", "a/b", ""} { + if _, err := s.path(name); err == nil { + t.Fatalf("expected %q to be rejected", name) + } + } + if _, err := s.path("my-stack_1.2"); err != nil { + t.Fatalf("valid name rejected: %v", err) + } +} + +func TestSaveStagesEnvAndSecretsBeforeComposeValidation(t *testing.T) { + root := t.TempDir() + bin := t.TempDir() + docker := filepath.Join(bin, "docker") + script := `#!/bin/sh +set -eu +[ -f .env ] +[ -f secrets/api_key ] +[ "$(cat secrets/api_key)" = "supersecret" ] +exit 0 +` + if err := os.WriteFile(docker, []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + s, err := New(root) + if err != nil { + t.Fatal(err) + } + in := SaveInput{Compose: "services:\n app:\n image: nginx:alpine\n env_file: .env\nsecrets:\n api_key:\n file: ./secrets/api_key\n", Env: "A=B\n", Secrets: []SecretFile{{Name: "api_key", Content: "supersecret"}}} + if err := s.Save(context.Background(), "demo", in); err != nil { + t.Fatalf("save: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "demo", "compose.yaml")); err != nil { + t.Fatal(err) + } + if b, err := os.ReadFile(filepath.Join(root, "demo", "secrets", "api_key")); err != nil || string(b) != "supersecret" { + t.Fatalf("secret not committed: %q %v", string(b), err) + } +} + +func TestSaveReportsDockerExecErrorWhenComposeIsSilent(t *testing.T) { + root := t.TempDir() + bin := t.TempDir() + docker := filepath.Join(bin, "docker") + if err := os.WriteFile(docker, []byte("#!/bin/sh\nexit 7\n"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + s, _ := New(root) + err := s.Save(context.Background(), "demo", SaveInput{Compose: "services:\n app:\n image: nginx\n"}) + if err == nil || !strings.Contains(err.Error(), "exit status 7") { + t.Fatalf("expected useful exit error, got %v", err) + } +} + +func TestDescriptorDigestPrefersDescriptor(t *testing.T) { + v := map[string]any{"Descriptor": map[string]any{"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + got := descriptorDigest(v) + if got != "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + t.Fatalf("got %q", got) + } +} + +func TestDeletePreservesUnmanagedStackDataByDefault(t *testing.T) { + root := t.TempDir() + s, err := New(root) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(root, "demo") + if err := os.MkdirAll(filepath.Join(dir, "data"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "data", "important.db"), []byte("keep"), 0640); err != nil { + t.Fatal(err) + } + if err := s.Delete(context.Background(), "demo", false, false); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "compose.yaml")); !os.IsNotExist(err) { + t.Fatalf("managed compose file should be removed, got %v", err) + } + if b, err := os.ReadFile(filepath.Join(dir, "data", "important.db")); err != nil || string(b) != "keep" { + t.Fatalf("unmanaged bind-mount data was damaged: %q %v", b, err) + } +} + +func TestSafeDockerPositionalRejectsOptionLikeValues(t *testing.T) { + for _, v := range []string{"--help", "-f", "bad\nvalue"} { + if _, err := safeDockerPositional(v, "target"); err == nil { + t.Fatalf("expected %q to be rejected", v) + } + } + if got, err := safeDockerPositional("sha256:abc", "target"); err != nil || got != "sha256:abc" { + t.Fatalf("valid target rejected: %q %v", got, err) + } +} + +func TestExecRejectsOptionLikeServiceName(t *testing.T) { + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := s.Exec(context.Background(), "demo", ExecInput{Service: "--index", Command: "id"}); err == nil { + t.Fatal("expected invalid service name to be rejected before invoking Docker") + } +} + +func TestStackPathRejectsSymlinkDirectory(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "demo")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + s, err := New(root) + if err != nil { + t.Fatal(err) + } + if _, err := s.path("demo"); err == nil { + t.Fatal("expected symlink stack directory to be rejected") + } +}