// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package containers import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "syscall" "time" "git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "github.com/docker/docker/api/types/image" registrytypes "github.com/docker/docker/api/types/registry" ) // InstallContainer downloads the raw docker-compose.yaml and pulls the Docker image. func InstallContainer(ctx context.Context, item types.RegistryContainer) error { // 1. Determine storage path (XDG_DATA_HOME or ~/.dcntrlized/[name]/) targetDir, err := getContainerDataDir(item.Name) if err != nil { return fmt.Errorf("failed to determine data directory: %w", err) } if err := os.MkdirAll(targetDir, 0755); err != nil { return fmt.Errorf("failed to create directory %s: %w", targetDir, err) } // 2. Fetch raw docker-compose.yaml from the Git URL rawURL, err := composeRawURL(item) if err != nil { return fmt.Errorf("failed to resolve repository URL: %w", err) } composePath := filepath.Join(targetDir, "docker-compose.yaml") logger.Info("Fetching compose file from %s...", rawURL) if err := downloadCompose(ctx, rawURL, composePath); err != nil { return fmt.Errorf("failed to download docker-compose.yaml: %w", err) } // 2.5. Persist install metadata so installed-but-not-running state can be tracked without Docker lookups // The version is required for update detection; fall back to the image tag when the payload omitted it. if item.Version == "" { item.Version = imageTag(item.Image) } metadata := types.InstalledMetadata{ RegistryContainer: item, InstalledAt: time.Now().UTC().Format(time.RFC3339), } // 2.6. Pin the auth service's signing key (security theme M2 §5): fetch its // attestation over plaintext HTTP and store the fingerprint in the install // record so the container gets AUTH_PINNED_FINGERPRINT at start. This is an // app-level fingerprint comparison, not a transport identity. Best-effort — // when the auth service is not reachable yet the pin stays empty and is // retried on start. if att, err := fetchAuthAttestation(ctx); err == nil { metadata.AuthFingerprint = att.Fingerprint metadata.AuthKid = att.Kid metadata.AuthAttestedAt = att.IssuedAt } else { logger.Warn("Install %s: auth attestation not pinned (will retry at start): %v", item.Name, err) } metadataPath := filepath.Join(targetDir, "metadata.json") if err := writeMetadata(metadataPath, metadata); err != nil { return fmt.Errorf("failed to write metadata: %w", err) } // 3. Pull the Docker Image with Registry Authentication logger.Info("Pulling docker image %s...", item.Image) cli, err := docker.NewDockerClient() if err != nil { return fmt.Errorf("failed to initialize docker client: %w", err) } defer cli.Close() pullOpts := image.PullOptions{} // Build auth payload if git credentials or registry auth env vars exist authHeader, err := buildRegistryAuthHeader() if err != nil { return fmt.Errorf("failed to create registry auth: %w", err) } if authHeader != "" { pullOpts.RegistryAuth = authHeader } reader, err := cli.ImagePull(ctx, item.Image, pullOpts) if err != nil { return fmt.Errorf("failed to pull docker image %s: %w", item.Image, err) } defer reader.Close() // Drain reader stream to ensure Docker finishes downloading layers _, _ = io.Copy(io.Discard, reader) logger.Info("Successfully installed container service: %s", item.Name) return nil } // buildRegistryAuthHeader builds the base64-encoded auth header required by Docker SDK func buildRegistryAuthHeader() (string, error) { username := os.Getenv("REGISTRY_USER") token := os.Getenv("GIT_TOKEN") if token == "" { token = os.Getenv("GITHUB_TOKEN") } // For Gitea/Forgejo container registries, token auth works using "token" or your username as the user, and GIT_TOKEN as password. if username == "" { username = "oauth2" // Fallback username when using personal access tokens } if token == "" { return "", nil // No token set; proceed unauthenticated } authConfig := registrytypes.AuthConfig{ Username: username, Password: token, } encodedJSON, err := json.Marshal(authConfig) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(encodedJSON), nil } // getDataRoot resolves the directory that stores installed container metadata, // using the first variable that is set. DCNTRLIZED_INSTALL_DIR is used as-is; // the others are base dirs with ".dcntrlized" appended. func getDataRoot() (string, error) { if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" { return dir, nil } if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { return filepath.Join(xdg, ".dcntrlized"), nil } if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" { return filepath.Join(xdgHome, ".dcntrlized"), nil } if home := os.Getenv("HOME"); home != "" { return filepath.Join(home, ".dcntrlized"), nil } return filepath.Join("/data", ".dcntrlized"), nil } // DataRoot is the exported accessor for the storage root shared by installed // container metadata and Maestro's private key material (instance identity, // session/secrets keys, config-signing keypair). func DataRoot() (string, error) { return getDataRoot() } // EnsureDataRoot creates the data root directory if it does not exist yet. // This gives a clear, early error when the install directory is missing or not // writable (e.g. the container's bind mount source was removed) instead of a // confusing "no such file or directory" when installing a container. // // When started as root inside the container it hands the data root over to the // host user (discovered at runtime, or overridden via DCNTRLIZED_UID/GID) and // drops privileges, so every file created afterwards stays user-owned. func EnsureDataRoot() error { root, err := getDataRoot() if err != nil { return err } if err := os.MkdirAll(root, 0755); err != nil { return fmt.Errorf("failed to create data root %s (is the host directory present and writable?): %w", root, err) } if os.Geteuid() == 0 { identity, err := resolveRuntimeIdentity(root) if err != nil { return err } if err := healOwnerState(root, identity); err != nil { return fmt.Errorf("failed to hand owner state dir to uid %d: %w", identity.uid, err) } if err := os.Chown(root, identity.uid, identity.gid); err != nil { return fmt.Errorf("failed to chown data root %s to %d:%d: %w", root, identity.uid, identity.gid, err) } if err := dropPrivileges(identity, root); err != nil { return err } } return nil } // healOwnerState gives the instance-owner gate's state directory to the // runtime user. Boots before the privilege drop opened the gate's stores as // root first, leaving a root-owned /maestro behind; after the handoff // every write then failed with EACCES (surfacing as a bogus // ERR_INVALID_CREDENTIALS on /api/auth/setup). Repairs such installs here, // while the process can still chown. Missing dirs are fine — nothing to heal. func healOwnerState(root string, id runtimeIdentity) error { dir := filepath.Join(root, "maestro") entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("read owner state dir: %w", err) } if err := os.Chown(dir, id.uid, id.gid); err != nil { return fmt.Errorf("chown %s: %w", dir, err) } for _, e := range entries { p := filepath.Join(dir, e.Name()) if err := os.Chown(p, id.uid, id.gid); err != nil && !os.IsNotExist(err) { return fmt.Errorf("chown %s: %w", p, err) } } return nil } // runtimeIdentity is the user the container should run as, plus any // supplementary groups needed to keep working (e.g. the docker socket group). type runtimeIdentity struct { username string hostDir string uid, gid int groups []int } // resolveRuntimeIdentity determines the user to hand the data root to. // An explicit DCNTRLIZED_UID/GID wins; otherwise the host user is discovered // from the bind-mounted home directory, falling back to the data root's owner // (self-heals upgrades) and finally to uid 1000. func resolveRuntimeIdentity(dataRoot string) (runtimeIdentity, error) { username := filepath.Base(hostPath(mountSource("/host-home"))) if username == "" || username == "." || username == "/" { username = "user" } hostDir := hostPath(mountSource(dataRoot)) if hostDir == "" { hostDir = "(unknown)" } var id runtimeIdentity if uidStr := os.Getenv("DCNTRLIZED_UID"); uidStr != "" { uid, err := strconv.Atoi(uidStr) if err != nil { return runtimeIdentity{}, fmt.Errorf("invalid DCNTRLIZED_UID %q: %w", uidStr, err) } gid, err := strconv.Atoi(os.Getenv("DCNTRLIZED_GID")) if err != nil { gid = uid } id = runtimeIdentity{uid: uid, gid: gid, groups: dockerSocketGroups(uid)} } else { uid, gid, err := discoverHostUser(dataRoot) if err != nil { return runtimeIdentity{}, err } id = runtimeIdentity{uid: uid, gid: gid, groups: dockerSocketGroups(uid)} } id.username, id.hostDir = username, hostDir logger.Info("User: %s (%d, %d)", id.username, id.uid, id.gid) return id, nil } // discoverHostUser finds the host user's uid/gid, preferring the read-only // bind mount of the host home directory, then the data root's owner. func discoverHostUser(dataRoot string) (uid, gid int, err error) { if uid, gid, ok := statOwner("/host-home"); ok && uid != 0 { logger.Debug("Host user resolved from /host-home") return uid, gid, nil } if uid, gid, ok := statOwner(dataRoot); ok && uid != 0 { logger.Debug("Host user resolved from data root owner") return uid, gid, nil } logger.Warn("Could not determine host user (no /host-home or owned data root); defaulting to uid 1000") return 1000, 1000, nil } // statOwner returns the numeric uid/gid owning the given path. func statOwner(path string) (uid, gid int, ok bool) { st, err := os.Stat(path) if err != nil { return 0, 0, false } sys, ok := st.Sys().(*syscall.Stat_t) if !ok { return 0, 0, false } return int(sys.Uid), int(sys.Gid), true } // mountSource returns the host source path of the bind mount at mountPoint by // parsing /proc/self/mountinfo, or "" when the mount does not exist. func mountSource(mountPoint string) string { data, err := os.ReadFile("/proc/self/mountinfo") if err != nil { return "" } for _, line := range strings.Split(string(data), "\n") { fields := strings.Fields(line) if len(fields) < 5 { continue } // Format: ... // For bind mounts, is the source path on the host. if fields[4] == mountPoint { return unescapeMountPath(fields[3]) } } return "" } // unescapeMountPath decodes the octal escapes used in mountinfo paths (e.g. \040). func unescapeMountPath(s string) string { if !strings.Contains(s, `\`) { return s } var b strings.Builder for i := 0; i < len(s); i++ { if s[i] == '\\' && i+3 < len(s) { if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil { b.WriteByte(byte(v)) i += 3 continue } } b.WriteByte(s[i]) } return b.String() } // hostPath converts a mount source into a host-visible path. Btrfs encodes // subvolumes as "@name/..." (e.g. "@home/franciscofadigas" mounted at // "/home/franciscofadigas"); ordinary sources already start with "/". func hostPath(source string) string { if source == "" { return "" } if strings.HasPrefix(source, "/@") { return "/" + strings.TrimPrefix(source, "/@") } if strings.HasPrefix(source, "@") { return "/" + strings.TrimPrefix(source, "@") } return source } // dockerSocketPath is where the host's docker socket is mounted into the container. const dockerSocketPath = "/var/run/docker.sock" // dockerSocketGroups returns the group of the mounted docker socket when it is // group-accessible, so the container can keep talking to it after dropping // privileges without hardcoding the host's docker group id. func dockerSocketGroups(runAsUID int) []int { st, err := os.Stat(dockerSocketPath) if err != nil { return nil } if st.Mode()&0o020 == 0 { return nil } sys, ok := st.Sys().(*syscall.Stat_t) if !ok || sys.Gid == 0 || int(sys.Gid) == runAsUID { return nil } return []int{int(sys.Gid)} } // dropPrivileges switches the process to the given uid/gid, keeping the // supplementary groups needed for the docker socket. func dropPrivileges(id runtimeIdentity, guestDir string) error { if err := syscall.Setgroups(id.groups); err != nil { return fmt.Errorf("failed to set supplementary groups %v: %w", id.groups, err) } if err := syscall.Setgid(id.gid); err != nil { return fmt.Errorf("failed to set gid to %d: %w", id.gid, err) } if err := syscall.Setuid(id.uid); err != nil { return fmt.Errorf("failed to set uid to %d: %w", id.uid, err) } if os.Geteuid() != id.uid { return fmt.Errorf("failed to drop privileges: still running as euid %d (want %d)", os.Geteuid(), id.uid) } logger.Info("Handoff %s (%s) %d:%d", guestDir, id.hostDir, id.uid, id.gid) return nil } func getContainerDataDir(serviceName string) (string, error) { root, err := getDataRoot() if err != nil { return "", err } return filepath.Join(root, serviceName), nil } // composeRawURL resolves the raw docker-compose.yaml URL for a container, // preferring the repository URL in item.URL (falling back to item.Source) and // rejecting URLs without an http/https scheme. func composeRawURL(item types.RegistryContainer) (string, error) { base := strings.TrimRight(item.URL, "/") if base == "" { base = strings.TrimRight(item.Source, "/") } u, err := url.Parse(base) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return "", fmt.Errorf("invalid repository URL %q", base) } return fmt.Sprintf("%s/raw/branch/master/docker-compose.yaml", base), nil } // downloadCompose fetches the compose file, retrying on the main branch when // the repository's default branch is not master. func downloadCompose(ctx context.Context, url, destPath string) error { err := downloadFile(ctx, url, destPath) if err != nil { mainURL := strings.Replace(url, "/raw/branch/master/", "/raw/branch/main/", 1) if mainURL != url { if retryErr := downloadFile(ctx, mainURL, destPath); retryErr == nil { return nil } } return err } return nil } func downloadFile(ctx context.Context, url string, destPath string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } token := os.Getenv("GIT_TOKEN") if token == "" { token = os.Getenv("GITHUB_TOKEN") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return fmt.Errorf("authentication failed (HTTP %d): ensure GIT_TOKEN is set and has access to the repository", resp.StatusCode) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected HTTP status fetching compose file: %s", resp.Status) } out, err := os.Create(destPath) if err != nil { return err } defer out.Close() _, err = io.Copy(out, resp.Body) return err } func writeMetadata(path string, metadata types.InstalledMetadata) error { data, err := json.MarshalIndent(metadata, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0644) } // fetchAuthAttestation fetches the auth service's public key statement over // plaintext HTTP (rev 9 removed the mTLS identity layer). It backs the M2 §5 // app-level pin: the returned fingerprint is what consumer containers are // pinned to. func fetchAuthAttestation(ctx context.Context) (*authmgmt.Attestation, error) { client, err := authmgmt.New() if err != nil { return nil, fmt.Errorf("auth management client: %w", err) } fetchCtx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() att, err := client.Attestation(fetchCtx) if err != nil { return nil, err } if att.Fingerprint == "" { return nil, fmt.Errorf("attestation carried an empty fingerprint") } return att, nil }