The dashboard sees containers Maestro did not create (each repo dev debug compose), and treating them as installs made start/uninstall/config act on a project file that never created them. Mark them external, refuse start/update/uninstall with a message pointing at their own compose project, and let stop work at the container level. Inject runtime env (config-signing public key, plaintext shared-DB DSN) on the managed start path, and keep the external flag a property of the service so a debug sibling cannot flip a managed service.
524 lines
17 KiB
Go
524 lines
17 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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 <root>/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: <id> <parent> <major:minor> <root> <mountpoint> <opts> ...
|
|
// For bind mounts, <root> 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
|
|
}
|