From 96cfa68ef5a726a51b987f39245bb2e24d79751a Mon Sep 17 00:00:00 2001 From: Harry Pidcock Date: Fri, 7 Aug 2026 14:46:02 +1000 Subject: [PATCH 1/9] feat: add trust-context to plan and layer schema --- internals/overlord/planstate/manager.go | 11 +- internals/overlord/servstate/manager_test.go | 22 +-- internals/plan/extensions_test.go | 9 +- internals/plan/plan.go | 175 +++++++++++++++---- internals/plan/plan_test.go | 147 +++++++++------- 5 files changed, 249 insertions(+), 115 deletions(-) diff --git a/internals/overlord/planstate/manager.go b/internals/overlord/planstate/manager.go index 3767e95a3..59f5654ce 100644 --- a/internals/overlord/planstate/manager.go +++ b/internals/overlord/planstate/manager.go @@ -255,11 +255,12 @@ func (m *PlanManager) updatePlanLayers(layers []*plan.Layer) (*plan.Plan, error) return nil, err } p := &plan.Plan{ - Layers: layers, - Services: combined.Services, - Checks: combined.Checks, - LogTargets: combined.LogTargets, - Sections: combined.Sections, + Layers: layers, + Services: combined.Services, + Checks: combined.Checks, + LogTargets: combined.LogTargets, + TrustContexts: combined.TrustContexts, + Sections: combined.Sections, } err = p.Validate() if err != nil { diff --git a/internals/overlord/servstate/manager_test.go b/internals/overlord/servstate/manager_test.go index 7e1ea2a4b..6d599be91 100644 --- a/internals/overlord/servstate/manager_test.go +++ b/internals/overlord/servstate/manager_test.go @@ -1975,11 +1975,12 @@ func (s *S) tryPlanAddLayer(c *C, layerYAML string) error { return err } s.plan = &plan.Plan{ - Layers: layers, - Services: combined.Services, - Checks: combined.Checks, - LogTargets: combined.LogTargets, - Sections: combined.Sections, + Layers: layers, + Services: combined.Services, + Checks: combined.Checks, + LogTargets: combined.LogTargets, + TrustContexts: combined.TrustContexts, + Sections: combined.Sections, } return s.plan.Validate() } @@ -2007,11 +2008,12 @@ func (s *S) planAddLayer(c *C, layerYAML string) { c.Assert(err, IsNil) c.Assert(combined.Validate(), IsNil) s.plan = &plan.Plan{ - Layers: layers, - Services: combined.Services, - Checks: combined.Checks, - LogTargets: combined.LogTargets, - Sections: combined.Sections, + Layers: layers, + Services: combined.Services, + Checks: combined.Checks, + LogTargets: combined.LogTargets, + TrustContexts: combined.TrustContexts, + Sections: combined.Sections, } c.Assert(s.plan.Validate(), IsNil) } diff --git a/internals/plan/extensions_test.go b/internals/plan/extensions_test.go index 164fafc9d..24976b745 100644 --- a/internals/plan/extensions_test.go +++ b/internals/plan/extensions_test.go @@ -475,10 +475,11 @@ func (s *S) TestSectionOrderExt(c *C) { combined, err := plan.CombineLayers(layer) c.Assert(err, IsNil) plan := plan.Plan{ - Services: combined.Services, - Checks: combined.Checks, - LogTargets: combined.LogTargets, - Sections: combined.Sections, + Services: combined.Services, + Checks: combined.Checks, + LogTargets: combined.LogTargets, + TrustContexts: combined.TrustContexts, + Sections: combined.Sections, } data, err := yaml.Marshal(plan) c.Assert(err, IsNil) diff --git a/internals/plan/plan.go b/internals/plan/plan.go index 62be686ab..223cc772e 100644 --- a/internals/plan/plan.go +++ b/internals/plan/plan.go @@ -30,6 +30,7 @@ import ( "github.com/canonical/x-go/strutil/shlex" "gopkg.in/yaml.v3" + "github.com/canonical/pebble/cmd" "github.com/canonical/pebble/internals/logger" "github.com/canonical/pebble/internals/osutil" ) @@ -117,10 +118,11 @@ func UnregisterSectionExtension(field string) { } type Plan struct { - Layers []*Layer `yaml:"-"` - Services map[string]*Service `yaml:"services,omitempty"` - Checks map[string]*Check `yaml:"checks,omitempty"` - LogTargets map[string]*LogTarget `yaml:"log-targets,omitempty"` + Layers []*Layer `yaml:"-"` + Services map[string]*Service `yaml:"services,omitempty"` + Checks map[string]*Check `yaml:"checks,omitempty"` + LogTargets map[string]*LogTarget `yaml:"log-targets,omitempty"` + TrustContexts map[string]*TrustContext `yaml:"trust-contexts,omitempty"` Sections map[string]Section `yaml:",inline"` } @@ -199,13 +201,14 @@ func (p *Plan) MarshalYAML() (any, error) { // // Please see ReadLayersDir for more details. type Layer struct { - Order int `yaml:"-"` - Label string `yaml:"-"` - Summary string `yaml:"summary,omitempty"` - Description string `yaml:"description,omitempty"` - Services map[string]*Service `yaml:"services,omitempty"` - Checks map[string]*Check `yaml:"checks,omitempty"` - LogTargets map[string]*LogTarget `yaml:"log-targets,omitempty"` + Order int `yaml:"-"` + Label string `yaml:"-"` + Summary string `yaml:"summary,omitempty"` + Description string `yaml:"description,omitempty"` + Services map[string]*Service `yaml:"services,omitempty"` + Checks map[string]*Check `yaml:"checks,omitempty"` + LogTargets map[string]*LogTarget `yaml:"log-targets,omitempty"` + TrustContexts map[string]*TrustContext `yaml:"trust-contexts,omitempty"` Sections map[string]Section `yaml:",inline"` } @@ -683,6 +686,60 @@ func (t *LogTarget) Merge(other *LogTarget) { } } +// TrustContext can specify a set of trusted keys, certificates, indentities, +// domains or scopes that can be consumed by services, checks and log-targets to +// establish trust with an exogenous entity. +type TrustContext struct { + Name string `yaml:"-"` + Override Override `yaml:"override,omitempty"` + Include []string `yaml:"include,omitempty"` + + TLS *TLSTrustContext `yaml:"tls"` +} + +// Copy returns a deep copy of the trust context configuration. +func (t *TrustContext) Copy() *TrustContext { + copied := *t + copied.Include = slices.Clone(t.Include) + if t.TLS != nil { + copied.TLS = t.TLS.Copy() + } + return &copied +} + +// Merge merges the fields set in other into t. +func (t *TrustContext) Merge(other *TrustContext) { + t.Include = slices.Concat(t.Include, other.Include) + if other.TLS != nil { + if t.TLS == nil { + t.TLS = other.TLS.Copy() + } else { + t.TLS.Merge(other.TLS) + } + } +} + +// TLSTrustContext establishes x509 certificates that are trusted by the trust +// context consumer. +type TLSTrustContext struct { + CACert string `yaml:"ca-cert"` +} + +// Copy returns a deep copy of the TLS trust context configuration. +func (t *TLSTrustContext) Copy() *TLSTrustContext { + copied := *t + return &copied +} + +// Merge merges the fields set in other into t. +func (t *TLSTrustContext) Merge(other *TLSTrustContext) { + if len(t.CACert) == 0 { + t.CACert = other.CACert + } else if len(other.CACert) > 0 { + t.CACert = strings.Join([]string{t.CACert, other.CACert}, "\n") + } +} + // FormatError is the error returned when a layer has a format error, such as // a missing "override" field. type FormatError struct { @@ -700,10 +757,11 @@ func (e *FormatError) Error() string { // validate the combined output if required. func CombineLayers(layers ...*Layer) (*Layer, error) { combined := &Layer{ - Services: make(map[string]*Service), - Checks: make(map[string]*Check), - LogTargets: make(map[string]*LogTarget), - Sections: make(map[string]Section), + Services: make(map[string]*Service), + Checks: make(map[string]*Check), + LogTargets: make(map[string]*LogTarget), + TrustContexts: make(map[string]*TrustContext), + Sections: make(map[string]Section), } // Combine the same sections from each layer. Note that we do this before @@ -799,6 +857,29 @@ func CombineLayers(layers ...*Layer) (*Layer, error) { } } } + + for name, context := range layer.TrustContexts { + switch context.Override { + case MergeOverride: + if old, ok := combined.TrustContexts[name]; ok { + old.Merge(context) + } else { + combined.TrustContexts[name] = context.Copy() + } + case ReplaceOverride: + combined.TrustContexts[name] = context.Copy() + case UnknownOverride: + return nil, &FormatError{ + Message: fmt.Sprintf(`layer %q must define "override" for trust context %q`, + layer.Label, context.Name), + } + default: + return nil, &FormatError{ + Message: fmt.Sprintf(`layer %q has invalid "override" value for trust context %q`, + layer.Label, context.Name), + } + } + } } // Set defaults where required. @@ -988,6 +1069,26 @@ func (layer *Layer) Validate() error { } } + for name, context := range layer.TrustContexts { + if context == nil { + return &FormatError{ + Message: fmt.Sprintf("trust context object cannot be null for trust context %q", name), + } + } + switch name { + case "system", "internal", cmd.ProgramName: + return &FormatError{ + Message: fmt.Sprintf("trust context name %q is reserved", name), + } + case "default": + if context.TLS != nil { + return &FormatError{ + Message: fmt.Sprintf("trust context %s cannot define tls context directly", name), + } + } + } + } + for _, section := range layer.Sections { err := section.Validate() if err != nil { @@ -1269,10 +1370,11 @@ func (p *Plan) checkCycles() error { func ParseLayer(order int, label string, data []byte) (*Layer, error) { layer := &Layer{ - Services: make(map[string]*Service), - Checks: make(map[string]*Check), - LogTargets: make(map[string]*LogTarget), - Sections: make(map[string]Section), + Services: make(map[string]*Service), + Checks: make(map[string]*Check), + LogTargets: make(map[string]*LogTarget), + TrustContexts: make(map[string]*TrustContext), + Sections: make(map[string]Section), } // The following manual approach is required because: @@ -1285,11 +1387,12 @@ func ParseLayer(order int, label string, data []byte) (*Layer, error) { // sections, and at the top field level, which includes Section field // names. builtins := map[string]any{ - "summary": &layer.Summary, - "description": &layer.Description, - "services": &layer.Services, - "checks": &layer.Checks, - "log-targets": &layer.LogTargets, + "summary": &layer.Summary, + "description": &layer.Description, + "services": &layer.Services, + "checks": &layer.Checks, + "log-targets": &layer.LogTargets, + "trust-contexts": &layer.TrustContexts, } sections := make(map[string]yaml.Node) @@ -1583,12 +1686,13 @@ func ReadDir(layersDir string, base *Plan) (*Plan, error) { // be to use order "1" and, before prepending this layer, offset all // other layers' orders by 1000 (which is what PlanManager.AppendLayer) // does internally in order to support layer sub-directories. - Order: 0, - Label: "pebble-base", - Services: base.Services, - Checks: base.Checks, - LogTargets: base.LogTargets, - Sections: base.Sections, + Order: 0, + Label: "pebble-base", + Services: base.Services, + Checks: base.Checks, + LogTargets: base.LogTargets, + TrustContexts: base.TrustContexts, + Sections: base.Sections, } layers = append([]*Layer{baseLayer}, layers...) } @@ -1598,11 +1702,12 @@ func ReadDir(layersDir string, base *Plan) (*Plan, error) { return nil, err } plan := &Plan{ - Layers: layers, - Services: combined.Services, - Checks: combined.Checks, - LogTargets: combined.LogTargets, - Sections: combined.Sections, + Layers: layers, + Services: combined.Services, + Checks: combined.Checks, + LogTargets: combined.LogTargets, + TrustContexts: combined.TrustContexts, + Sections: combined.Sections, } err = plan.Validate() if err != nil { diff --git a/internals/plan/plan_test.go b/internals/plan/plan_test.go index eed4d5462..16e5c4c87 100644 --- a/internals/plan/plan_test.go +++ b/internals/plan/plan_test.go @@ -205,9 +205,10 @@ var planTests = []planTest{{ Startup: plan.StartupUnknown, }, }, - Checks: map[string]*plan.Check{}, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, { Order: 1, Label: "layer-1", @@ -256,9 +257,10 @@ var planTests = []planTest{{ }, }, }, - Checks: map[string]*plan.Check{}, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }}, result: &plan.Layer{ Summary: "Simple override layer.", @@ -336,9 +338,10 @@ var planTests = []planTest{{ BackoffLimit: plan.OptionalDuration{Value: defaultBackoffLimit}, }, }, - Checks: map[string]*plan.Check{}, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, start: map[string][]string{ "srv1": {"srv2", "srv1", "srv3"}, @@ -399,9 +402,10 @@ var planTests = []planTest{{ }, }, }, - Checks: map[string]*plan.Check{}, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }}, }, { summary: "Unknown keys are not accepted", @@ -550,9 +554,10 @@ var planTests = []planTest{{ Command: `cmd -v [ --foo bar -e "x [ y ] z" ]`, }, }, - Checks: map[string]*plan.Check{}, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }}, }, { summary: `Invalid service command: cannot have any arguments after [ ... ] group`, @@ -664,8 +669,9 @@ var planTests = []planTest{{ }, }, }, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, }, { summary: "Checks override replace works correctly", @@ -742,8 +748,9 @@ var planTests = []planTest{{ }, }, }, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, }, { summary: "Checks override merge works correctly", @@ -826,8 +833,9 @@ var planTests = []planTest{{ }, }, }, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, }, { summary: "Timeout is capped at period", @@ -856,8 +864,9 @@ var planTests = []planTest{{ }, }, }, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, }, { summary: "Unset timeout is capped at period", @@ -885,8 +894,9 @@ var planTests = []planTest{{ }, }, }, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, }, { summary: "One of http, tcp, or exec must be present for check", @@ -1030,7 +1040,8 @@ var planTests = []planTest{{ Override: plan.MergeOverride, }, }, - Sections: map[string]plan.Section{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, }, { summary: "Overriding log targets", @@ -1110,7 +1121,8 @@ var planTests = []planTest{{ Override: plan.MergeOverride, }, }, - Sections: map[string]plan.Section{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, { Label: "layer-1", Order: 1, @@ -1142,7 +1154,8 @@ var planTests = []planTest{{ Override: plan.MergeOverride, }, }, - Sections: map[string]plan.Section{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }}, result: &plan.Layer{ Services: map[string]*plan.Service{ @@ -1182,7 +1195,8 @@ var planTests = []planTest{{ Override: plan.MergeOverride, }, }, - Sections: map[string]plan.Section{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, }, { summary: "Log target requires type field", @@ -1292,7 +1306,8 @@ var planTests = []planTest{{ }, }, }, - Sections: map[string]plan.Section{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, { Order: 1, Label: "layer-1", @@ -1318,7 +1333,8 @@ var planTests = []planTest{{ }, }, }, - Sections: map[string]plan.Section{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }}, result: &plan.Layer{ Services: map[string]*plan.Service{}, @@ -1346,7 +1362,8 @@ var planTests = []planTest{{ }, }, }, - Sections: map[string]plan.Section{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, }, { summary: "Reserved log target labels", @@ -1395,9 +1412,10 @@ var planTests = []planTest{{ }, }, }, - Checks: map[string]*plan.Check{}, - LogTargets: map[string]*plan.LogTarget{}, - Sections: map[string]plan.Section{}, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, + Sections: map[string]plan.Section{}, }, }, { summary: "Three layers missing command", @@ -1467,11 +1485,12 @@ func (s *S) TestParseLayer(c *C) { } if err == nil { p := &plan.Plan{ - Layers: sup.Layers, - Services: result.Services, - Checks: result.Checks, - LogTargets: result.LogTargets, - Sections: result.Sections, + Layers: sup.Layers, + Services: result.Services, + Checks: result.Checks, + LogTargets: result.LogTargets, + TrustContexts: result.TrustContexts, + Sections: result.Sections, } err = p.Validate() } @@ -1510,11 +1529,12 @@ services: c.Assert(err, IsNil) layers := []*plan.Layer{layer1, layer2} p := &plan.Plan{ - Layers: layers, - Services: combined.Services, - Checks: combined.Checks, - LogTargets: combined.LogTargets, - Sections: combined.Sections, + Layers: layers, + Services: combined.Services, + Checks: combined.Checks, + LogTargets: combined.LogTargets, + TrustContexts: combined.TrustContexts, + Sections: combined.Sections, } err = p.Validate() c.Assert(err, ErrorMatches, `services in before/after loop: .*`) @@ -1551,11 +1571,12 @@ services: c.Assert(err, IsNil) layers := []*plan.Layer{layer1, layer2} p := &plan.Plan{ - Layers: layers, - Services: combined.Services, - Checks: combined.Checks, - LogTargets: combined.LogTargets, - Sections: combined.Sections, + Layers: layers, + Services: combined.Services, + Checks: combined.Checks, + LogTargets: combined.LogTargets, + TrustContexts: combined.TrustContexts, + Sections: combined.Sections, } err = p.Validate() c.Check(err, ErrorMatches, `plan must define "command" for service "srv1"`) @@ -2008,8 +2029,9 @@ func (s *S) TestStartStopOrderSingleLane(c *C) { Startup: plan.StartupEnabled, }, }, - Checks: map[string]*plan.Check{}, - LogTargets: map[string]*plan.LogTarget{}, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, } p := plan.Plan{Services: layer.Services} @@ -2049,8 +2071,9 @@ func (s *S) TestStartStopOrderMultipleLanes(c *C) { Startup: plan.StartupEnabled, }, }, - Checks: map[string]*plan.Check{}, - LogTargets: map[string]*plan.LogTarget{}, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, } p := plan.Plan{Services: layer.Services} @@ -2102,8 +2125,9 @@ func (s *S) TestStartStopOrderMultipleLanesRandomOrder(c *C) { After: []string{"srv1"}, }, }, - Checks: map[string]*plan.Check{}, - LogTargets: map[string]*plan.LogTarget{}, + Checks: map[string]*plan.Check{}, + LogTargets: map[string]*plan.LogTarget{}, + TrustContexts: map[string]*plan.TrustContext{}, } p := plan.Plan{Services: layer.Services} @@ -2129,9 +2153,9 @@ func (s *S) TestStartStopOrderMultipleLanesRandomOrder(c *C) { // the plan library where required. func (s *S) TestSectionFieldStability(c *C) { layerFields := structYamlFields(plan.Layer{}) - c.Assert(layerFields, testutil.DeepUnsortedMatches, []string{"summary", "description", "services", "checks", "log-targets", "sections"}) + c.Assert(layerFields, testutil.DeepUnsortedMatches, []string{"summary", "description", "services", "checks", "log-targets", "trust-contexts", "sections"}) planFields := structYamlFields(*plan.NewPlan()) - c.Assert(planFields, testutil.DeepUnsortedMatches, []string{"services", "checks", "log-targets", "sections"}) + c.Assert(planFields, testutil.DeepUnsortedMatches, []string{"services", "checks", "log-targets", "trust-contexts", "sections"}) } // structYamlFields extracts the YAML fields from a struct. If the YAML tag @@ -2183,9 +2207,10 @@ func (s *S) TestSectionOrder(c *C) { combined, err := plan.CombineLayers(layer) c.Assert(err, IsNil) plan := plan.Plan{ - Services: combined.Services, - Checks: combined.Checks, - LogTargets: combined.LogTargets, + Services: combined.Services, + Checks: combined.Checks, + LogTargets: combined.LogTargets, + TrustContexts: combined.TrustContexts, } data, err := yaml.Marshal(plan) c.Assert(err, IsNil) From 3cb315b66ad48b3edc9442b317290c29ba0c66d0 Mon Sep 17 00:00:00 2001 From: Harry Pidcock Date: Fri, 7 Aug 2026 14:51:17 +1000 Subject: [PATCH 2/9] feat: add trust-context field to services, checks and log-targets --- internals/plan/plan.go | 46 ++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/internals/plan/plan.go b/internals/plan/plan.go index 223cc772e..4cbe9904c 100644 --- a/internals/plan/plan.go +++ b/internals/plan/plan.go @@ -91,7 +91,7 @@ var ( // builtinSections represents all the built-in layer sections. This list is used // for identifying built-in fields in this package. It is unit tested to match // the YAML fields exposed in the Layer type, to catch inconsistencies. -var builtinSections = []string{"summary", "description", "services", "checks", "log-targets"} +var builtinSections = []string{"summary", "description", "services", "checks", "log-targets", "trust-contexts"} // RegisterSectionExtension adds a plan schema extension. All registrations must be // done before the plan library is used. The order in which extensions are @@ -215,12 +215,13 @@ type Layer struct { type Service struct { // Basic details - Name string `yaml:"-"` - Summary string `yaml:"summary,omitempty"` - Description string `yaml:"description,omitempty"` - Startup ServiceStartup `yaml:"startup,omitempty"` - Override Override `yaml:"override,omitempty"` - Command string `yaml:"command,omitempty"` + Name string `yaml:"-"` + Summary string `yaml:"summary,omitempty"` + Description string `yaml:"description,omitempty"` + Startup ServiceStartup `yaml:"startup,omitempty"` + Override Override `yaml:"override,omitempty"` + Command string `yaml:"command,omitempty"` + TrustContext string `yaml:"trust-context,omitempty"` // Service dependencies After []string `yaml:"after,omitempty"` @@ -277,6 +278,9 @@ func (s *Service) Merge(other *Service) { if other.Command != "" { s.Command = other.Command } + if other.TrustContext != "" { + s.TrustContext = other.TrustContext + } if other.KillDelay.IsSet { s.KillDelay = other.KillDelay } @@ -539,8 +543,9 @@ const ( // HTTPCheck holds the configuration for an HTTP health check. type HTTPCheck struct { - URL string `yaml:"url,omitempty"` - Headers map[string]string `yaml:"headers,omitempty"` + URL string `yaml:"url,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + TrustContext string `yaml:"trust-context,omitempty"` } // Copy returns a deep copy of the HTTP check configuration. @@ -555,6 +560,9 @@ func (c *HTTPCheck) Merge(other *HTTPCheck) { if other.URL != "" { c.URL = other.URL } + if other.TrustContext != "" { + c.TrustContext = other.TrustContext + } for k, v := range other.Headers { if c.Headers == nil { c.Headers = make(map[string]string) @@ -595,6 +603,7 @@ type ExecCheck struct { GroupID *int `yaml:"group-id,omitempty"` Group string `yaml:"group,omitempty"` WorkingDir string `yaml:"working-dir,omitempty"` + TrustContext string `yaml:"trust-context,omitempty"` } // Copy returns a deep copy of the exec check configuration. @@ -639,16 +648,20 @@ func (c *ExecCheck) Merge(other *ExecCheck) { if other.WorkingDir != "" { c.WorkingDir = other.WorkingDir } + if other.TrustContext != "" { + c.TrustContext = other.TrustContext + } } // LogTarget specifies a remote server to forward logs to. type LogTarget struct { - Name string `yaml:"-"` - Type LogTargetType `yaml:"type"` - Location string `yaml:"location"` - Services []string `yaml:"services"` - Override Override `yaml:"override,omitempty"` - Labels map[string]string `yaml:"labels,omitempty"` + Name string `yaml:"-"` + Type LogTargetType `yaml:"type"` + Location string `yaml:"location"` + Services []string `yaml:"services"` + Override Override `yaml:"override,omitempty"` + Labels map[string]string `yaml:"labels,omitempty"` + TrustContext string `yaml:"trust-context,omitempty"` } // LogTargetType defines the protocol to use to forward logs. @@ -677,6 +690,9 @@ func (t *LogTarget) Merge(other *LogTarget) { if other.Location != "" { t.Location = other.Location } + if other.TrustContext != "" { + t.TrustContext = other.TrustContext + } t.Services = append(t.Services, other.Services...) for k, v := range other.Labels { if t.Labels == nil { From 90dcf310c2f10f5bb6b7932479db0c702a1721b5 Mon Sep 17 00:00:00 2001 From: Harry Pidcock Date: Mon, 10 Aug 2026 15:03:48 +1000 Subject: [PATCH 3/9] feat: add truststate manager --- internals/overlord/overlord.go | 13 + internals/overlord/truststate/context.go | 174 ++++++++++ internals/overlord/truststate/manager.go | 318 ++++++++++++++++++ internals/overlord/truststate/manager_test.go | 304 +++++++++++++++++ internals/overlord/truststate/package_test.go | 89 +++++ internals/overlord/truststate/systemca.go | 63 ++++ .../overlord/truststate/systemca_linux.go | 155 +++++++++ .../overlord/truststate/systemca_other.go | 23 ++ 8 files changed, 1139 insertions(+) create mode 100644 internals/overlord/truststate/context.go create mode 100644 internals/overlord/truststate/manager.go create mode 100644 internals/overlord/truststate/manager_test.go create mode 100644 internals/overlord/truststate/package_test.go create mode 100644 internals/overlord/truststate/systemca.go create mode 100644 internals/overlord/truststate/systemca_linux.go create mode 100644 internals/overlord/truststate/systemca_other.go diff --git a/internals/overlord/overlord.go b/internals/overlord/overlord.go index bb82fb5f5..80f5d0810 100644 --- a/internals/overlord/overlord.go +++ b/internals/overlord/overlord.go @@ -41,6 +41,7 @@ import ( "github.com/canonical/pebble/internals/overlord/servstate" "github.com/canonical/pebble/internals/overlord/state" "github.com/canonical/pebble/internals/overlord/tlsstate" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/timing" ) @@ -128,6 +129,7 @@ type Overlord struct { checkMgr *checkstate.CheckManager logMgr *logstate.LogManager tlsMgr *tlsstate.TLSManager + trustMgr *truststate.TrustManager identitiesMgr *identities.Manager pairingMgr *pairingstate.PairingManager @@ -211,6 +213,11 @@ func New(opts *Options) (*Overlord, error) { o.tlsMgr = tlsstate.NewManager(&tlsOpts) o.stateEng.AddManager(o.tlsMgr) + trustDir := filepath.Join(opts.PebbleDir, "trust") + o.trustMgr = truststate.NewManager(trustDir) + o.stateEng.AddManager(o.trustMgr) + o.planMgr.AddChangeListener(o.trustMgr.PlanChanged) + o.identitiesMgr, err = identities.NewManager(s) if err != nil { return nil, fmt.Errorf("cannot create identities manager: %w", err) @@ -667,6 +674,12 @@ func (o *Overlord) TLSManager() *tlsstate.TLSManager { return o.tlsMgr } +// TrustManager returns the manager responsible for managing trust contexts +// declared in the plan. +func (o *Overlord) TrustManager() *truststate.TrustManager { + return o.trustMgr +} + // IdentitiesManager returns the manager responsible for managing client // identities. func (o *Overlord) IdentitiesManager() *identities.Manager { diff --git a/internals/overlord/truststate/context.go b/internals/overlord/truststate/context.go new file mode 100644 index 000000000..06b7f9d3b --- /dev/null +++ b/internals/overlord/truststate/context.go @@ -0,0 +1,174 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package truststate + +import ( + "crypto/x509" + "errors" + "fmt" + "os" + "sync" + + "github.com/canonical/pebble/internals/logger" + "github.com/canonical/pebble/internals/osutil" +) + +// ErrClosed is returned by TrustContext methods once the TrustContext has +// been closed. +var ErrClosed = errors.New("trust context reference already closed") + +// TrustContext is a reference to a specific, immutable, resolved snapshot of +// a named trust context, as maintained by a TrustManager. Once the caller no +// longer needs it, Release must be called so the manager can reclaim +// resources (such as an on-disk CA bundle file) once a newer version of the +// trust context has superseded this one (or it has been removed from the +// plan). +// +// TrustContext is safe for concurrent use, but is not safe for use after +// Release has been called. +type TrustContext struct { + mu sync.Mutex + version *trustContextVersion +} + +// CAPool returns a certificate pool containing all of the CA certificates +// trusted by this trust context, including those pulled in transitively via +// "include". The returned pool is a fresh copy on each call, so it's safe +// for the caller to hold on to and use (for example, as tls.Config.RootCAs) +// without it changing underneath them or being affected by later plan +// changes. +func (t *TrustContext) CAPool() (*x509.CertPool, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.version == nil { + return nil, ErrClosed + } + return t.version.pool.Clone(), nil +} + +// CABundleFile returns the path of a PEM file on disk containing all of the +// CA certificates trusted by this trust context. The file is created lazily +// on first use, and is guaranteed to exist and remain unchanged for as long +// as this (or any other reference to the same resolved version) has not +// been released. +func (t *TrustContext) CABundleFile() (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.version == nil { + return "", ErrClosed + } + return t.version.ensureFile() +} + +// Close tells the TrustManager that this reference is no longer needed. +// It is safe to call Close more than once; calls after the first are a +// no-op. +func (t *TrustContext) Close() error { + t.mu.Lock() + v := t.version + t.version = nil + t.mu.Unlock() + if v != nil { + v.release() + } + return nil +} + +// trustContextVersion holds one immutable, fully-resolved snapshot of a +// named trust context, as computed by TrustManager.resolve. A new version is +// only created when the resolved CA data actually changes (tracked via +// shortSha); as long as the content is unchanged across plan changes, the +// same version (and any outstanding references and on-disk file) continues +// to be used. +type trustContextVersion struct { + mgr *TrustManager + name string + + shortSha string + pemBundle []byte + pool *x509.CertPool + filePath string + + // refCount, superseded and cleaned are all only ever accessed while + // holding mgr.mu. + refCount int + superseded bool + cleaned bool + + // fileMu guards lazy creation of the CA bundle file. + fileMu sync.Mutex + fileWritten bool +} + +// addRef must be called while holding mgr.mu. +func (v *trustContextVersion) addRef() { + v.refCount++ +} + +// release drops a reference previously obtained via addRef, cleaning up the +// version's on-disk state if it has been superseded and this was the last +// outstanding reference. +func (v *trustContextVersion) release() { + v.mgr.mu.Lock() + v.refCount-- + doCleanup := v.maybeCleanupLocked() + v.mgr.mu.Unlock() + if doCleanup { + v.cleanup() + } +} + +// maybeCleanupLocked must be called while holding mgr.mu. It returns true +// (at most once, ever, for a given version) when the caller has become +// responsible for removing this version's on-disk state. +func (v *trustContextVersion) maybeCleanupLocked() bool { + if v.cleaned || !v.superseded || v.refCount > 0 { + return false + } + v.cleaned = true + return true +} + +// ensureFile lazily writes the CA bundle file for this version to disk, if +// it hasn't been already, and returns its path. +func (v *trustContextVersion) ensureFile() (string, error) { + v.fileMu.Lock() + defer v.fileMu.Unlock() + if v.fileWritten { + return v.filePath, nil + } + if err := v.mgr.ensureTrustDir(); err != nil { + return "", fmt.Errorf("cannot create trust directory: %w", err) + } + if err := osutil.AtomicWriteFile(v.filePath, v.pemBundle, 0o644, 0); err != nil { + return "", fmt.Errorf("cannot write CA bundle file for trust context %q: %w", v.name, err) + } + v.fileWritten = true + return v.filePath, nil +} + +// cleanup removes this version's CA bundle file from disk, if it was ever +// written. +func (v *trustContextVersion) cleanup() { + v.fileMu.Lock() + written := v.fileWritten + v.fileMu.Unlock() + if !written { + return + } + if err := os.Remove(v.filePath); err != nil && !os.IsNotExist(err) { + logger.Noticef("Cannot remove stale CA bundle file %q: %v", v.filePath, err) + } +} diff --git a/internals/overlord/truststate/manager.go b/internals/overlord/truststate/manager.go new file mode 100644 index 000000000..ebe323f1d --- /dev/null +++ b/internals/overlord/truststate/manager.go @@ -0,0 +1,318 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// Package truststate manages the trust contexts declared in the Pebble +// plan. A trust context is a named collection of trusted x509 CA +// certificates (optionally built up from other trust contexts via +// "include") that can be consumed by services, checks and log targets to +// establish trust with an exogenous entity. +// +// The manager maintains two built-in trust contexts in addition to any +// declared in the plan: +// +// - "system" is an immutable trust context backed by the host's default +// x509 CA certificate pool (as loaded by the standard library). +// - "default" is the trust context consumers use when none is explicitly +// configured. It is mutable (via the plan), but may only "include" +// other trust contexts; it can never define its own CA certificate +// directly. +package truststate + +import ( + "bytes" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "fmt" + "path/filepath" + "sync" + + "github.com/canonical/pebble/internals/logger" + "github.com/canonical/pebble/internals/osutil" + "github.com/canonical/pebble/internals/plan" +) + +const ( + // SystemTrustContext is the name of the built-in, immutable trust + // context backed by the host's default CA certificate pool. + SystemTrustContext = "system" + + // DefaultTrustContext is the name of the built-in trust context used by + // default by trust context consumers (services, checks, log targets). + DefaultTrustContext = "default" +) + +// TrustManager loads and maintains the CA certificate data for every trust +// context declared in the plan (plus the "system" and "default" built-ins), +// and provides access to it, both as an in-memory x509.CertPool (for Go +// clients) and as a maintained PEM file on disk (for consumers that require +// a file path, such as exec or external processes). +type TrustManager struct { + // trustDir is the directory in which CA bundle files are maintained, + // normally "$PEBBLE/trust". + trustDir string + + // systemPool is the CA certificate pool backing the "system" trust + // context, loaded once via the x509 package. + systemPool *x509.CertPool + // systemPEM is a best-effort PEM encoded representation of the same + // trust roots as systemPool, used only when a CA bundle *file* that + // includes the system trust anchors is required. The x509 package does + // not expose a way to enumerate the certificates that make up a + // *x509.CertPool (in particular, one obtained via SystemCertPool), so + // this is populated by reading one of a handful of well-known CA bundle + // locations on disk. It may be nil if no such file can be found (for + // example, in minimal container images, or on platforms where the + // system trust store isn't backed by a single PEM file), in which case + // file-based bundles won't include the system trust anchors even though + // the in-memory pool always will. + systemPEM []byte + + mu sync.Mutex + // current holds the latest resolved version for every trust context + // name currently known to the manager (built-ins plus anything declared + // in the plan). + current map[string]*trustContextVersion +} + +// NewManager creates a new TrustManager which maintains CA bundle files +// under trustDir (which will be created on demand). The built-in "system" +// and "default" trust contexts are available immediately, even before the +// first call to PlanChanged. +func NewManager(trustDir string) *TrustManager { + m := &TrustManager{ + trustDir: trustDir, + current: make(map[string]*trustContextVersion), + } + m.systemPool, m.systemPEM = loadSystemCAs() + + // Bootstrap the built-in trust contexts right away, so callers don't + // have to wait for a real plan to be loaded to use "system" or + // "default". + m.PlanChanged(plan.NewPlan()) + return m +} + +// Ensure implements overlord.StateManager. All of the TrustManager's work is +// performed synchronously (from PlanChanged, and lazily when a trust +// context's CABundleFile is first requested), so there's nothing to do here. +func (m *TrustManager) Ensure() error { + return nil +} + +// PlanChanged is called (normally registered as a plan change listener) +// whenever the plan changes. It re-resolves every trust context declared in +// the new plan (as well as the "system" and "default" built-ins), updating +// the CA pool and PEM bundle available to consumers. +// +// If a trust context can't be resolved (for example, because it includes an +// unknown trust context), an error is logged and the trust context's +// previous (last known good) resolved state, if any, is retained. +func (m *TrustManager) PlanChanged(pl *plan.Plan) { + keep := make(map[string]bool) + keep[SystemTrustContext] = true + keep[DefaultTrustContext] = true + for name := range pl.TrustContexts { + keep[name] = true + } + + type resolution struct { + name string + data *resolvedTrust + err error + } + resolutions := make([]resolution, 0, len(keep)) + for name := range keep { + data, err := m.resolve(name, pl) + resolutions = append(resolutions, resolution{name: name, data: data, err: err}) + } + + var cleanup []*trustContextVersion + + m.mu.Lock() + newCurrent := make(map[string]*trustContextVersion, len(keep)) + for _, r := range resolutions { + old := m.current[r.name] + if r.err != nil { + logger.Noticef("Cannot resolve trust context %q: %v", r.name, r.err) + if old != nil { + // Keep serving the previous good state. + newCurrent[r.name] = old + } + continue + } + if old != nil && old.shortSha == r.data.shortSha { + // Nothing of substance changed, keep the existing version + // (and its references, and its file on disk) as-is. + newCurrent[r.name] = old + continue + } + v := &trustContextVersion{ + mgr: m, + name: r.name, + shortSha: r.data.shortSha, + pemBundle: r.data.pemBundle, + pool: r.data.pool, + filePath: bundleFilePath(m.trustDir, r.name, r.data.shortSha), + } + newCurrent[r.name] = v + if old != nil { + old.superseded = true + if old.maybeCleanupLocked() { + cleanup = append(cleanup, old) + } + } + } + // Trust contexts that used to exist but are no longer declared anywhere + // (removed from the plan). + for name, old := range m.current { + if keep[name] { + continue + } + old.superseded = true + if old.maybeCleanupLocked() { + cleanup = append(cleanup, old) + } + } + m.current = newCurrent + m.mu.Unlock() + + for _, v := range cleanup { + v.cleanup() + } +} + +// TrustContext returns a reference to the current resolved state of the +// named trust context. The caller must call Close on the returned +// TrustContext once it is no longer needed. +func (m *TrustManager) TrustContext(name string) (*TrustContext, error) { + m.mu.Lock() + v, ok := m.current[name] + if ok { + v.addRef() + } + m.mu.Unlock() + if !ok { + return nil, fmt.Errorf("trust context %q not found", name) + } + return &TrustContext{version: v}, nil +} + +func (m *TrustManager) ensureTrustDir() error { + return osutil.Mkdir(m.trustDir, 0o755, &osutil.MkdirOptions{ + MakeParents: true, + ExistOK: true, + Chmod: true, + }) +} + +// resolvedTrust holds the trust data for a single trust context. +type resolvedTrust struct { + pemBundle []byte + pool *x509.CertPool + shortSha string +} + +// resolve computes the fully-resolved CA pool and PEM bundle for the named +// trust context, following "include" chains (including through the +// synthesized "default" trust context). Cycles are broken by tracking which +// trust contexts have already been visited: since "include" only ever adds +// to the resulting set of trusted CAs, revisiting an already-included trust +// context can't change the result, so it's simply skipped. +func (m *TrustManager) resolve(name string, pl *plan.Plan) (*resolvedTrust, error) { + visited := make(map[string]bool) + queue := []string{name} + includesSystem := false + var pemParts [][]byte + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + if visited[cur] { + continue + } + visited[cur] = true + + if cur == SystemTrustContext { + includesSystem = true + continue + } + + ctx, ok := lookupTrustContext(cur, pl) + if !ok { + return nil, fmt.Errorf("includes unknown trust context %q", cur) + } + if ctx.TLS != nil && len(ctx.TLS.CACert) > 0 { + pemParts = append(pemParts, []byte(ctx.TLS.CACert)) + } + queue = append(queue, ctx.Include...) + } + + var pool *x509.CertPool + var bundle bytes.Buffer + if includesSystem { + pool = m.systemPool.Clone() + writePEMPart(&bundle, m.systemPEM) + } else { + pool = x509.NewCertPool() + } + + for _, part := range pemParts { + if !pool.AppendCertsFromPEM(part) { + return nil, fmt.Errorf("no valid CA certificate found for trust context %q", name) + } + writePEMPart(&bundle, part) + } + + pemBundle := bundle.Bytes() + sum := sha256.Sum256(pemBundle) + shortSha := hex.EncodeToString(sum[:])[:8] + + return &resolvedTrust{ + pemBundle: pemBundle, + pool: pool, + shortSha: shortSha, + }, nil +} + +// writePEMPart appends data to buf, ensuring it's separated from any +// subsequent content by a newline. +func writePEMPart(buf *bytes.Buffer, data []byte) { + if len(data) == 0 { + return + } + buf.Write(data) + if data[len(data)-1] != '\n' { + buf.WriteByte('\n') + } +} + +// lookupTrustContext returns the trust context configuration for name. +func lookupTrustContext(name string, pl *plan.Plan) (*plan.TrustContext, bool) { + ctx, ok := pl.TrustContexts[name] + if !ok && name == DefaultTrustContext { + return &plan.TrustContext{ + Name: DefaultTrustContext, + Override: plan.ReplaceOverride, + Include: []string{SystemTrustContext}, + }, true + } + return ctx, ok +} + +// bundleFilePath returns the path of the maintained CA bundle file for the +// named trust context's given content hash. +func bundleFilePath(trustDir, name, shortSha string) string { + return filepath.Join(trustDir, fmt.Sprintf("%s-%s-tls-ca-bundle.pem", name, shortSha)) +} diff --git a/internals/overlord/truststate/manager_test.go b/internals/overlord/truststate/manager_test.go new file mode 100644 index 000000000..6b9adf9a5 --- /dev/null +++ b/internals/overlord/truststate/manager_test.go @@ -0,0 +1,304 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package truststate_test + +import ( + "crypto/x509" + "os" + "path/filepath" + "strings" + + . "gopkg.in/check.v1" + + "github.com/canonical/pebble/internals/overlord/truststate" + "github.com/canonical/pebble/internals/plan" +) + +type trustSuite struct{} + +var _ = Suite(&trustSuite{}) + +func newTestManager(c *C) *truststate.TrustManager { + return truststate.NewManager(filepath.Join(c.MkDir(), "trust")) +} + +func (s *trustSuite) TestBuiltinContexts(c *C) { + mgr := newTestManager(c) + + sys, err := mgr.TrustContext("system") + c.Assert(err, IsNil) + defer sys.Close() + sysPool, err := sys.CAPool() + c.Assert(err, IsNil) + c.Assert(sysPool, NotNil) + + def, err := mgr.TrustContext("default") + c.Assert(err, IsNil) + defer def.Close() + defPool, err := def.CAPool() + c.Assert(err, IsNil) + c.Assert(defPool, NotNil) + + _, err = mgr.TrustContext("unknown") + c.Assert(err, ErrorMatches, `trust context "unknown" not found`) +} + +func (s *trustSuite) TestCustomTrustContextPoolAndFile(c *C) { + mgr := newTestManager(c) + + caPEM, caCert, caKey := generateCACert(c, "vendorA") + leaf := generateLeafCert(c, caCert, caKey, "leaf.vendora.example") + + mgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": { + Name: "vendorA", + TLS: &plan.TLSTrustContext{CACert: string(caPEM)}, + }, + }, + }) + + tc, err := mgr.TrustContext("vendorA") + c.Assert(err, IsNil) + defer tc.Close() + + pool, err := tc.CAPool() + c.Assert(err, IsNil) + _, err = leaf.Verify(x509.VerifyOptions{Roots: pool}) + c.Assert(err, IsNil) + + path, err := tc.CABundleFile() + c.Assert(err, IsNil) + c.Assert(strings.HasPrefix(filepath.Base(path), "vendorA-"), Equals, true) + c.Assert(strings.HasSuffix(filepath.Base(path), "-tls-ca-bundle.pem"), Equals, true) + + data, err := os.ReadFile(path) + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, caPEM) +} + +func (s *trustSuite) TestIncludeCyclesAreBroken(c *C) { + mgr := newTestManager(c) + + aPEM, aCert, aKey := generateCACert(c, "A") + bPEM, bCert, bKey := generateCACert(c, "B") + leafA := generateLeafCert(c, aCert, aKey, "leafA") + leafB := generateLeafCert(c, bCert, bKey, "leafB") + + mgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "a": {Name: "a", Include: []string{"b"}, TLS: &plan.TLSTrustContext{CACert: string(aPEM)}}, + "b": {Name: "b", Include: []string{"a"}, TLS: &plan.TLSTrustContext{CACert: string(bPEM)}}, + }, + }) + + tc, err := mgr.TrustContext("a") + c.Assert(err, IsNil) + defer tc.Close() + + pool, err := tc.CAPool() + c.Assert(err, IsNil) + + _, err = leafA.Verify(x509.VerifyOptions{Roots: pool}) + c.Assert(err, IsNil) + _, err = leafB.Verify(x509.VerifyOptions{Roots: pool}) + c.Assert(err, IsNil) +} + +func (s *trustSuite) TestDefaultIncludesSystemAndCustom(c *C) { + mgr := newTestManager(c) + + caPEM, caCert, caKey := generateCACert(c, "vendorA") + leaf := generateLeafCert(c, caCert, caKey, "leaf") + + mgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": {Name: "vendorA", TLS: &plan.TLSTrustContext{CACert: string(caPEM)}}, + "default": {Name: "default", Include: []string{"vendorA"}}, + }, + }) + + tc, err := mgr.TrustContext("default") + c.Assert(err, IsNil) + defer tc.Close() + + pool, err := tc.CAPool() + c.Assert(err, IsNil) + _, err = leaf.Verify(x509.VerifyOptions{Roots: pool}) + c.Assert(err, IsNil) +} + +func (s *trustSuite) TestUnknownIncludeRetainsPreviousGoodState(c *C) { + mgr := newTestManager(c) + + caPEM, caCert, caKey := generateCACert(c, "vendorA") + leaf := generateLeafCert(c, caCert, caKey, "leaf") + + mgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": {Name: "vendorA", TLS: &plan.TLSTrustContext{CACert: string(caPEM)}}, + }, + }) + + tc1, err := mgr.TrustContext("vendorA") + c.Assert(err, IsNil) + defer tc1.Close() + path1, err := tc1.CABundleFile() + c.Assert(err, IsNil) + + // Update the plan with an invalid trust context (referencing an unknown + // included trust context). The previous good version should be kept. + mgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": {Name: "vendorA", Include: []string{"doesnotexist"}}, + }, + }) + + tc2, err := mgr.TrustContext("vendorA") + c.Assert(err, IsNil) + defer tc2.Close() + path2, err := tc2.CABundleFile() + c.Assert(err, IsNil) + c.Assert(path2, Equals, path1) + + pool, err := tc2.CAPool() + c.Assert(err, IsNil) + _, err = leaf.Verify(x509.VerifyOptions{Roots: pool}) + c.Assert(err, IsNil) +} + +func (s *trustSuite) TestFileLifecycleAcrossUpdatesAndRelease(c *C) { + mgr := newTestManager(c) + + pem1, _, _ := generateCACert(c, "vendorA-v1") + pem2, _, _ := generateCACert(c, "vendorA-v2") + + mgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": {Name: "vendorA", TLS: &plan.TLSTrustContext{CACert: string(pem1)}}, + }, + }) + + tcOld, err := mgr.TrustContext("vendorA") + c.Assert(err, IsNil) + oldPath, err := tcOld.CABundleFile() + c.Assert(err, IsNil) + _, err = os.Stat(oldPath) + c.Assert(err, IsNil) + + mgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": {Name: "vendorA", TLS: &plan.TLSTrustContext{CACert: string(pem2)}}, + }, + }) + + // The old version's file must still be present: tcOld hasn't been + // released yet, even though it's now superseded. + _, err = os.Stat(oldPath) + c.Assert(err, IsNil) + + tcNew, err := mgr.TrustContext("vendorA") + c.Assert(err, IsNil) + newPath, err := tcNew.CABundleFile() + c.Assert(err, IsNil) + c.Assert(newPath, Not(Equals), oldPath) + + // Releasing the superseded reference should now clean up its file. + tcOld.Close() + _, err = os.Stat(oldPath) + c.Assert(os.IsNotExist(err), Equals, true) + + // The new (current) version's file must remain until it's released. + _, err = os.Stat(newPath) + c.Assert(err, IsNil) + tcNew.Close() +} + +func (s *trustSuite) TestRemovedTrustContextCleanup(c *C) { + mgr := newTestManager(c) + + caPEM, _, _ := generateCACert(c, "vendorA") + mgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": {Name: "vendorA", TLS: &plan.TLSTrustContext{CACert: string(caPEM)}}, + }, + }) + + tc, err := mgr.TrustContext("vendorA") + c.Assert(err, IsNil) + path, err := tc.CABundleFile() + c.Assert(err, IsNil) + + // Release the only outstanding reference before removing the trust + // context. Since nothing has superseded it yet, the file must remain. + tc.Close() + _, err = os.Stat(path) + c.Assert(err, IsNil) + + // Now remove vendorA from the plan entirely. + mgr.PlanChanged(&plan.Plan{}) + + _, err = mgr.TrustContext("vendorA") + c.Assert(err, ErrorMatches, `trust context "vendorA" not found`) + + _, err = os.Stat(path) + c.Assert(os.IsNotExist(err), Equals, true) +} + +func (s *trustSuite) TestReleaseIdempotentAndUseAfterRelease(c *C) { + mgr := newTestManager(c) + + tc, err := mgr.TrustContext("system") + c.Assert(err, IsNil) + tc.Close() + tc.Close() // must not panic + + _, err = tc.CAPool() + c.Assert(err, Equals, truststate.ErrClosed) + _, err = tc.CABundleFile() + c.Assert(err, Equals, truststate.ErrClosed) +} + +func (s *trustSuite) TestSameContentKeepsSameVersion(c *C) { + mgr := newTestManager(c) + + caPEM, _, _ := generateCACert(c, "vendorA") + pl := &plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": {Name: "vendorA", TLS: &plan.TLSTrustContext{CACert: string(caPEM)}}, + }, + } + mgr.PlanChanged(pl) + + tc1, err := mgr.TrustContext("vendorA") + c.Assert(err, IsNil) + defer tc1.Close() + path1, err := tc1.CABundleFile() + c.Assert(err, IsNil) + + // Re-announce an equivalent plan; the resolved content is unchanged so + // the same version (and file) should still be in use. + mgr.PlanChanged(pl) + + tc2, err := mgr.TrustContext("vendorA") + c.Assert(err, IsNil) + defer tc2.Close() + path2, err := tc2.CABundleFile() + c.Assert(err, IsNil) + c.Assert(path2, Equals, path1) + + _, err = os.Stat(path1) + c.Assert(err, IsNil) +} diff --git a/internals/overlord/truststate/package_test.go b/internals/overlord/truststate/package_test.go new file mode 100644 index 000000000..e3a09e7f4 --- /dev/null +++ b/internals/overlord/truststate/package_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package truststate_test + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + + . "gopkg.in/check.v1" + + "github.com/canonical/pebble/internals/testutil" +) + +// Hook up check.v1 into the "go test" runner. +func Test(t *testing.T) { + testutil.PrintGoroutineLeaks(t, TestingT) +} + +var serial int64 + +func nextSerial() *big.Int { + serial++ + return big.NewInt(serial) +} + +// generateCACert creates a self-signed CA certificate for use in tests, and +// returns its PEM encoding, the parsed certificate and its private key (so +// that leaf certificates can be generated and signed by it). +func generateCACert(c *C, commonName string) (pemBytes []byte, cert *x509.Certificate, key ed25519.PrivateKey) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + c.Assert(err, IsNil) + + tmpl := &x509.Certificate{ + SerialNumber: nextSerial(), + Subject: pkix.Name{CommonName: commonName}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv) + c.Assert(err, IsNil) + cert, err = x509.ParseCertificate(der) + c.Assert(err, IsNil) + + pemBytes = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + return pemBytes, cert, priv +} + +// generateLeafCert creates a leaf certificate signed by the given CA +// certificate and key, for use in verifying that a resolved trust context's +// CA pool actually trusts the expected certificate authority. +func generateLeafCert(c *C, ca *x509.Certificate, caKey ed25519.PrivateKey, commonName string) *x509.Certificate { + pub, _, err := ed25519.GenerateKey(rand.Reader) + c.Assert(err, IsNil) + + tmpl := &x509.Certificate{ + SerialNumber: nextSerial(), + Subject: pkix.Name{CommonName: commonName}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, pub, caKey) + c.Assert(err, IsNil) + leaf, err := x509.ParseCertificate(der) + c.Assert(err, IsNil) + return leaf +} diff --git a/internals/overlord/truststate/systemca.go b/internals/overlord/truststate/systemca.go new file mode 100644 index 000000000..1c54480f7 --- /dev/null +++ b/internals/overlord/truststate/systemca.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package truststate + +import ( + "crypto/x509" + + "github.com/canonical/pebble/internals/logger" +) + +// loadSystemCAs loads the pool backing the "system" trust context via the +// x509 package, and (on a best-effort, platform-specific basis) the same +// trust anchors as raw PEM data, for use when a file-based CA bundle needs +// to include the system trust anchors. The returned pool is never nil; the +// returned PEM data may be nil if it can't be located (or reconstructed) on +// this platform. +// +// The x509 package doesn't provide a way to enumerate the certificates that +// make up a *x509.CertPool (which is required to build a CA bundle *file*, +// as opposed to just an in-memory pool), so loadReplicaSystemCABundle +// (platform-specific) independently locates and reads the same underlying +// CA certificate data that x509.SystemCertPool uses. Since this duplicates +// logic from the standard library, and could conceivably drift or disagree +// with it (for example, if this file needs updating to track a Go release, +// or the underlying platform's trust store changed shape), the resulting +// pool is compared against the real system pool via CertPool.Equal, and a +// warning is logged if they don't match. +func loadSystemCAs() (*x509.CertPool, []byte) { + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + logger.Noticef("Cannot load system CA certificate pool: %v", err) + pool = x509.NewCertPool() + } + + replicaPool, pemBytes := loadSystemCABundle() + if replicaPool == nil { + logger.Debugf(`Cannot locate a system CA certificate bundle on disk; ` + + `file-based CA bundles that include the "system" trust context ` + + `will not contain the system trust anchors`) + return pool, nil + } + + if !pool.Equal(replicaPool) { + logger.Noticef(`The system CA certificates read from disk do not match ` + + `the standard library's system certificate pool; file-based CA ` + + `bundles that include the "system" trust context may not exactly ` + + `match what Go HTTP clients trust`) + } + + return pool, pemBytes +} diff --git a/internals/overlord/truststate/systemca_linux.go b/internals/overlord/truststate/systemca_linux.go new file mode 100644 index 000000000..282f41fe5 --- /dev/null +++ b/internals/overlord/truststate/systemca_linux.go @@ -0,0 +1,155 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google LLC nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +//go:build linux + +package truststate + +import ( + "bytes" + "crypto/x509" + "io/fs" + "os" + "path/filepath" + "strings" +) + +const ( + // certFileEnv is the environment variable which identifies where to + // locate the SSL certificate file. If set this overrides the system + // default. This matches the standard library (see + // crypto/x509/root_unix.go). + certFileEnv = "SSL_CERT_FILE" + + // certDirEnv is the environment variable which identifies which + // directory to check for SSL certificate files. If set this overrides + // the system default. It is a colon separated list of directories. This + // matches the standard library (see crypto/x509/root_unix.go). + certDirEnv = "SSL_CERT_DIR" +) + +// certFiles lists possible certificate bundle files; only the first one +// found is read. This is a copy of the equivalent (unexported) list in +// crypto/x509/root_linux.go. +var certFiles = []string{ + "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Gentoo etc. + "/etc/pki/tls/certs/ca-bundle.crt", // Fedora/RHEL 6 + "/etc/ssl/ca-bundle.pem", // OpenSUSE + "/etc/pki/tls/cacert.pem", // OpenELEC + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS/RHEL 7 + "/etc/ssl/cert.pem", // Alpine Linux +} + +// certDirectories lists possible directories with certificate files; all +// files in all of these directories are read. This is a copy of the +// equivalent (unexported) list in crypto/x509/root_linux.go (excluding the +// Android-specific entries, which aren't relevant to Pebble). +var certDirectories = []string{ + "/etc/ssl/certs", // SLES10/SLES11, https://golang.org/issue/12139 + "/etc/pki/tls/certs", // Fedora/RHEL +} + +// loadSystemCABundle locates and reads the same CA certificate data +// that the standard library's x509.SystemCertPool uses on Linux (see +// crypto/x509/root_unix.go's loadSystemRoots), so that a CA bundle file can +// be maintained on disk in addition to the in-memory pool. It returns nil, +// nil if no CA certificate data could be found. +func loadSystemCABundle() (*x509.CertPool, []byte) { + pool := x509.NewCertPool() + var bundle bytes.Buffer + found := false + + files := certFiles + if f := os.Getenv(certFileEnv); f != "" { + files = []string{f} + } + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + continue + } + pool.AppendCertsFromPEM(data) + writePEMPart(&bundle, data) + found = true + break + } + + dirs := certDirectories + if d := os.Getenv(certDirEnv); d != "" { + // OpenSSL and BoringSSL both use ":" as the SSL_CERT_DIR separator. + dirs = strings.Split(d, ":") + } + for _, dir := range dirs { + entries, err := readUniqueDirectoryEntries(dir) + if err != nil { + continue + } + for _, entry := range entries { + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + continue + } + pool.AppendCertsFromPEM(data) + writePEMPart(&bundle, data) + found = true + } + } + + if !found { + return nil, nil + } + return pool, bundle.Bytes() +} + +// readUniqueDirectoryEntries is like os.ReadDir but omits symlinks that +// point within the directory. This is a copy of the equivalent (unexported) +// function in crypto/x509/root_unix.go. +func readUniqueDirectoryEntries(dir string) ([]fs.DirEntry, error) { + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + uniq := files[:0] + for _, f := range files { + if !isSameDirSymlink(f, dir) { + uniq = append(uniq, f) + } + } + return uniq, nil +} + +// isSameDirSymlink reports whether f in dir is a symlink with a target not +// containing a slash. This is a copy of the equivalent (unexported) function +// in crypto/x509/root_unix.go. +func isSameDirSymlink(f fs.DirEntry, dir string) bool { + if f.Type()&fs.ModeSymlink == 0 { + return false + } + target, err := os.Readlink(filepath.Join(dir, f.Name())) + return err == nil && !strings.Contains(target, "/") +} diff --git a/internals/overlord/truststate/systemca_other.go b/internals/overlord/truststate/systemca_other.go new file mode 100644 index 000000000..282cbd889 --- /dev/null +++ b/internals/overlord/truststate/systemca_other.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Canonical Ltd +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 3 as +// published by the Free Software Foundation. +// +// 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build !linux + +package truststate + +import "crypto/x509" + +func loadSystemCABundle() (*x509.CertPool, []byte) { + return nil, nil +} From c37d78f7ee1c4bb44b92d70b68e8ea4f8169a427 Mon Sep 17 00:00:00 2001 From: Harry Pidcock Date: Mon, 10 Aug 2026 17:44:06 +1000 Subject: [PATCH 4/9] feat: pass service context via SSL_CERT_FILE env var --- internals/overlord/overlord.go | 5 +- internals/overlord/servstate/handlers.go | 47 +++++++- internals/overlord/servstate/manager.go | 13 ++- internals/overlord/servstate/manager_test.go | 116 ++++++++++++++++++- internals/overlord/truststate/context.go | 20 +++- internals/overlord/truststate/manager.go | 27 +++-- 6 files changed, 206 insertions(+), 22 deletions(-) diff --git a/internals/overlord/overlord.go b/internals/overlord/overlord.go index 80f5d0810..98a75c7f3 100644 --- a/internals/overlord/overlord.go +++ b/internals/overlord/overlord.go @@ -238,7 +238,8 @@ func New(opts *Options) (*Overlord, error) { o.runner, opts.ServiceOutput, opts.RestartHandler, - o.logMgr) + o.logMgr, + o.trustMgr) if err != nil { return nil, fmt.Errorf("cannot create service manager: %w", err) } @@ -711,7 +712,7 @@ func FakeWithState(handleRestart func(restart.RestartType)) *Overlord { s := state.New(fakeBackend{o: o}) o.stateEng = NewStateEngine(s) o.runner = state.NewTaskRunner(s) - o.serviceMgr, _ = servstate.NewManager(s, o.runner, nil, nil, nil) + o.serviceMgr, _ = servstate.NewManager(s, o.runner, nil, nil, nil, nil) return o } diff --git a/internals/overlord/servstate/handlers.go b/internals/overlord/servstate/handlers.go index 6faccd704..c492c50f9 100644 --- a/internals/overlord/servstate/handlers.go +++ b/internals/overlord/servstate/handlers.go @@ -21,6 +21,7 @@ import ( "github.com/canonical/pebble/internals/osutil" "github.com/canonical/pebble/internals/overlord/restart" "github.com/canonical/pebble/internals/overlord/state" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/plan" "github.com/canonical/pebble/internals/reaper" "github.com/canonical/pebble/internals/servicelog" @@ -110,6 +111,11 @@ type serviceData struct { restarting bool currentSince time.Time startCount atomic.Int64 + // trustContext holds a reference to the resolved trust context used by + // the currently running (or most recently started) process, if any. It + // is held until the process has finished, and released (closed) by the + // goroutine that waits for the process to exit. + trustContext *truststate.TrustContext } func (m *ServiceManager) doStart(task *state.Task, tomb *tomb.Tomb) error { @@ -367,6 +373,7 @@ func (s *serviceData) startInternal() error { return err } args := append(base, extra...) + serviceName := s.config.Name s.cmd = exec.Command(args[0], args[1:]...) s.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} @@ -434,6 +441,36 @@ func (s *serviceData) startInternal() error { } } + // Resolve the trust context configured for this service (falling back + // to the "default" trust context if none is set), and hold on to a + // reference to it for as long as the service process is running, so its + // CA bundle file remains valid. The reference is released once the + // process has finished (see the goroutine below). + if s.manager.trustMgr != nil { + trustContextName := s.config.TrustContext + if trustContextName == "" { + trustContextName = truststate.DefaultTrustContext + } + trustContext, err := s.manager.trustMgr.TrustContext(trustContextName) + if err != nil { + logger.Noticef("Cannot resolve trust context %q for service %q: %v", trustContextName, serviceName, err) + } else if trustContext.IsSystemCA() { + trustContext.Close() + } else { + s.trustContext = trustContext + // Provide the service a CA bundle via SSL_CERT_FILE, unless the + // service has set that environment variable itself. + if _, ok := environment["SSL_CERT_FILE"]; !ok { + caBundleFile, err := trustContext.CABundleFile() + if err != nil { + logger.Noticef("Cannot get CA bundle file for trust context %q: %v", trustContextName, err) + } else { + environment["SSL_CERT_FILE"] = caBundleFile + } + } + } + } + // Pass service description's environment variables to child process. s.cmd.Env = os.Environ() for k, v := range environment { @@ -447,7 +484,6 @@ func (s *serviceData) startInternal() error { // started (previous logs have already been copied). outputIterator = s.logs.HeadIterator(0) } - serviceName := s.config.Name logWriter := servicelog.NewFormatWriter(s.logs, serviceName) s.cmd.Stdout = logWriter s.cmd.Stderr = logWriter @@ -474,6 +510,10 @@ func (s *serviceData) startInternal() error { if outputIterator != nil { _ = outputIterator.Close() } + if s.trustContext != nil { + s.trustContext.Close() + s.trustContext = nil + } return fmt.Errorf("cannot start service: %w", err) } logger.Debugf("Service %q started with PID %d", serviceName, s.cmd.Process.Pid) @@ -482,6 +522,7 @@ func (s *serviceData) startInternal() error { // Start a goroutine to wait for the process to finish. done := make(chan struct{}) cmd := s.cmd + trustContext := s.trustContext go func() { exitCode, waitErr := reaper.WaitCommand(cmd) if waitErr != nil { @@ -490,6 +531,10 @@ func (s *serviceData) startInternal() error { logger.Debugf("Service %q exited with code %d.", serviceName, exitCode) } close(done) + if trustContext != nil { + trustContext.Close() + trustContext = nil + } err := s.exited(exitCode) if err != nil { logger.Noticef("Cannot transition state after service exit: %v", err) diff --git a/internals/overlord/servstate/manager.go b/internals/overlord/servstate/manager.go index 5662ef015..7b8bcc1e1 100644 --- a/internals/overlord/servstate/manager.go +++ b/internals/overlord/servstate/manager.go @@ -13,6 +13,7 @@ import ( "github.com/canonical/pebble/internals/metrics" "github.com/canonical/pebble/internals/overlord/restart" "github.com/canonical/pebble/internals/overlord/state" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/plan" "github.com/canonical/pebble/internals/servicelog" "github.com/canonical/pebble/internals/workloads" @@ -36,18 +37,25 @@ type ServiceManager struct { randLock sync.Mutex rand *rand.Rand - logMgr LogManager + logMgr LogManager + trustMgr TrustManager } type LogManager interface { ServiceStarted(service *plan.Service, logs *servicelog.RingBuffer) } +// TrustManager provides access to the CA trust contexts declared in the +// plan, as maintained by truststate.TrustManager. +type TrustManager interface { + TrustContext(name string) (*truststate.TrustContext, error) +} + type Restarter interface { HandleRestart(t restart.RestartType) } -func NewManager(s *state.State, runner *state.TaskRunner, serviceOutput io.Writer, restarter Restarter, logMgr LogManager) (*ServiceManager, error) { +func NewManager(s *state.State, runner *state.TaskRunner, serviceOutput io.Writer, restarter Restarter, logMgr LogManager, trustMgr TrustManager) (*ServiceManager, error) { manager := &ServiceManager{ state: s, services: make(map[string]*serviceData), @@ -55,6 +63,7 @@ func NewManager(s *state.State, runner *state.TaskRunner, serviceOutput io.Write restarter: restarter, rand: rand.New(rand.NewSource(time.Now().UnixNano())), logMgr: logMgr, + trustMgr: trustMgr, } runner.AddHandler("start", manager.doStart, nil) diff --git a/internals/overlord/servstate/manager_test.go b/internals/overlord/servstate/manager_test.go index 6d599be91..179b4d1a7 100644 --- a/internals/overlord/servstate/manager_test.go +++ b/internals/overlord/servstate/manager_test.go @@ -40,6 +40,7 @@ import ( "github.com/canonical/pebble/internals/overlord/restart" "github.com/canonical/pebble/internals/overlord/servstate" "github.com/canonical/pebble/internals/overlord/state" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/plan" "github.com/canonical/pebble/internals/reaper" "github.com/canonical/pebble/internals/servicelog" @@ -120,6 +121,7 @@ type S struct { manager *servstate.ServiceManager runner *state.TaskRunner stopDaemon chan restart.RestartType + trustMgr *truststate.TrustManager plan *plan.Plan planPropagated bool @@ -168,6 +170,8 @@ func (s *S) SetUpTest(c *C) { restore = func() { plan.UnregisterSectionExtension(workloads.WorkloadsField) } s.AddCleanup(restore) + s.trustMgr = truststate.NewManager(filepath.Join(s.dir, "trust")) + s.plan = plan.NewPlan() s.planPropagated = false s.manager = nil @@ -859,6 +863,115 @@ PEBBLE_ENV_TEST_PARENT=from-parent `[1:]) } +// testCACertPEM is a self-signed CA certificate used to exercise trust +// context resolution in tests. It has no corresponding private key checked +// in; it's only used as CA certificate data, never to sign anything. +const testCACertPEM = `-----BEGIN CERTIFICATE----- +MIIDEzCCAfugAwIBAgIUIvvZuKuTEAhQ3k+mSVtSdYkODUYwDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOcGViYmxlLXRlc3QtY2EwHhcNMjYwODEwMDUyNTM2WhcN +MzYwODA3MDUyNTM2WjAZMRcwFQYDVQQDDA5wZWJibGUtdGVzdC1jYTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAPHveEb1T/2cYyhJElZM1qeMoDs4DthU +no3Y07E8aDOvSR6OIF4xG27eJeQZBYqClmNxpgvUmzdycbQia5InZxlnikyAXsjL +0hgPDNzLkxNZZtKTeQdOjLaUuBWN8lLXnz+5Mq5584fbbd5nOtVPmH3hhcbL07LW +rABj9/9qrxKbAGeZfQBYpwRtwiZR5KUaQ3Ed+uuA4eLV5PxAmYos3xI2ibLbwG98 +mG6IFbk0x1FoJ5T4nyouNwrCfaX8NNaa8KX+SiVBRRj+tzJiklKLHTe5kpxsX6cH +ky/YTIC2Gb6RyWQrkPXe0uOX4NamNHIF+Kl3wYKl2AoAtDdM9mjvd9MCAwEAAaNT +MFEwHQYDVR0OBBYEFIa+M4EWaY5tLSHawLqId6sYqHutMB8GA1UdIwQYMBaAFIa+ +M4EWaY5tLSHawLqId6sYqHutMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL +BQADggEBAHq4o4YxsqcfJlUS9XgkTynt6VUgUiDDgd2fFU2NsjKCUyGQFm3wQ177 +dGm0XbBUtzxnHGELKCmmU9Yve8SOy3ez4yC2dSSdi4OO1eMydjMeipfu2oWOKhn2 +n4w4B7LrGGqKGWrCqXCw3cfXNit0AzyS6Qe+EtLFrCF91UeOcJAwBzmrJGSIZfck +P+z0FL8MP8rx2X8eHYldHgb1AIa1r47qJ8oF/Jd7vmyddit0ZMIu8udxQ0hYaOe/ +eR5T5RBSrkwDisFdU2q8XOkzbXvQtYDtTRifUVbnBAgppl9H5PHaJP+WHnJCLjBX +yqWyrDYJ7zt3gver4qn7zSZ4TTVl6/4= +-----END CERTIFICATE-----` + +// indentPEM indents each line of a PEM block by the given number of spaces, +// so it can be embedded in a YAML literal block scalar at the right nesting +// level. +func indentPEM(pemData string, spaces int) string { + prefix := strings.Repeat(" ", spaces) + lines := strings.Split(strings.TrimSpace(pemData), "\n") + for i, line := range lines { + lines[i] = prefix + line + } + return strings.Join(lines, "\n") +} + +// TestTrustContextSetsSSLCertFile checks that, when a service declares a +// trust context, its resolved CA bundle file is exposed to the service via +// the SSL_CERT_FILE environment variable. +func (s *S) TestTrustContextSetsSSLCertFile(c *C) { + s.newServiceManager(c) + s.planAddLayer(c, testPlanLayer) + + dir := c.MkDir() + logPath := filepath.Join(dir, "log.txt") + layer := fmt.Sprintf(` +trust-contexts: + vendorA: + override: replace + tls: + ca-cert: | +%s + +services: + ssltest: + override: replace + command: /bin/sh -c "echo -n $SSL_CERT_FILE > %s; {{.NotifyDoneCheck}}; sleep 10" + trust-context: vendorA +`, indentPEM(testCACertPEM, 16), logPath) + s.planAddLayer(c, layer) + s.planChanged(c) + + chg := s.startServices(c, [][]string{{"ssltest"}}) + s.st.Lock() + c.Check(chg.Status(), Equals, state.DoneStatus, Commentf("Error: %v", chg.Err())) + s.st.Unlock() + + s.waitForDoneCheck(c, "ssltest") + + data, err := os.ReadFile(logPath) + c.Assert(err, IsNil) + sslCertFile := string(data) + c.Assert(sslCertFile, Not(Equals), "") + + bundle, err := os.ReadFile(sslCertFile) + c.Assert(err, IsNil) + c.Assert(strings.Contains(string(bundle), strings.TrimSpace(testCACertPEM)), Equals, true) +} + +// TestTrustContextSSLCertFileNotOverridden checks that SSL_CERT_FILE is left +// untouched when the service has set it manually. +func (s *S) TestTrustContextSSLCertFileNotOverridden(c *C) { + s.newServiceManager(c) + s.planAddLayer(c, testPlanLayer) + + dir := c.MkDir() + logPath := filepath.Join(dir, "log.txt") + layer := fmt.Sprintf(` +services: + sslmanual: + override: replace + command: /bin/sh -c "echo -n $SSL_CERT_FILE > %s; {{.NotifyDoneCheck}}; sleep 10" + environment: + SSL_CERT_FILE: /custom/path/ca.pem +`, logPath) + s.planAddLayer(c, layer) + s.planChanged(c) + + chg := s.startServices(c, [][]string{{"sslmanual"}}) + s.st.Lock() + c.Check(chg.Status(), Equals, state.DoneStatus, Commentf("Error: %v", chg.Err())) + s.st.Unlock() + + s.waitForDoneCheck(c, "sslmanual") + + data, err := os.ReadFile(logPath) + c.Assert(err, IsNil) + c.Assert(string(data), Equals, "/custom/path/ca.pem") +} + // TestActionRestart makes sure that the service restart backoff mechanism // works as designed, including the reset of backoff once a service runs // continuously for at least the backoff limit duration. @@ -1987,12 +2100,13 @@ func (s *S) tryPlanAddLayer(c *C, layerYAML string) error { func (s *S) newServiceManager(c *C) { var err error - s.manager, err = servstate.NewManager(s.st, s.runner, s.logOutput, testRestarter{s.stopDaemon}, fakeLogManager{}) + s.manager, err = servstate.NewManager(s.st, s.runner, s.logOutput, testRestarter{s.stopDaemon}, fakeLogManager{}, s.trustMgr) c.Assert(err, IsNil) } func (s *S) planChanged(c *C) { c.Assert(s.plan, NotNil) + s.trustMgr.PlanChanged(s.plan) s.manager.PlanChanged(s.plan) s.planPropagated = true } diff --git a/internals/overlord/truststate/context.go b/internals/overlord/truststate/context.go index 06b7f9d3b..494626b42 100644 --- a/internals/overlord/truststate/context.go +++ b/internals/overlord/truststate/context.go @@ -43,6 +43,17 @@ type TrustContext struct { version *trustContextVersion } +// IsSystemCA returns true if the trust context resolved to the system CA pool, +// either directly or indirectly. +func (t *TrustContext) IsSystemCA() bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.version == nil { + return false + } + return t.version.isSystemPool +} + // CAPool returns a certificate pool containing all of the CA certificates // trusted by this trust context, including those pulled in transitively via // "include". The returned pool is a fresh copy on each call, so it's safe @@ -96,10 +107,11 @@ type trustContextVersion struct { mgr *TrustManager name string - shortSha string - pemBundle []byte - pool *x509.CertPool - filePath string + shortSha string + pemBundle []byte + pool *x509.CertPool + isSystemPool bool + filePath string // refCount, superseded and cleaned are all only ever accessed while // holding mgr.mu. diff --git a/internals/overlord/truststate/manager.go b/internals/overlord/truststate/manager.go index ebe323f1d..afe7b5a1d 100644 --- a/internals/overlord/truststate/manager.go +++ b/internals/overlord/truststate/manager.go @@ -159,12 +159,13 @@ func (m *TrustManager) PlanChanged(pl *plan.Plan) { continue } v := &trustContextVersion{ - mgr: m, - name: r.name, - shortSha: r.data.shortSha, - pemBundle: r.data.pemBundle, - pool: r.data.pool, - filePath: bundleFilePath(m.trustDir, r.name, r.data.shortSha), + mgr: m, + name: r.name, + shortSha: r.data.shortSha, + pemBundle: r.data.pemBundle, + pool: r.data.pool, + isSystemPool: r.data.isSystemPool, + filePath: bundleFilePath(m.trustDir, r.name, r.data.shortSha), } newCurrent[r.name] = v if old != nil { @@ -219,9 +220,10 @@ func (m *TrustManager) ensureTrustDir() error { // resolvedTrust holds the trust data for a single trust context. type resolvedTrust struct { - pemBundle []byte - pool *x509.CertPool - shortSha string + pemBundle []byte + pool *x509.CertPool + isSystemPool bool + shortSha string } // resolve computes the fully-resolved CA pool and PEM bundle for the named @@ -280,9 +282,10 @@ func (m *TrustManager) resolve(name string, pl *plan.Plan) (*resolvedTrust, erro shortSha := hex.EncodeToString(sum[:])[:8] return &resolvedTrust{ - pemBundle: pemBundle, - pool: pool, - shortSha: shortSha, + pemBundle: pemBundle, + pool: pool, + isSystemPool: includesSystem && len(pemParts) == 0, + shortSha: shortSha, }, nil } From 16331995e63b7509eaea784129678c6c9b0333f3 Mon Sep 17 00:00:00 2001 From: Harry Pidcock Date: Tue, 11 Aug 2026 09:25:39 +1000 Subject: [PATCH 5/9] feat: use trust context in exec and http checks --- internals/overlord/checkstate/checkers.go | 73 +++++++-- .../overlord/checkstate/checkers_test.go | 150 ++++++++++++++++-- internals/overlord/checkstate/handlers.go | 4 +- internals/overlord/checkstate/manager.go | 50 +++--- internals/overlord/checkstate/manager_test.go | 2 +- internals/overlord/overlord.go | 2 +- internals/overlord/servstate/handlers.go | 33 ++-- internals/overlord/servstate/manager_test.go | 8 +- internals/overlord/truststate/manager.go | 6 +- 9 files changed, 255 insertions(+), 73 deletions(-) diff --git a/internals/overlord/checkstate/checkers.go b/internals/overlord/checkstate/checkers.go index c9b2a099b..31ab64d2c 100644 --- a/internals/overlord/checkstate/checkers.go +++ b/internals/overlord/checkstate/checkers.go @@ -16,6 +16,7 @@ package checkstate import ( "context" + "crypto/tls" "errors" "fmt" "io" @@ -44,14 +45,39 @@ const ( // httpChecker is a checker that ensures an HTTP GET at a specified URL returns 2xx. type httpChecker struct { - name string - url string - headers map[string]string + name string + url string + headers map[string]string + trustContext string + trustMgr TrustManager } func (c *httpChecker) check(ctx context.Context) error { logger.Debugf("Check %q (http): requesting %q", c.name, c.url) client := &http.Client{} + + // Resolve the trust context configured for this check (falling back to + // the "default" trust context if none is set), and use its CA bundle to + // validate the server's certificate, unless it resolves to the system CA + // pool, in which case the default HTTP client behaviour is used. + if c.trustMgr == nil { + logger.Noticef("Check %q (exec): cannot resolve trust context %q: no trust manager", c.name, c.trustContext) + } else if trustContext, err := c.trustMgr.TrustContext(c.trustContext); err != nil { + logger.Noticef("Check %q (exec): cannot resolve trust context %q: %v", c.name, c.trustContext, err) + } else if trustContext.IsSystemCA() { + trustContext.Close() + } else { + defer trustContext.Close() + pool, err := trustContext.CAPool() + if err != nil { + logger.Noticef("Check %q (http): cannot get CA pool for trust context %q: %v", c.name, c.trustContext, err) + } else { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool}, + } + } + } + request, err := http.NewRequestWithContext(ctx, "GET", c.url, nil) if err != nil { return fmt.Errorf("cannot build request: %w", err) @@ -117,14 +143,16 @@ func (c *tcpChecker) check(ctx context.Context) error { // execChecker is a checker that ensures a command executes successfully. type execChecker struct { - name string - command string - environment map[string]string - userID *int - user string - groupID *int - group string - workingDir string + name string + command string + environment map[string]string + userID *int + user string + groupID *int + group string + workingDir string + trustContext string + trustMgr TrustManager } func (c *execChecker) check(ctx context.Context) error { @@ -138,6 +166,29 @@ func (c *execChecker) check(ctx context.Context) error { // Requested environment takes precedence. maps.Copy(environment, c.environment) + // Resolve the trust context configured for this check (falling back to + // the "default" trust context if none is set), and provide its CA bundle + // via SSL_CERT_FILE, unless the check has set that environment variable + // itself, or the trust context resolves to the system CA pool. + if c.trustMgr == nil { + logger.Noticef("Check %q (exec): cannot resolve trust context %q: no trust manager", c.name, c.trustContext) + } else if trustContext, err := c.trustMgr.TrustContext(c.trustContext); err != nil { + logger.Noticef("Check %q (exec): cannot resolve trust context %q: %v", c.name, c.trustContext, err) + } else if trustContext.IsSystemCA() || c.environment["SSL_CERT_FILE"] != "" { + trustContext.Close() + } else { + defer trustContext.Close() + caBundleFile, err := trustContext.CABundleFile() + if err != nil { + logger.Noticef( + "Check %q (exec): cannot get CA bundle file for trust context %q: %v", + c.name, c.trustContext, err, + ) + } else { + environment["SSL_CERT_FILE"] = caBundleFile + } + } + cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Env = make([]string, 0, len(environment)) // avoid additional allocations for k, v := range environment { diff --git a/internals/overlord/checkstate/checkers_test.go b/internals/overlord/checkstate/checkers_test.go index 57608d577..6de4ca30c 100644 --- a/internals/overlord/checkstate/checkers_test.go +++ b/internals/overlord/checkstate/checkers_test.go @@ -17,6 +17,7 @@ package checkstate import ( "bytes" "context" + "encoding/pem" "fmt" "net" "net/http" @@ -24,13 +25,43 @@ import ( "os" "os/user" "strconv" + "strings" . "gopkg.in/check.v1" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/plan" "github.com/canonical/pebble/internals/reaper" ) +// testCACertPEM returns a self-signed certificate (PEM-encoded), suitable +// for use as CA certificate data in a trust context. It's the certificate +// httptest.NewTLSServer presents by default, so it can also be used to +// establish trust with a server created that way. +func testCACertPEM(c *C) string { + server := httptest.NewTLSServer(http.NotFoundHandler()) + defer server.Close() + return string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: server.Certificate().Raw, + })) +} + +// newTestTrustManager creates a truststate.TrustManager with a single named +// trust context ("vendorA") backed by caCertPEM. +func newTestTrustManager(c *C, caCertPEM string) *truststate.TrustManager { + trustMgr := truststate.NewManager(c.MkDir()) + trustMgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": { + Name: "vendorA", + TLS: &plan.TLSTrustContext{CACert: caCertPEM}, + }, + }, + }) + return trustMgr +} + type CheckersSuite struct{} var _ = Suite(&CheckersSuite{}) @@ -119,6 +150,40 @@ func (s *CheckersSuite) TestHTTP(c *C) { c.Assert(err, ErrorMatches, "cannot build request: .*") } +func (s *CheckersSuite) TestHTTPTrustContext(c *C) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "ok") + })) + defer server.Close() + + trustMgr := newTestTrustManager(c, testCACertPEM(c)) + + // Without a trust context, the server's self-signed certificate isn't + // trusted by the system pool, so the check fails. + chk := &httpChecker{url: server.URL} + err := chk.check(context.Background()) + c.Assert(err, ErrorMatches, ".*(certificate|x509).*") + + // With the matching trust context configured, the check succeeds. + chk = &httpChecker{ + url: server.URL, + trustContext: "vendorA", + trustMgr: trustMgr, + } + err = chk.check(context.Background()) + c.Assert(err, IsNil) + + // An unknown trust context logs an error and falls back to the default + // HTTP client behaviour, so the check still fails. + chk = &httpChecker{ + url: server.URL, + trustContext: "unknown", + trustMgr: trustMgr, + } + err = chk.check(context.Background()) + c.Assert(err, ErrorMatches, ".*(certificate|x509).*") +} + func (s *CheckersSuite) TestTCP(c *C) { listener, err := net.Listen("tcp", "localhost:") c.Assert(err, IsNil) @@ -259,19 +324,74 @@ func (s *CheckersSuite) TestExec(c *C) { c.Assert(detailsErr.Details(), Equals, currentUser.Username) } +func (s *CheckersSuite) TestExecTrustContext(c *C) { + err := reaper.Start() + c.Assert(err, IsNil) + defer reaper.Stop() + + caCertPEM := testCACertPEM(c) + trustMgr := newTestTrustManager(c, caCertPEM) + + // With a trust context configured, SSL_CERT_FILE is set to the resolved + // CA bundle file's path, which contains the trust context's CA cert. + var sslCertFile string + chk := &execChecker{ + command: "/bin/sh -c 'echo -n $SSL_CERT_FILE; exit 1'", + trustContext: "vendorA", + trustMgr: trustMgr, + } + err = chk.check(context.Background()) + c.Assert(err, ErrorMatches, "exit status 1") + detailsErr, ok := err.(*detailsError) + c.Assert(ok, Equals, true) + sslCertFile = detailsErr.Details() + c.Assert(sslCertFile, Not(Equals), "") + + bundle, err := os.ReadFile(sslCertFile) + c.Assert(err, IsNil) + c.Assert(strings.Contains(string(bundle), strings.TrimSpace(caCertPEM)), Equals, true) + + // SSL_CERT_FILE is left untouched if the check has set it explicitly. + chk = &execChecker{ + command: "/bin/sh -c 'echo -n $SSL_CERT_FILE; exit 1'", + environment: map[string]string{"SSL_CERT_FILE": "/custom/path"}, + trustContext: "vendorA", + trustMgr: trustMgr, + } + err = chk.check(context.Background()) + c.Assert(err, ErrorMatches, "exit status 1") + detailsErr, ok = err.(*detailsError) + c.Assert(ok, Equals, true) + c.Assert(detailsErr.Details(), Equals, "/custom/path") + + // An unknown trust context logs an error and leaves SSL_CERT_FILE unset. + chk = &execChecker{ + command: "/bin/sh -c 'echo -n $SSL_CERT_FILE; exit 1'", + trustContext: "unknown", + trustMgr: trustMgr, + } + err = chk.check(context.Background()) + c.Assert(err, ErrorMatches, "exit status 1") + detailsErr, ok = err.(*detailsError) + c.Assert(ok, Equals, true) + c.Assert(detailsErr.Details(), Equals, "") +} + func (s *CheckersSuite) TestNewChecker(c *C) { chk := newChecker(&plan.Check{ Name: "http", HTTP: &plan.HTTPCheck{ - URL: "https://example.com/foo", - Headers: map[string]string{"k": "v"}, + URL: "https://example.com/foo", + Headers: map[string]string{"k": "v"}, + TrustContext: "vendorA", }, - }) + }, nil) http, ok := chk.(*httpChecker) c.Assert(ok, Equals, true) c.Check(http.name, Equals, "http") c.Check(http.url, Equals, "https://example.com/foo") c.Check(http.headers, DeepEquals, map[string]string{"k": "v"}) + c.Check(http.trustContext, Equals, "vendorA") chk = newChecker(&plan.Check{ Name: "tcp", @@ -279,7 +399,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) { Port: 80, Host: "localhost", }, - }) + }, nil) tcp, ok := chk.(*tcpChecker) c.Assert(ok, Equals, true) c.Check(tcp.name, Equals, "tcp") @@ -290,15 +410,16 @@ func (s *CheckersSuite) TestNewChecker(c *C) { chk = newChecker(&plan.Check{ Name: "exec", Exec: &plan.ExecCheck{ - Command: "sleep 1", - Environment: map[string]string{"k": "v"}, - UserID: &userID, - User: "user", - GroupID: &groupID, - Group: "group", - WorkingDir: "/working/dir", + Command: "sleep 1", + Environment: map[string]string{"k": "v"}, + UserID: &userID, + User: "user", + GroupID: &groupID, + Group: "group", + WorkingDir: "/working/dir", + TrustContext: "vendorA", }, - }) + }, nil) exec, ok := chk.(*execChecker) c.Assert(ok, Equals, true) c.Assert(exec.name, Equals, "exec") @@ -308,6 +429,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) { c.Assert(exec.user, Equals, "user") c.Assert(exec.groupID, Equals, &groupID) c.Assert(exec.workingDir, Equals, "/working/dir") + c.Assert(exec.trustContext, Equals, "vendorA") } func (s *CheckersSuite) TestExecContextNoOverride(c *C) { @@ -329,7 +451,7 @@ func (s *CheckersSuite) TestExecContextNoOverride(c *C) { ServiceContext: "svc1", }, }) - chk := newChecker(config) + chk := newChecker(config, nil) exec, ok := chk.(*execChecker) c.Assert(ok, Equals, true) c.Check(exec.name, Equals, "exec") @@ -367,7 +489,7 @@ func (s *CheckersSuite) TestExecContextOverride(c *C) { WorkingDir: "/working/dir", }, }) - chk := newChecker(config) + chk := newChecker(config, nil) exec, ok := chk.(*execChecker) c.Assert(ok, Equals, true) c.Check(exec.name, Equals, "exec") diff --git a/internals/overlord/checkstate/handlers.go b/internals/overlord/checkstate/handlers.go index 88697f594..5ac66d9b1 100644 --- a/internals/overlord/checkstate/handlers.go +++ b/internals/overlord/checkstate/handlers.go @@ -48,7 +48,7 @@ func (m *CheckManager) doPerformCheck(task *state.Task, tomb *tombpkg.Tomb) erro prevChangeID := data.prevChangeID m.checksLock.Unlock() - chk := newChecker(config) + chk := newChecker(config, m.trustMgr) performCheck := func() (shouldExit bool, err error) { //lint:ignore SA1012 providing a nil context to tomb.Context() is valid @@ -166,7 +166,7 @@ func (m *CheckManager) doRecoverCheck(task *state.Task, tomb *tombpkg.Tomb) erro prevChangeID := data.prevChangeID m.checksLock.Unlock() - chk := newChecker(config) + chk := newChecker(config, m.trustMgr) recoverCheck := func() (shouldExit bool, err error) { //lint:ignore SA1012 providing a nil context to tomb.Context() is valid diff --git a/internals/overlord/checkstate/manager.go b/internals/overlord/checkstate/manager.go index 99d2e324f..a0e44bbcd 100644 --- a/internals/overlord/checkstate/manager.go +++ b/internals/overlord/checkstate/manager.go @@ -28,6 +28,7 @@ import ( "github.com/canonical/pebble/internals/metrics" "github.com/canonical/pebble/internals/overlord/planstate" "github.com/canonical/pebble/internals/overlord/state" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/plan" ) @@ -41,8 +42,9 @@ const ( // CheckManager starts and manages the health checks. type CheckManager struct { - state *state.State - planMgr *planstate.PlanManager + state *state.State + planMgr *planstate.PlanManager + trustMgr TrustManager failureHandlers []FailureFunc @@ -53,12 +55,18 @@ type CheckManager struct { // FailureFunc is the type of function called when a failure action is triggered. type FailureFunc func(name string) +// TrustManager provides access to the trust context. +type TrustManager interface { + TrustContext(name string) (*truststate.TrustContext, error) +} + // NewManager creates a new check manager. -func NewManager(s *state.State, runner *state.TaskRunner, planMgr *planstate.PlanManager) *CheckManager { +func NewManager(s *state.State, runner *state.TaskRunner, planMgr *planstate.PlanManager, trustMgr TrustManager) *CheckManager { manager := &CheckManager{ - state: s, - checks: make(map[string]*checkData), - planMgr: planMgr, + state: s, + checks: make(map[string]*checkData), + planMgr: planMgr, + trustMgr: trustMgr, } // Health check changes can be long-running; ensure they don't get pruned. @@ -254,13 +262,15 @@ func checkType(config *plan.Check) string { // newChecker creates a new checker of the configured type. Assumes // mergeServiceContext has already been called. -func newChecker(config *plan.Check) checker { +func newChecker(config *plan.Check, trustMgr TrustManager) checker { switch { case config.HTTP != nil: return &httpChecker{ - name: config.Name, - url: config.HTTP.URL, - headers: config.HTTP.Headers, + name: config.Name, + url: config.HTTP.URL, + headers: config.HTTP.Headers, + trustContext: config.HTTP.TrustContext, + trustMgr: trustMgr, } case config.TCP != nil: @@ -272,14 +282,16 @@ func newChecker(config *plan.Check) checker { case config.Exec != nil: return &execChecker{ - name: config.Name, - command: config.Exec.Command, - environment: config.Exec.Environment, - userID: config.Exec.UserID, - user: config.Exec.User, - groupID: config.Exec.GroupID, - group: config.Exec.Group, - workingDir: config.Exec.WorkingDir, + name: config.Name, + command: config.Exec.Command, + environment: config.Exec.Environment, + userID: config.Exec.UserID, + user: config.Exec.User, + groupID: config.Exec.GroupID, + group: config.Exec.Group, + workingDir: config.Exec.WorkingDir, + trustContext: config.Exec.TrustContext, + trustMgr: trustMgr, } default: @@ -658,7 +670,7 @@ func (m *CheckManager) RefreshCheck(ctx context.Context, check *plan.Check) (*Ch // If the check is stopped, run the check directly without using changes and tasks. if changeID == "" { - chk := newChecker(check) + chk := newChecker(check, m.trustMgr) err := runCheck(ctx, chk, check.Timeout.Value) if err != nil { return getCheckInfo(), fmt.Errorf("%s", errorDetails(err)) diff --git a/internals/overlord/checkstate/manager_test.go b/internals/overlord/checkstate/manager_test.go index e38bbfe98..c1a1f390f 100644 --- a/internals/overlord/checkstate/manager_test.go +++ b/internals/overlord/checkstate/manager_test.go @@ -72,7 +72,7 @@ func (s *ManagerSuite) SetUpTest(c *C) { s.planMgr, err = planstate.NewManager(layersDir) c.Assert(err, IsNil) s.overlord.AddManager(s.planMgr) - s.manager = checkstate.NewManager(s.overlord.State(), s.overlord.TaskRunner(), s.planMgr) + s.manager = checkstate.NewManager(s.overlord.State(), s.overlord.TaskRunner(), s.planMgr, nil) s.planMgr.AddChangeListener(s.manager.PlanChanged) s.overlord.AddManager(s.manager) s.overlord.AddManager(s.overlord.TaskRunner()) diff --git a/internals/overlord/overlord.go b/internals/overlord/overlord.go index 98a75c7f3..3d6fea577 100644 --- a/internals/overlord/overlord.go +++ b/internals/overlord/overlord.go @@ -256,7 +256,7 @@ func New(opts *Options) (*Overlord, error) { o.commandMgr = cmdstate.NewManager(o.runner) o.stateEng.AddManager(o.commandMgr) - o.checkMgr = checkstate.NewManager(s, o.runner, o.planMgr) + o.checkMgr = checkstate.NewManager(s, o.runner, o.planMgr, o.trustMgr) o.stateEng.AddManager(o.checkMgr) // Tell check manager about plan updates. diff --git a/internals/overlord/servstate/handlers.go b/internals/overlord/servstate/handlers.go index c492c50f9..2ec430789 100644 --- a/internals/overlord/servstate/handlers.go +++ b/internals/overlord/servstate/handlers.go @@ -446,28 +446,21 @@ func (s *serviceData) startInternal() error { // reference to it for as long as the service process is running, so its // CA bundle file remains valid. The reference is released once the // process has finished (see the goroutine below). - if s.manager.trustMgr != nil { - trustContextName := s.config.TrustContext - if trustContextName == "" { - trustContextName = truststate.DefaultTrustContext - } - trustContext, err := s.manager.trustMgr.TrustContext(trustContextName) + hasCertFileEnv := s.config.Environment["SSL_CERT_FILE"] != "" || + s.workload != nil && s.workload.Environment["SSL_CERT_FILE"] != "" + if s.manager.trustMgr == nil { + logger.Noticef("Cannot resolve trust context %q for service %q: no trust manager", s.config.TrustContext, serviceName) + } else if trustContext, err := s.manager.trustMgr.TrustContext(s.config.TrustContext); err != nil { + logger.Noticef("Cannot resolve trust context %q for service %q: %v", s.config.TrustContext, serviceName, err) + } else if trustContext.IsSystemCA() || hasCertFileEnv { + trustContext.Close() + } else { + s.trustContext = trustContext + caBundleFile, err := trustContext.CABundleFile() if err != nil { - logger.Noticef("Cannot resolve trust context %q for service %q: %v", trustContextName, serviceName, err) - } else if trustContext.IsSystemCA() { - trustContext.Close() + logger.Noticef("Cannot get CA bundle file for trust context %q: %v", s.config.TrustContext, err) } else { - s.trustContext = trustContext - // Provide the service a CA bundle via SSL_CERT_FILE, unless the - // service has set that environment variable itself. - if _, ok := environment["SSL_CERT_FILE"]; !ok { - caBundleFile, err := trustContext.CABundleFile() - if err != nil { - logger.Noticef("Cannot get CA bundle file for trust context %q: %v", trustContextName, err) - } else { - environment["SSL_CERT_FILE"] = caBundleFile - } - } + environment["SSL_CERT_FILE"] = caBundleFile } } diff --git a/internals/overlord/servstate/manager_test.go b/internals/overlord/servstate/manager_test.go index 179b4d1a7..31a9ec0f4 100644 --- a/internals/overlord/servstate/manager_test.go +++ b/internals/overlord/servstate/manager_test.go @@ -1109,7 +1109,7 @@ func (s *S) TestOnCheckFailureRestartWhileRunning(c *C) { s.planAddLayer(c, testPlanLayer) // Create check manager and tell it about plan updates - checkMgr := checkstate.NewManager(s.st, s.runner, nil) + checkMgr := checkstate.NewManager(s.st, s.runner, nil, s.trustMgr) defer checkMgr.PlanChanged(plan.NewPlan()) // Tell service manager about check failures @@ -1204,7 +1204,7 @@ func (s *S) TestOnCheckFailureRestartDuringBackoff(c *C) { s.planAddLayer(c, testPlanLayer) // Create check manager and tell it about plan updates - checkMgr := checkstate.NewManager(s.st, s.runner, nil) + checkMgr := checkstate.NewManager(s.st, s.runner, nil, s.trustMgr) defer checkMgr.PlanChanged(plan.NewPlan()) // Tell service manager about check failures @@ -1296,7 +1296,7 @@ func (s *S) TestOnCheckFailureIgnore(c *C) { s.planAddLayer(c, testPlanLayer) // Create check manager and tell it about plan updates - checkMgr := checkstate.NewManager(s.st, s.runner, nil) + checkMgr := checkstate.NewManager(s.st, s.runner, nil, s.trustMgr) defer checkMgr.PlanChanged(plan.NewPlan()) // Tell service manager about check failures @@ -1381,7 +1381,7 @@ func (s *S) testOnCheckFailureShutdown(c *C, action string, restartType restart. s.planAddLayer(c, testPlanLayer) // Create check manager and tell it about plan updates - checkMgr := checkstate.NewManager(s.st, s.runner, nil) + checkMgr := checkstate.NewManager(s.st, s.runner, nil, s.trustMgr) defer checkMgr.PlanChanged(plan.NewPlan()) // Tell service manager about check failures diff --git a/internals/overlord/truststate/manager.go b/internals/overlord/truststate/manager.go index afe7b5a1d..c08b353cd 100644 --- a/internals/overlord/truststate/manager.go +++ b/internals/overlord/truststate/manager.go @@ -196,8 +196,12 @@ func (m *TrustManager) PlanChanged(pl *plan.Plan) { // TrustContext returns a reference to the current resolved state of the // named trust context. The caller must call Close on the returned -// TrustContext once it is no longer needed. +// TrustContext once it is no longer needed. If passed a trust context name that +// is an empty string, the default trust context is returned. func (m *TrustManager) TrustContext(name string) (*TrustContext, error) { + if name == "" { + name = DefaultTrustContext + } m.mu.Lock() v, ok := m.current[name] if ok { From cf8eca300c6a5590d0d684a2189b0b0de1b03080 Mon Sep 17 00:00:00 2001 From: Harry Pidcock Date: Tue, 11 Aug 2026 10:28:50 +1000 Subject: [PATCH 6/9] feat: use trust context for http log targets --- internals/overlord/logstate/gatherer.go | 30 +++++----- internals/overlord/logstate/gatherer_test.go | 17 +++--- internals/overlord/logstate/loki/loki.go | 55 +++++++++++++++++++ internals/overlord/logstate/loki/loki_test.go | 51 +++++++++++++++++ internals/overlord/logstate/manager.go | 16 +++++- internals/overlord/logstate/manager_test.go | 24 ++++---- .../logstate/opentelemetry/opentelemetry.go | 55 +++++++++++++++++++ .../opentelemetry/opentelemetry_test.go | 52 ++++++++++++++++++ internals/overlord/overlord.go | 2 +- 9 files changed, 265 insertions(+), 37 deletions(-) diff --git a/internals/overlord/logstate/gatherer.go b/internals/overlord/logstate/gatherer.go index 47ecbdfe7..160b15bc7 100644 --- a/internals/overlord/logstate/gatherer.go +++ b/internals/overlord/logstate/gatherer.go @@ -90,19 +90,19 @@ type logGathererOptions struct { timeoutCurrentFlush time.Duration timeoutFinalFlush time.Duration // method to get a new client - newClient func(*plan.LogTarget) (logClient, error) + newClient func(*plan.LogTarget, TrustManager) (logClient, error) } -func newLogGatherer(target *plan.LogTarget) (*logGatherer, error) { - return newLogGathererInternal(target, &logGathererOptions{}) +func newLogGatherer(target *plan.LogTarget, trustMgr TrustManager) (*logGatherer, error) { + return newLogGathererInternal(target, trustMgr, &logGathererOptions{}) } // newLogGathererInternal contains the actual creation code for a logGatherer. // This function is used in the real implementation, but also allows overriding // certain configuration values for testing. -func newLogGathererInternal(target *plan.LogTarget, options *logGathererOptions) (*logGatherer, error) { +func newLogGathererInternal(target *plan.LogTarget, trustMgr TrustManager, options *logGathererOptions) (*logGatherer, error) { options = fillDefaultOptions(options) - client, err := options.newClient(target) + client, err := options.newClient(target, trustMgr) if err != nil { return nil, fmt.Errorf("cannot create log client: %w", err) } @@ -367,20 +367,24 @@ type logClient interface { SetLabels(serviceName string, labels map[string]string) } -func newLogClient(target *plan.LogTarget) (logClient, error) { +func newLogClient(target *plan.LogTarget, trustMgr TrustManager) (logClient, error) { switch target.Type { case plan.LokiTarget: return loki.NewClient(&loki.ClientOptions{ - TargetName: target.Name, - Location: target.Location, - UserAgent: fmt.Sprintf("%s/%s", cmd.ProgramName, cmd.Version), + TargetName: target.Name, + Location: target.Location, + UserAgent: fmt.Sprintf("%s/%s", cmd.ProgramName, cmd.Version), + TrustContext: target.TrustContext, + TrustManager: trustMgr, }), nil case plan.OpenTelemetryTarget: return opentelemetry.NewClient(&opentelemetry.ClientOptions{ - TargetName: target.Name, - Location: target.Location, - UserAgent: fmt.Sprintf("%s/%s", cmd.ProgramName, cmd.Version), - ScopeName: cmd.ProgramName, + TargetName: target.Name, + Location: target.Location, + UserAgent: fmt.Sprintf("%s/%s", cmd.ProgramName, cmd.Version), + ScopeName: cmd.ProgramName, + TrustContext: target.TrustContext, + TrustManager: trustMgr, }), nil case plan.SyslogTarget: hostname, err := os.Hostname() diff --git a/internals/overlord/logstate/gatherer_test.go b/internals/overlord/logstate/gatherer_test.go index 5a6e9dd9f..26d8e5e59 100644 --- a/internals/overlord/logstate/gatherer_test.go +++ b/internals/overlord/logstate/gatherer_test.go @@ -38,7 +38,7 @@ func (s *gathererSuite) TestGatherer(c *C) { received := make(chan []servicelog.Entry, 1) gathererOptions := logGathererOptions{ maxBufferedEntries: 5, - newClient: func(target *plan.LogTarget) (logClient, error) { + newClient: func(target *plan.LogTarget, trustMgr TrustManager) (logClient, error) { return &testClient{ bufferSize: 5, sendCh: received, @@ -46,7 +46,7 @@ func (s *gathererSuite) TestGatherer(c *C) { }, } - g, err := newLogGathererInternal(&plan.LogTarget{Name: "tgt1"}, &gathererOptions) + g, err := newLogGathererInternal(&plan.LogTarget{Name: "tgt1"}, nil, &gathererOptions) c.Assert(err, IsNil) testSvc := newTestService("svc1") @@ -75,7 +75,7 @@ func (s *gathererSuite) TestGathererTimeout(c *C) { received := make(chan []servicelog.Entry, 1) gathererOptions := logGathererOptions{ bufferTimeout: 1 * time.Millisecond, - newClient: func(target *plan.LogTarget) (logClient, error) { + newClient: func(target *plan.LogTarget, trustMgr TrustManager) (logClient, error) { return &testClient{ bufferSize: 5, sendCh: received, @@ -83,7 +83,7 @@ func (s *gathererSuite) TestGathererTimeout(c *C) { }, } - g, err := newLogGathererInternal(&plan.LogTarget{Name: "tgt1"}, &gathererOptions) + g, err := newLogGathererInternal(&plan.LogTarget{Name: "tgt1"}, nil, &gathererOptions) c.Assert(err, IsNil) testSvc := newTestService("svc1") @@ -102,7 +102,7 @@ func (s *gathererSuite) TestGathererShutdown(c *C) { received := make(chan []servicelog.Entry, 1) gathererOptions := logGathererOptions{ bufferTimeout: 1 * time.Microsecond, - newClient: func(target *plan.LogTarget) (logClient, error) { + newClient: func(target *plan.LogTarget, trustMgr TrustManager) (logClient, error) { return &testClient{ bufferSize: 5, sendCh: received, @@ -110,7 +110,7 @@ func (s *gathererSuite) TestGathererShutdown(c *C) { }, } - g, err := newLogGathererInternal(&plan.LogTarget{Name: "tgt1"}, &gathererOptions) + g, err := newLogGathererInternal(&plan.LogTarget{Name: "tgt1"}, nil, &gathererOptions) c.Assert(err, IsNil) testSvc := newTestService("svc1") @@ -157,10 +157,11 @@ func (s *gathererSuite) TestRetryLoki(c *C) { g, err := newLogGathererInternal( logTarget, + nil, &logGathererOptions{ bufferTimeout: 1 * time.Millisecond, maxBufferedEntries: 5, - newClient: func(target *plan.LogTarget) (logClient, error) { + newClient: func(target *plan.LogTarget, trustMgr TrustManager) (logClient, error) { return loki.NewClient(&loki.ClientOptions{ TargetName: target.Name, Location: target.Location, @@ -241,7 +242,7 @@ func (s *gathererSuite) TestConcurrency(c *C) { Labels: map[string]string{"foo": "bar-$SECRET-$SECRET2", "baz": "foo"}, } - g, err := newLogGathererInternal(target, &logGathererOptions{ + g, err := newLogGathererInternal(target, nil, &logGathererOptions{ maxBufferedEntries: 2, }) c.Assert(err, IsNil) diff --git a/internals/overlord/logstate/loki/loki.go b/internals/overlord/logstate/loki/loki.go index 1bba17640..5a1237589 100644 --- a/internals/overlord/logstate/loki/loki.go +++ b/internals/overlord/logstate/loki/loki.go @@ -17,6 +17,7 @@ package loki import ( "bytes" "context" + "crypto/tls" "encoding/json" "fmt" "io" @@ -28,6 +29,7 @@ import ( "time" "github.com/canonical/pebble/internals/logger" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/servicelog" ) @@ -36,6 +38,13 @@ const ( maxRequestEntries = 100 ) +// TrustManager provides access to the trust context, so that the client can +// validate the Loki server's certificate against the trust context +// configured for the log target. +type TrustManager interface { + TrustContext(name string) (*truststate.TrustContext, error) +} + type Client struct { options *ClientOptions httpClient *http.Client @@ -70,6 +79,14 @@ type ClientOptions struct { UserAgent string TargetName string Location string + + // TrustContext is the name of the trust context to use to validate the + // Loki server's certificate (falling back to the "default" trust + // context if empty). + TrustContext string + // TrustManager provides access to the trust context named by + // TrustContext. If nil, the default HTTP client behaviour is used. + TrustManager TrustManager } func fillDefaultOptions(options *ClientOptions) { @@ -155,6 +172,13 @@ func (c *Client) Flush(ctx context.Context) error { httpReq.Header.Set("Content-Type", "application/json; charset=utf-8") httpReq.Header.Set("User-Agent", c.options.UserAgent) + // Resolve the trust context configured for this log target (falling + // back to the "default" trust context if none is set), and use its CA + // bundle to validate the server's certificate, unless it resolves to + // the system CA pool, in which case the default HTTP client behaviour + // is used. + c.httpClient.Transport = c.resolveTransport() + resp, err := c.httpClient.Do(httpReq) if err != nil { return err @@ -163,6 +187,37 @@ func (c *Client) Flush(ctx context.Context) error { return c.handleServerResponse(resp) } +// resolveTransport resolves the trust context configured for this log +// target, and returns an *http.Transport configured to validate the +// server's certificate against it. It returns nil (meaning the default HTTP +// client behaviour should be used) if there's no trust manager, the trust +// context can't be resolved, or it resolves to the system CA pool. +func (c *Client) resolveTransport() http.RoundTripper { + if c.options.TrustManager == nil { + logger.Noticef("Log target %q (loki): cannot resolve trust context %q: no trust manager", + c.options.TargetName, c.options.TrustContext) + return nil + } + trustContext, err := c.options.TrustManager.TrustContext(c.options.TrustContext) + if err != nil { + logger.Noticef("Log target %q (loki): cannot resolve trust context %q: %v", + c.options.TargetName, c.options.TrustContext, err) + return nil + } + if trustContext.IsSystemCA() { + trustContext.Close() + return nil + } + defer trustContext.Close() + pool, err := trustContext.CAPool() + if err != nil { + logger.Noticef("Log target %q (loki): cannot get CA pool for trust context %q: %v", + c.options.TargetName, c.options.TrustContext, err) + return nil + } + return &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}} +} + // resetBuffer drops all buffered logs (in the case of a successful send, or an // unrecoverable error). func (c *Client) resetBuffer() { diff --git a/internals/overlord/logstate/loki/loki_test.go b/internals/overlord/logstate/loki/loki_test.go index bbd709ac5..a11c536ea 100644 --- a/internals/overlord/logstate/loki/loki_test.go +++ b/internals/overlord/logstate/loki/loki_test.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "encoding/json" + "encoding/pem" "fmt" "io" "net/http" @@ -28,6 +29,8 @@ import ( . "gopkg.in/check.v1" "github.com/canonical/pebble/internals/overlord/logstate/loki" + "github.com/canonical/pebble/internals/overlord/truststate" + "github.com/canonical/pebble/internals/plan" "github.com/canonical/pebble/internals/servicelog" "github.com/canonical/pebble/internals/testutil" ) @@ -294,6 +297,54 @@ func (*suite) TestLabels(c *C) { } } +func (*suite) TestTrustContext(c *C) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + caCertPEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: server.Certificate().Raw, + })) + trustMgr := truststate.NewManager(c.MkDir()) + trustMgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": { + Name: "vendorA", + TLS: &plan.TLSTrustContext{CACert: caCertPEM}, + }, + }, + }) + + newClient := func(trustContext string) *loki.Client { + return loki.NewClient(&loki.ClientOptions{ + Location: server.URL, + TrustContext: trustContext, + TrustManager: trustMgr, + }) + } + addAndFlush := func(client *loki.Client) error { + err := client.Add(servicelog.Entry{Service: "svc1", Message: "hello\n"}) + c.Assert(err, IsNil) + return client.Flush(context.Background()) + } + + // Without a trust context, the server's self-signed certificate isn't + // trusted by the system pool, so the flush fails. + err := addAndFlush(newClient("")) + c.Assert(err, ErrorMatches, ".*(certificate|x509).*") + + // With the matching trust context configured, the flush succeeds. + err = addAndFlush(newClient("vendorA")) + c.Assert(err, IsNil) + + // An unknown trust context logs an error and falls back to the default + // HTTP client behaviour, so the flush still fails. + err = addAndFlush(newClient("unknown")) + c.Assert(err, ErrorMatches, ".*(certificate|x509).*") +} + // Strips all extraneous whitespace from JSON func compactJSON(s string) []byte { var buf bytes.Buffer diff --git a/internals/overlord/logstate/manager.go b/internals/overlord/logstate/manager.go index 465828993..6bfe89464 100644 --- a/internals/overlord/logstate/manager.go +++ b/internals/overlord/logstate/manager.go @@ -18,23 +18,33 @@ import ( "sync" "github.com/canonical/pebble/internals/logger" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/plan" "github.com/canonical/pebble/internals/servicelog" ) +// TrustManager provides access to the trust context, so that log targets +// (such as loki and opentelemetry) can validate the remote server's +// certificate against the trust context configured for the log target. +type TrustManager interface { + TrustContext(name string) (*truststate.TrustContext, error) +} + type LogManager struct { mu sync.Mutex gatherers map[string]*logGatherer buffers map[string]*servicelog.RingBuffer plan *plan.Plan + trustMgr TrustManager - newGatherer func(*plan.LogTarget) (*logGatherer, error) + newGatherer func(*plan.LogTarget, TrustManager) (*logGatherer, error) } -func NewLogManager() *LogManager { +func NewLogManager(trustMgr TrustManager) *LogManager { return &LogManager{ gatherers: map[string]*logGatherer{}, buffers: map[string]*servicelog.RingBuffer{}, + trustMgr: trustMgr, newGatherer: newLogGatherer, } } @@ -54,7 +64,7 @@ func (m *LogManager) PlanChanged(pl *plan.Plan) { if gatherer == nil { // Create new gatherer var err error - gatherer, err = m.newGatherer(target) + gatherer, err = m.newGatherer(target, m.trustMgr) if err != nil { logger.Noticef("Internal error: cannot create gatherer for target %q: %v", target.Name, err) diff --git a/internals/overlord/logstate/manager_test.go b/internals/overlord/logstate/manager_test.go index f82180f1d..b0a516620 100644 --- a/internals/overlord/logstate/manager_test.go +++ b/internals/overlord/logstate/manager_test.go @@ -40,13 +40,13 @@ func (*managerSuite) SetUpSuite(c *C) { func (*managerSuite) TestPlanChange(c *C) { gathererOptions := logGathererOptions{ - newClient: func(target *plan.LogTarget) (logClient, error) { + newClient: func(target *plan.LogTarget, trustMgr TrustManager) (logClient, error) { return &testClient{}, nil }, } - m := NewLogManager() - m.newGatherer = func(t *plan.LogTarget) (*logGatherer, error) { - return newLogGathererInternal(t, &gathererOptions) + m := NewLogManager(nil) + m.newGatherer = func(t *plan.LogTarget, trustMgr TrustManager) (*logGatherer, error) { + return newLogGathererInternal(t, trustMgr, &gathererOptions) } svc1 := newTestService("svc1") @@ -132,14 +132,14 @@ func (s *managerSuite) TestTimelyShutdown(c *C) { gathererOptions := logGathererOptions{ timeoutCurrentFlush: 5 * time.Millisecond, timeoutFinalFlush: 5 * time.Millisecond, - newClient: func(_ *plan.LogTarget) (logClient, error) { + newClient: func(_ *plan.LogTarget, _ TrustManager) (logClient, error) { return client, nil }, } - m := NewLogManager() - m.newGatherer = func(t *plan.LogTarget) (*logGatherer, error) { - return newLogGathererInternal(t, &gathererOptions) + m := NewLogManager(nil) + m.newGatherer = func(t *plan.LogTarget, trustMgr TrustManager) (*logGatherer, error) { + return newLogGathererInternal(t, trustMgr, &gathererOptions) } svc1 := newTestService("svc1") @@ -219,10 +219,10 @@ func (s *managerSuite) TestLabels(c *C) { notifySetLabels: make(chan struct{}, 2), } - m := NewLogManager() - m.newGatherer = func(t *plan.LogTarget) (*logGatherer, error) { - return newLogGathererInternal(t, &logGathererOptions{ - newClient: func(_ *plan.LogTarget) (logClient, error) { return fakeClient, nil }, + m := NewLogManager(nil) + m.newGatherer = func(t *plan.LogTarget, trustMgr TrustManager) (*logGatherer, error) { + return newLogGathererInternal(t, trustMgr, &logGathererOptions{ + newClient: func(_ *plan.LogTarget, _ TrustManager) (logClient, error) { return fakeClient, nil }, }) } diff --git a/internals/overlord/logstate/opentelemetry/opentelemetry.go b/internals/overlord/logstate/opentelemetry/opentelemetry.go index f41e94fc3..0223c4048 100644 --- a/internals/overlord/logstate/opentelemetry/opentelemetry.go +++ b/internals/overlord/logstate/opentelemetry/opentelemetry.go @@ -17,6 +17,7 @@ package opentelemetry import ( "bytes" "context" + "crypto/tls" "encoding/json" "fmt" "io" @@ -27,6 +28,7 @@ import ( "time" "github.com/canonical/pebble/internals/logger" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/servicelog" ) @@ -35,6 +37,13 @@ const ( maxRequestEntries = 100 ) +// TrustManager provides access to the trust context, so that the client can +// validate the OpenTelemetry collector's certificate against the trust +// context configured for the log target. +type TrustManager interface { + TrustContext(name string) (*truststate.TrustContext, error) +} + // A collection of ScopeLogs from a Resource. // Refer to `type ResourceLogs struct` in // https://github.com/open-telemetry/opentelemetry-collector/blob/3c0fd3946f70a0b1fa97813c39dbc4d91d95afa6/pdata/internal/data/protogen/logs/v1/logs.pb.go#L223 @@ -148,6 +157,14 @@ type ClientOptions struct { ScopeName string TargetName string Location string + + // TrustContext is the name of the trust context to use to validate the + // OpenTelemetry collector's certificate (falling back to the "default" + // trust context if empty). + TrustContext string + // TrustManager provides access to the trust context named by + // TrustContext. If nil, the default HTTP client behaviour is used. + TrustManager TrustManager } func fillDefaultOptions(options *ClientOptions) { @@ -294,6 +311,13 @@ func (c *Client) sendBatch(ctx context.Context, payload payload) error { req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", c.options.UserAgent) + // Resolve the trust context configured for this log target (falling + // back to the "default" trust context if none is set), and use its CA + // bundle to validate the server's certificate, unless it resolves to + // the system CA pool, in which case the default HTTP client behaviour + // is used. + c.httpClient.Transport = c.resolveTransport() + resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("cannot send logs: %v", err) @@ -302,6 +326,37 @@ func (c *Client) sendBatch(ctx context.Context, payload payload) error { return c.handleServerResponse(resp) } +// resolveTransport resolves the trust context configured for this log +// target, and returns an *http.Transport configured to validate the +// server's certificate against it. It returns nil (meaning the default HTTP +// client behaviour should be used) if there's no trust manager, the trust +// context can't be resolved, or it resolves to the system CA pool. +func (c *Client) resolveTransport() http.RoundTripper { + if c.options.TrustManager == nil { + logger.Noticef("Log target %q (opentelemetry): cannot resolve trust context %q: no trust manager", + c.options.TargetName, c.options.TrustContext) + return nil + } + trustContext, err := c.options.TrustManager.TrustContext(c.options.TrustContext) + if err != nil { + logger.Noticef("Log target %q (opentelemetry): cannot resolve trust context %q: %v", + c.options.TargetName, c.options.TrustContext, err) + return nil + } + if trustContext.IsSystemCA() { + trustContext.Close() + return nil + } + defer trustContext.Close() + pool, err := trustContext.CAPool() + if err != nil { + logger.Noticef("Log target %q (opentelemetry): cannot get CA pool for trust context %q: %v", + c.options.TargetName, c.options.TrustContext, err) + return nil + } + return &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}} +} + // resetBuffer drops all buffered logs (in the case of a successful send, or an unrecoverable error). func (c *Client) resetBuffer() { // Zero removed elements to allow garbage collection. diff --git a/internals/overlord/logstate/opentelemetry/opentelemetry_test.go b/internals/overlord/logstate/opentelemetry/opentelemetry_test.go index f682ef9cf..8a3aa7ebd 100644 --- a/internals/overlord/logstate/opentelemetry/opentelemetry_test.go +++ b/internals/overlord/logstate/opentelemetry/opentelemetry_test.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "encoding/json" + "encoding/pem" "fmt" "io" "net/http" @@ -28,6 +29,8 @@ import ( . "gopkg.in/check.v1" "github.com/canonical/pebble/internals/overlord/logstate/opentelemetry" + "github.com/canonical/pebble/internals/overlord/truststate" + "github.com/canonical/pebble/internals/plan" "github.com/canonical/pebble/internals/servicelog" "github.com/canonical/pebble/internals/testutil" ) @@ -348,6 +351,55 @@ func (*suite) TestLabels(c *C) { } } +func (*suite) TestTrustContext(c *C) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + caCertPEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: server.Certificate().Raw, + })) + trustMgr := truststate.NewManager(c.MkDir()) + trustMgr.PlanChanged(&plan.Plan{ + TrustContexts: map[string]*plan.TrustContext{ + "vendorA": { + Name: "vendorA", + TLS: &plan.TLSTrustContext{CACert: caCertPEM}, + }, + }, + }) + + newClient := func(trustContext string) *opentelemetry.Client { + return opentelemetry.NewClient(&opentelemetry.ClientOptions{ + Location: server.URL, + ScopeName: "pebble", + TrustContext: trustContext, + TrustManager: trustMgr, + }) + } + addAndFlush := func(client *opentelemetry.Client) error { + err := client.Add(servicelog.Entry{Service: "svc1", Message: "hello\n"}) + c.Assert(err, IsNil) + return client.Flush(context.Background()) + } + + // Without a trust context, the server's self-signed certificate isn't + // trusted by the system pool, so the flush fails. + err := addAndFlush(newClient("")) + c.Assert(err, ErrorMatches, ".*(certificate|x509).*") + + // With the matching trust context configured, the flush succeeds. + err = addAndFlush(newClient("vendorA")) + c.Assert(err, IsNil) + + // An unknown trust context logs an error and falls back to the default + // HTTP client behaviour, so the flush still fails. + err = addAndFlush(newClient("unknown")) + c.Assert(err, ErrorMatches, ".*(certificate|x509).*") +} + // Strips all extraneous whitespace from JSON func compactJSON(s string) []byte { var buf bytes.Buffer diff --git a/internals/overlord/overlord.go b/internals/overlord/overlord.go index 3d6fea577..f223de035 100644 --- a/internals/overlord/overlord.go +++ b/internals/overlord/overlord.go @@ -231,7 +231,7 @@ func New(opts *Options) (*Overlord, error) { o.stateEng.AddManager(o.pairingMgr) o.planMgr.AddChangeListener(o.pairingMgr.PlanChanged) - o.logMgr = logstate.NewLogManager() + o.logMgr = logstate.NewLogManager(o.trustMgr) o.serviceMgr, err = servstate.NewManager( s, From 3ac5a785ed7ea1807c431187e53a95f6e9f5511b Mon Sep 17 00:00:00 2001 From: Harry Pidcock Date: Tue, 11 Aug 2026 12:05:41 +1000 Subject: [PATCH 7/9] feat: use service's trust context in pebble exec --- internals/daemon/api_exec.go | 65 +++++++++++++++++---- internals/daemon/api_exec_test.go | 81 ++++++++++++++++++++++++++ internals/overlord/cmdstate/manager.go | 13 ++++- internals/overlord/cmdstate/request.go | 60 ++++++++++++------- 4 files changed, 184 insertions(+), 35 deletions(-) diff --git a/internals/daemon/api_exec.go b/internals/daemon/api_exec.go index 3cf00e6ad..9e34fbfdb 100644 --- a/internals/daemon/api_exec.go +++ b/internals/daemon/api_exec.go @@ -24,6 +24,7 @@ import ( "github.com/canonical/pebble/internals/osutil" "github.com/canonical/pebble/internals/overlord/cmdstate" "github.com/canonical/pebble/internals/overlord/state" + "github.com/canonical/pebble/internals/overlord/truststate" "github.com/canonical/pebble/internals/plan" ) @@ -79,9 +80,17 @@ func v1PostExec(c *Command, req *http.Request, user *UserState) Response { return BadRequest("%v", err) } + trustContext, err := resolveExecTrustContext(c, p, payload.ServiceContext) + if err != nil { + return BadRequest("%v", err) + } + // Convert User/UserID and Group/GroupID combinations into raw uid/gid. uid, gid, err := osutil.NormalizeUidGid(merged.UserID, merged.GroupID, merged.User, merged.Group) if err != nil { + if trustContext != nil { + trustContext.Close() + } return BadRequest("%v", err) } @@ -90,20 +99,24 @@ func v1PostExec(c *Command, req *http.Request, user *UserState) Response { defer st.Unlock() args := &cmdstate.ExecArgs{ - Command: payload.Command, - Environment: merged.Environment, - WorkingDir: merged.WorkingDir, - Timeout: timeout, - UserID: uid, - GroupID: gid, - Terminal: payload.Terminal, - Interactive: payload.Interactive, - SplitStderr: payload.SplitStderr, - Width: payload.Width, - Height: payload.Height, + Command: payload.Command, + Environment: merged.Environment, + WorkingDir: merged.WorkingDir, + Timeout: timeout, + UserID: uid, + GroupID: gid, + Terminal: payload.Terminal, + Interactive: payload.Interactive, + SplitStderr: payload.SplitStderr, + Width: payload.Width, + Height: payload.Height, + TrustContext: trustContext, } task, metadata, err := cmdstate.Exec(st, args) if err != nil { + if trustContext != nil { + trustContext.Close() + } return ServerError("cannot call exec: %v", err) } @@ -122,3 +135,33 @@ func v1PostExec(c *Command, req *http.Request, user *UserState) Response { } return AsyncResponse(result, change.ID()) } + +// resolveExecTrustContext resolves the trust context configured for the +// service/workload. If a trust context is returned, it must be closed by the +// caller. +func resolveExecTrustContext(c *Command, p *plan.Plan, serviceContextName string) (*truststate.TrustContext, error) { + if serviceContextName == "" { + return nil, nil + } + service, ok := p.Services[serviceContextName] + if !ok { + return nil, nil + } + + trustMgr := c.d.overlord.TrustManager() + if trustMgr == nil { + return nil, fmt.Errorf( + "cannot resolve trust context %q for service context %q: no trust manager", + service.TrustContext, serviceContextName, + ) + } + trustContext, err := trustMgr.TrustContext(service.TrustContext) + if err != nil { + return nil, fmt.Errorf( + "cannot resolve trust context %q for service context %q: %v", + service.TrustContext, serviceContextName, err, + ) + } + + return trustContext, nil +} diff --git a/internals/daemon/api_exec_test.go b/internals/daemon/api_exec_test.go index f63850909..a612e7116 100644 --- a/internals/daemon/api_exec_test.go +++ b/internals/daemon/api_exec_test.go @@ -253,6 +253,87 @@ func (s *execSuite) TestContextOverrides(c *C) { c.Check(stderr, Equals, "") } +// testCACertPEM is a self-signed CA certificate used to exercise trust +// context resolution in tests. +const testCACertPEM = `-----BEGIN CERTIFICATE----- +MIIDEzCCAfugAwIBAgIUIvvZuKuTEAhQ3k+mSVtSdYkODUYwDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOcGViYmxlLXRlc3QtY2EwHhcNMjYwODEwMDUyNTM2WhcN +MzYwODA3MDUyNTM2WjAZMRcwFQYDVQQDDA5wZWJibGUtdGVzdC1jYTCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAPHveEb1T/2cYyhJElZM1qeMoDs4DthU +no3Y07E8aDOvSR6OIF4xG27eJeQZBYqClmNxpgvUmzdycbQia5InZxlnikyAXsjL +0hgPDNzLkxNZZtKTeQdOjLaUuBWN8lLXnz+5Mq5584fbbd5nOtVPmH3hhcbL07LW +rABj9/9qrxKbAGeZfQBYpwRtwiZR5KUaQ3Ed+uuA4eLV5PxAmYos3xI2ibLbwG98 +mG6IFbk0x1FoJ5T4nyouNwrCfaX8NNaa8KX+SiVBRRj+tzJiklKLHTe5kpxsX6cH +ky/YTIC2Gb6RyWQrkPXe0uOX4NamNHIF+Kl3wYKl2AoAtDdM9mjvd9MCAwEAAaNT +MFEwHQYDVR0OBBYEFIa+M4EWaY5tLSHawLqId6sYqHutMB8GA1UdIwQYMBaAFIa+ +M4EWaY5tLSHawLqId6sYqHutMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL +BQADggEBAHq4o4YxsqcfJlUS9XgkTynt6VUgUiDDgd2fFU2NsjKCUyGQFm3wQ177 +dGm0XbBUtzxnHGELKCmmU9Yve8SOy3ez4yC2dSSdi4OO1eMydjMeipfu2oWOKhn2 +n4w4B7LrGGqKGWrCqXCw3cfXNit0AzyS6Qe+EtLFrCF91UeOcJAwBzmrJGSIZfck +P+z0FL8MP8rx2X8eHYldHgb1AIa1r47qJ8oF/Jd7vmyddit0ZMIu8udxQ0hYaOe/ +eR5T5RBSrkwDisFdU2q8XOkzbXvQtYDtTRifUVbnBAgppl9H5PHaJP+WHnJCLjBX +yqWyrDYJ7zt3gver4qn7zSZ4TTVl6/4= +-----END CERTIFICATE-----` + +// TestContextTrustContextSSLCertFile checks that, when a command references a +// service context whose service declares a trust context, the resolved CA +// bundle is exposed to the command via the SSL_CERT_FILE env var. +func (s *execSuite) TestContextTrustContextSSLCertFile(c *C) { + err := s.daemon.overlord.PlanManager().AppendLayer(&plan.Layer{ + Label: "layer1", + TrustContexts: map[string]*plan.TrustContext{"vendorA": { + Name: "vendorA", + Override: "replace", + TLS: &plan.TLSTrustContext{CACert: testCACertPEM}, + }}, + Services: map[string]*plan.Service{"svc1": { + Name: "svc1", + Override: "replace", + Command: "dummy", + TrustContext: "vendorA", + }}, + }, false) + c.Assert(err, IsNil) + + stdout, stderr, waitErr := s.exec(c, "", &client.ExecOptions{ + Command: []string{"/bin/sh", "-c", "cat $SSL_CERT_FILE"}, + ServiceContext: "svc1", + }) + c.Assert(waitErr, IsNil) + c.Check(stderr, Equals, "") + c.Check(strings.Contains(stdout, strings.TrimSpace(testCACertPEM)), Equals, true) +} + +// TestContextTrustContextSSLCertFileNotOverridden checks that SSL_CERT_FILE +// is left untouched when it is already set via the exec payload environment, +// even though the referenced service declares a trust context. +func (s *execSuite) TestContextTrustContextSSLCertFileNotOverridden(c *C) { + err := s.daemon.overlord.PlanManager().AppendLayer(&plan.Layer{ + Label: "layer1", + TrustContexts: map[string]*plan.TrustContext{"vendorA": { + Name: "vendorA", + Override: "replace", + TLS: &plan.TLSTrustContext{CACert: testCACertPEM}, + }}, + Services: map[string]*plan.Service{"svc1": { + Name: "svc1", + Override: "replace", + Command: "dummy", + TrustContext: "vendorA", + }}, + }, false) + c.Assert(err, IsNil) + + stdout, stderr, waitErr := s.exec(c, "", &client.ExecOptions{ + Command: []string{"/bin/sh", "-c", "echo -n $SSL_CERT_FILE"}, + ServiceContext: "svc1", + Environment: map[string]string{"SSL_CERT_FILE": "/custom/path/ca.pem"}, + }) + c.Assert(waitErr, IsNil) + c.Check(stderr, Equals, "") + c.Check(stdout, Equals, "/custom/path/ca.pem") +} + func (s *execSuite) TestCurrentUserGroup(c *C) { current, err := user.Current() c.Assert(err, IsNil) diff --git a/internals/overlord/cmdstate/manager.go b/internals/overlord/cmdstate/manager.go index 6b08d11ef..352a7a1b2 100644 --- a/internals/overlord/cmdstate/manager.go +++ b/internals/overlord/cmdstate/manager.go @@ -36,12 +36,21 @@ func NewManager(runner *state.TaskRunner) *CommandManager { } runner.AddHandler("exec", manager.doExec, nil) - // Delete the in-memory execSetup object when the exec is done. + // Delete the in-memory execSetup object when the exec is done, releasing + // its trust context if set. runner.AddCleanup("exec", func(task *state.Task, tomb *tomb.Tomb) error { st := task.State() + st.Lock() - defer st.Unlock() + v := st.Cached(execSetupKey{task.ID()}) st.Cache(execSetupKey{task.ID()}, nil) + st.Unlock() + + execSetup, _ := v.(*execSetup) + if execSetup != nil && execSetup.TrustContext != nil { + execSetup.TrustContext.Close() + } + return nil }) diff --git a/internals/overlord/cmdstate/request.go b/internals/overlord/cmdstate/request.go index a8ebb3ddc..fb5e47044 100644 --- a/internals/overlord/cmdstate/request.go +++ b/internals/overlord/cmdstate/request.go @@ -27,6 +27,7 @@ import ( "github.com/canonical/pebble/internals/logger" "github.com/canonical/pebble/internals/osutil" "github.com/canonical/pebble/internals/overlord/state" + "github.com/canonical/pebble/internals/overlord/truststate" ) // ExecArgs holds the arguments for a command execution. @@ -42,6 +43,8 @@ type ExecArgs struct { SplitStderr bool Width int Height int + // TrustContext is optionally set to the trust context to be used. + TrustContext *truststate.TrustContext } // ExecMetadata is the metadata returned from an Exec call. @@ -53,17 +56,18 @@ type ExecMetadata struct { // execSetup is stored on a task to specify the args for an execution. type execSetup struct { - Command []string - Environment map[string]string - Timeout time.Duration - Terminal bool - Interactive bool - SplitStderr bool - Width int - Height int - UserID *int - GroupID *int - WorkingDir string + Command []string + Environment map[string]string + Timeout time.Duration + Terminal bool + Interactive bool + SplitStderr bool + Width int + Height int + UserID *int + GroupID *int + WorkingDir string + TrustContext *truststate.TrustContext } // Exec creates a task that will execute the command with the given arguments. @@ -117,6 +121,17 @@ func Exec(st *state.State, args *ExecArgs) (*state.Task, ExecMetadata, error) { environment["LANG"] = "C.UTF-8" } + // Set SSL_CERT_FILE if not already set. + if args.TrustContext != nil && + !args.TrustContext.IsSystemCA() && + args.Environment["SSL_CERT_FILE"] == "" { + caBundleFile, err := args.TrustContext.CABundleFile() + if err != nil { + return nil, ExecMetadata{}, err + } + environment["SSL_CERT_FILE"] = caBundleFile + } + workingDir, err := getWorkingDir(args.WorkingDir, environment["HOME"]) if err != nil { return nil, ExecMetadata{}, err @@ -125,17 +140,18 @@ func Exec(st *state.State, args *ExecArgs) (*state.Task, ExecMetadata, error) { // Create a task for this execution (though it's not started here). task := st.NewTask("exec", fmt.Sprintf("Execute command %q", args.Command[0])) setup := execSetup{ - Command: args.Command, - Environment: environment, - Timeout: args.Timeout, - Terminal: args.Terminal, - Interactive: args.Interactive, - SplitStderr: args.SplitStderr, - Width: args.Width, - Height: args.Height, - UserID: args.UserID, - GroupID: args.GroupID, - WorkingDir: workingDir, + Command: args.Command, + Environment: environment, + Timeout: args.Timeout, + Terminal: args.Terminal, + Interactive: args.Interactive, + SplitStderr: args.SplitStderr, + Width: args.Width, + Height: args.Height, + UserID: args.UserID, + GroupID: args.GroupID, + WorkingDir: workingDir, + TrustContext: args.TrustContext, } st.Cache(execSetupKey{task.ID()}, &setup) From d6a5edf0411d10f366857d3dd1127593a9472eb4 Mon Sep 17 00:00:00 2001 From: Harry Pidcock Date: Wed, 12 Aug 2026 18:25:54 +1000 Subject: [PATCH 8/9] docs: trust contexts --- docs/how-to/index.md | 12 + .../use-tls-ca-certs-with-trust-context.md | 231 ++++++++++++++++++ docs/reference/layer-specification.md | 77 ++++++ 3 files changed, 320 insertions(+) create mode 100644 docs/how-to/use-tls-ca-certs-with-trust-context.md diff --git a/docs/how-to/index.md b/docs/how-to/index.md index c680e8cfa..ddf4a60ea 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -66,6 +66,18 @@ Manage identities ``` +## Trust contexts + +Use named "trust contexts" to make services, checks, and log targets trust custom TLS CA certificates. + +```{toctree} +:titlesonly: +:maxdepth: 1 + +Use TLS CA certificates with trust contexts +``` + + ## API To integrate Pebble with your automated workflows, you can use the Pebble API. diff --git a/docs/how-to/use-tls-ca-certs-with-trust-context.md b/docs/how-to/use-tls-ca-certs-with-trust-context.md new file mode 100644 index 000000000..6a084cee4 --- /dev/null +++ b/docs/how-to/use-tls-ca-certs-with-trust-context.md @@ -0,0 +1,231 @@ +# How to use TLS CA certificates with trust contexts + +Services, checks, and log targets often need to talk to a server over TLS. +For example, a service calling an internal HTTPS API, an `http` check probing an +HTTPS endpoint, or a `loki`/`opentelemetry` log target pushing logs to a +collector. If that server's certificate is signed by a CA that isn't in the +host's system CA pool (e.g. a private/internal CA), the TLS handshake will fail +unless Pebble is told to trust that CA. + +Pebble solves this with **trust contexts**: named collections of trusted CA +certificates, configured once in the plan and then referenced by name wherever +they're needed. + +This guide shows how to declare a trust context with a custom CA certificate, +and how to use it from a service, a check, a log target, and `pebble exec`. + +## Trust context basics + +Trust contexts are declared in the plan's top-level `trust-contexts` section: + +```yaml +trust-contexts: + vendorA: + override: merge + tls: + ca-cert: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- +``` + +Two trust contexts are always available, even if you don't declare anything: + +- `system`: an immutable trust context backed by the host's default CA + certificate pool. This name is reserved; you cannot declare your own trust + context called `system`. +- `default`: the trust context used automatically by any service, check, or + log target that doesn't set `trust-context` explicitly. The "default" trust + context simply includes `system`, but you can alter it in your plan (see + [Change what "default" trusts](#change-what-default-trusts) below). + +For the full specification, see [Layer specification](../reference/layer-specification). + +## Add a custom CA certificate + +Suppose you run an internal HTTPS service (`https://api.internal.example.com`) +whose certificate is signed by an internal CA, `vendorA`. Add a trust context +with that CA certificate: + +```yaml +# ca-cert-layer.yaml +trust-contexts: + vendorA: + override: merge + tls: + ca-cert: | + -----BEGIN CERTIFICATE----- + MIIDXTCCAkWgAwIBAgIJAJC1H... + -----END CERTIFICATE----- +``` + +`ca-cert` accepts one or more PEM-encoded certificates concatenated together, +so you can include an entire chain (intermediate plus root) if needed. + +Add the layer: + +```{terminal} +pebble add ca-certs ca-cert-layer.yaml + +Layer "ca-certs" added successfully from "ca-cert-layer.yaml" +``` + +At this point, `vendorA` is declared but not used by anything yet. + +## Use a trust context in a service + +To make a service trust `vendorA`'s CA bundle, set `trust-context` on the +service: + +```yaml +services: + myservice: + override: merge + command: /usr/bin/myservice + trust-context: vendorA +``` + +When `myservice` starts, the `vendorA` trust context is resolved and sets the +`SSL_CERT_FILE` environment variable to point at a PEM bundle containing +`vendorA`'s CA certificate(s). Most TLS libraries (including Go's `crypto/x509` +and OpenSSL-based stacks) honor `SSL_CERT_FILE` automatically, so `myservice` +should trust `https://api.internal.example.com` without any code changes. + +If `myservice` sets its own `SSL_CERT_FILE` in `environment`, that value is +not modified. + +## Use a trust context in a check + +### HTTP check + +For an `http` check that probes an HTTPS URL signed by `vendorA`, set +`trust-context` on the check's `http` section: + +```yaml +checks: + api-up: + override: replace + http: + url: https://api.internal.example.com/health + trust-context: vendorA +``` + +The resolved CA pool is used to validate the server's certificate when +performing the check. + +### Exec check + +For an `exec` check that runs a command needing to make its own TLS connections, +set `trust-context` on the check's `exec` section, the same way as for services: + +```yaml +checks: + api-check: + override: replace + exec: + command: /usr/bin/check-api.sh + trust-context: vendorA +``` + +As with services, this sets `SSL_CERT_FILE` environment variable (unless it's +already set). + +## Use a trust context for a log target + +If your Loki or OpenTelemetry collector uses a certificate signed by `vendorA`, +set `trust-context` on the log target: + +```yaml +log-targets: + loki-internal: + override: merge + type: loki + location: https://loki.internal.example.com:3100/loki/api/v1/push + services: [all] + trust-context: vendorA +``` + +`trust-context` is only supported for `loki` and `opentelemetry` log targets. + +## Use a trust context with `pebble exec` + +`pebble exec --context ` inherits environment variables, +user/group, and working directory from the named service. If that service has a +`trust-context` configured, the trust context is inherited too: + +```{terminal} +pebble exec --context myservice curl https://api.internal.example.com/health + +{"status": "ok"} +``` + +Without `--context`, `pebble exec` uses the `default` trust context (typically +just the system CA pool), so an unrelated internal CA won't be trusted unless +you add it to `default` (see below). + +(change-what-default-trusts)= +## Change what "default" trusts + +If most things in your plan need to trust `vendorA`, it may be simpler to add +it to the `default` trust context instead of setting `trust-context` everywhere. +The `default` trust context can only use `include` (i.e. it can't declare its +own `tls.ca-cert` directly): + +```yaml +trust-contexts: + default: + override: merge + include: [system, vendorA] +``` + +With this in place, any service, check, log target or `pebble exec` that doesn't +set `trust-context` explicitly will trust `vendorA`'s CA in addition to the +system CA pool. + +## Combine multiple CAs + +A trust context can include other trust contexts, so you can build up a combined +set of trusted CAs. For example, to trust both `vendorA` and `vendorB`: + +```yaml +trust-contexts: + vendorA: + override: merge + tls: + ca-cert: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + vendorB: + override: merge + tls: + ca-cert: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + combined: + override: merge + include: [vendorA, vendorB] + +services: + myservice: + override: merge + command: /usr/bin/myservice + trust-context: combined +``` + +Use the built-in name `system` in an `include` list to add the host's system CA +pool alongside your custom CAs: + +```yaml +trust-contexts: + combined: + override: merge + include: [system, vendorA, vendorB] +``` + +## See more + +- [Layer specification](../reference/layer-specification) +- [Health checks](../reference/health-checks) +- [Log forwarding](../reference/log-forwarding) diff --git a/docs/reference/layer-specification.md b/docs/reference/layer-specification.md index f0cec4bdf..8a7df79ed 100644 --- a/docs/reference/layer-specification.md +++ b/docs/reference/layer-specification.md @@ -30,6 +30,15 @@ services: # Example: /usr/bin/somedaemon --db=/db/path [ --port 8080 ] command: + # (Optional) The name of the trust context used to provide a CA + # certificate bundle to the service process. If a trust context is + # configured (and it doesn't resolve to the system CA pool), its CA + # bundle is exposed to the process via the SSL_CERT_FILE environment + # variable, unless SSL_CERT_FILE is already set explicitly. If not + # specified, the "default" trust context is used. See the + # "trust-contexts" section below for details. + trust-context: + # (Optional) A short summary of the service. summary: @@ -180,6 +189,12 @@ checks: headers: : + # (Optional) The name of the trust context used to validate the + # TLS certificate presented by the server (relevant only for + # "https" URLs). If not specified, the "default" trust context + # is used. See the "trust-contexts" section below for details. + trust-context: + # Configures a TCP port check, which is successful if the specified # TCP port is listening and we can successfully open it. Nothing is # sent to the port. @@ -235,6 +250,16 @@ checks: # command is run in the service manager's current directory. working-dir: + # (Optional) The name of the trust context used to provide a CA + # certificate bundle to the command. If a trust context is + # configured (and it doesn't resolve to the system CA pool), its + # CA bundle is exposed to the command via the SSL_CERT_FILE + # environment variable, unless SSL_CERT_FILE is already set + # explicitly (or inherited via service-context). If not + # specified, the "default" trust context is used. See the + # "trust-contexts" section below for details. + trust-context: + # (Optional) A list of remote log receivers, to which service logs can be sent. log-targets: @@ -285,6 +310,58 @@ log-targets: labels: