feat(security): instance CA + service identity certs (M1)
- 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)
This commit is contained in:
@@ -22,11 +22,16 @@ import (
|
||||
|
||||
"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 {
|
||||
@@ -54,6 +59,15 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -89,6 +103,95 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user