- lib/pki: root CA provisioning on first run (~/.dcntrlized/pki/), per-service leaf identity issuance (90d, refreshed at start), revocation with CRL - main: EnsureCA on startup - start: inject CA + identity mounts and AUTH_* mTLS env into compose services - uninstall: revoke the service's cert (CRL entry published)
260 lines
8.6 KiB
Go
260 lines
8.6 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"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
|
"github.com/docker/compose/v2/pkg/api"
|
|
)
|
|
|
|
// identityMountDir is where Maestro bind-mounts the instance CA and the
|
|
// service's identity certificate inside every peer container.
|
|
const identityMountDir = "/dcntrlized/pki"
|
|
|
|
// StartContainer creates and starts the service containers defined in the
|
|
// service's docker-compose.yaml.
|
|
func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
|
dc, err := docker.NewDockerClient()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize docker client: %w", err)
|
|
}
|
|
defer dc.Close()
|
|
|
|
serviceName := resolveServiceName(ctx, dc, item.Name)
|
|
project, err := loadComposeProject(ctx, serviceName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Infrastructure first: the external dcntrlized_net network must exist
|
|
// before any compose project is started, and services that depend on the
|
|
// shared database need it running before they boot.
|
|
if err := EnsureDCNTRLIZEDNetwork(ctx, dc); err != nil {
|
|
return err
|
|
}
|
|
if serviceName != DatabaseServiceName {
|
|
if err := EnsureDatabaseRunning(ctx); err != nil {
|
|
logger.Warn("Failed to ensure shared database before starting %s: %v", serviceName, err)
|
|
}
|
|
}
|
|
|
|
// Issue (or refresh) the service's identity certificate from the instance
|
|
// CA and mount it plus the CA bundle into the container. The compose file
|
|
// downloaded from git never declares these mounts — Maestro injects them
|
|
// (security theme M1 §2). Failing to provision identity is fatal: a peer
|
|
// without a cert cannot authenticate on the mTLS edges.
|
|
if err := injectServiceIdentity(project, serviceName); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The compose file may reference the image by a short name (e.g.
|
|
// "testservice") while install pulled it under its full registry reference.
|
|
// Make sure those short names exist so Up() can find them.
|
|
installedImage := item.Image
|
|
if installedImage == "" {
|
|
if dir, dirErr := getContainerDataDir(serviceName); dirErr == nil {
|
|
if metadata, metaErr := readInstalledMetadata(dir); metaErr == nil {
|
|
installedImage = metadata.Image
|
|
}
|
|
}
|
|
}
|
|
pinServiceImages(project, installedImage)
|
|
ensureServiceImages(ctx, dc, project, installedImage)
|
|
|
|
composeService, err := newComposeService(dc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
logger.Info("Starting service %s...", serviceName)
|
|
err = composeService.Up(ctx, project, api.UpOptions{
|
|
Create: api.CreateOptions{
|
|
Recreate: api.RecreateDiverged,
|
|
AssumeYes: true,
|
|
},
|
|
Start: api.StartOptions{},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start service %s: %w", serviceName, err)
|
|
}
|
|
|
|
logger.Info("Successfully started service: %s", serviceName)
|
|
return nil
|
|
}
|
|
|
|
// injectServiceIdentity issues (or refreshes) the service's identity
|
|
// certificate from the instance CA and injects the CA bundle, the identity
|
|
// cert/key, and the mTLS env vars into every non-oneoff compose service. The
|
|
// env vars follow the shared-module contract (AUTH_CA_BUNDLE/AUTH_CLIENT_CERT/
|
|
// AUTH_CLIENT_KEY) so consumers need no per-service config.
|
|
func injectServiceIdentity(project *composetypes.Project, serviceName string) error {
|
|
dataRoot, err := getDataRoot()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve data root: %w", err)
|
|
}
|
|
|
|
ca, err := pki.EnsureCA(dataRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("ensure instance CA: %w", err)
|
|
}
|
|
identity, err := pki.EnsureServiceIdentity(ca, serviceName)
|
|
if err != nil {
|
|
return fmt.Errorf("provision identity for %s: %w", serviceName, err)
|
|
}
|
|
|
|
mounts := []string{
|
|
fmt.Sprintf("%s:%s/ca.crt:ro", pki.CertPath(dataRoot), identityMountDir),
|
|
fmt.Sprintf("%s:%s/tls.crt:ro", identity.CertPath, identityMountDir),
|
|
fmt.Sprintf("%s:%s/tls.key:ro", identity.KeyPath, identityMountDir),
|
|
}
|
|
env := map[string]string{
|
|
"SERVICE_NAME": serviceName,
|
|
"AUTH_CA_BUNDLE": identityMountDir + "/ca.crt",
|
|
"AUTH_CLIENT_CERT": identityMountDir + "/tls.crt",
|
|
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
|
|
}
|
|
|
|
for name, s := range project.Services {
|
|
if isOneoff(s) {
|
|
continue
|
|
}
|
|
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
|
|
s.Environment = mergeEnv(s.Environment, env)
|
|
project.Services[name] = s
|
|
}
|
|
logger.Info("Injected service identity for %s (fingerprint %s)", serviceName, identity.Meta.Fingerprint)
|
|
return nil
|
|
}
|
|
|
|
// composeVolumes converts "src:dst:ro" mount strings to compose volume configs.
|
|
func composeVolumes(mounts []string) []composetypes.ServiceVolumeConfig {
|
|
out := make([]composetypes.ServiceVolumeConfig, 0, len(mounts))
|
|
for _, m := range mounts {
|
|
parts := strings.SplitN(m, ":", 3)
|
|
v := composetypes.ServiceVolumeConfig{
|
|
Type: "bind",
|
|
Source: parts[0],
|
|
Target: parts[1],
|
|
}
|
|
if len(parts) == 3 && strings.Contains(parts[2], "ro") {
|
|
v.ReadOnly = true
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isOneoff reports whether a compose service is a one-shot helper (e.g. the
|
|
// migrate container) via the org.dcntrlized.oneoff label the compose file
|
|
// declares.
|
|
func isOneoff(s composetypes.ServiceConfig) bool {
|
|
if s.CustomLabels == nil {
|
|
return false
|
|
}
|
|
v, ok := s.CustomLabels["org.dcntrlized.oneoff"]
|
|
if !ok {
|
|
v = s.Labels["org.dcntrlized.oneoff"]
|
|
}
|
|
return v == "true"
|
|
}
|
|
|
|
// mergeEnv merges a map into the compose service's env slice, overriding any
|
|
// existing value with the same key.
|
|
func mergeEnv(existing map[string]*string, add map[string]string) map[string]*string {
|
|
if existing == nil {
|
|
existing = map[string]*string{}
|
|
}
|
|
for k, v := range add {
|
|
val := v
|
|
existing[k] = &val
|
|
}
|
|
return existing
|
|
}
|
|
|
|
// pinServiceImages rewrites each compose service's image reference to the exact
|
|
// installed image (e.g. git.../testservice:3.4.0) when its base name matches, so
|
|
// containers run pinned to a concrete semver tag instead of a moving "latest" tag.
|
|
func pinServiceImages(project *composetypes.Project, installedImage string) {
|
|
if installedImage == "" {
|
|
return
|
|
}
|
|
for name, s := range project.Services {
|
|
if s.Image == "" || s.Image == installedImage {
|
|
continue
|
|
}
|
|
if baseImageName(s.Image) != baseImageName(installedImage) {
|
|
continue
|
|
}
|
|
logger.Info("Pinning service %s image %s to installed image %s", name, s.Image, installedImage)
|
|
s.Image = installedImage
|
|
project.Services[name] = s
|
|
}
|
|
}
|
|
|
|
// ensureServiceImages makes sure every image referenced by the compose project
|
|
// exists locally. Registries install the image under its full reference (e.g.
|
|
// git.../testservice:3.4) while the service's compose file often references it
|
|
// by a short name (e.g. "testservice"); when the short name is missing locally
|
|
// and its base name matches the installed image, it is tagged so start works.
|
|
func ensureServiceImages(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, installedImage string) {
|
|
if installedImage == "" {
|
|
return
|
|
}
|
|
for name, s := range project.Services {
|
|
ref := s.Image
|
|
if ref == "" || ref == installedImage {
|
|
continue
|
|
}
|
|
if imageExists(ctx, dc, ref) {
|
|
continue
|
|
}
|
|
if baseImageName(ref) != baseImageName(installedImage) {
|
|
continue
|
|
}
|
|
logger.Info("Tagging %s as %s for compose service %s", installedImage, ref, name)
|
|
if err := dc.Client.ImageTag(ctx, installedImage, ref); err != nil {
|
|
logger.Warn("Failed to tag %s as %s: %v", installedImage, ref, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// imageExists reports whether an image reference exists locally.
|
|
func imageExists(ctx context.Context, dc *docker.DockerClient, ref string) bool {
|
|
_, _, err := dc.Client.ImageInspectWithRaw(ctx, ref)
|
|
return err == nil
|
|
}
|
|
|
|
// baseImageName strips the registry/namespace and tag from an image reference,
|
|
// returning the repository name (e.g. "testservice").
|
|
func baseImageName(ref string) string {
|
|
base := ref
|
|
if i := strings.LastIndex(base, "/"); i >= 0 {
|
|
base = base[i+1:]
|
|
}
|
|
if i := strings.LastIndex(base, ":"); i >= 0 {
|
|
base = base[:i]
|
|
}
|
|
return base
|
|
}
|