Skip to content

Commit 54a6859

Browse files
committed
feat(tlsbridge): config block + main wiring + trust self-check
Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent fe93eec commit 54a6859

4 files changed

Lines changed: 129 additions & 1 deletion

File tree

authbridge/authlib/config/config.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,33 @@ type Config struct {
3737
// = today's spiffe-helper-driven behavior (until the chart/operator
3838
// follow-ups land and start populating the block).
3939
SPIFFE *SPIFFEConfig `yaml:"spiffe,omitempty" json:"spiffe,omitempty"`
40+
// TLSBridge, when non-nil and Enabled, terminates agent outbound TLS so the
41+
// outbound pipeline sees decrypted HTTPS. See docs/.../tlsbridge-design.md.
42+
TLSBridge *TLSBridgeConfig `yaml:"tls_bridge,omitempty" json:"tls_bridge,omitempty"`
43+
}
44+
45+
// TLSBridgeConfig configures the outbound TLS bridge (MITM termination of
46+
// agent egress so the outbound plugin pipeline sees decrypted HTTPS).
47+
type TLSBridgeConfig struct {
48+
Enabled bool `yaml:"enabled" json:"enabled"`
49+
Scope string `yaml:"scope" json:"scope"` // external | all
50+
InternalCIDRs []string `yaml:"internal_cidrs" json:"internal_cidrs"`
51+
CASource string `yaml:"ca_source" json:"ca_source"` // file | ephemeral
52+
CACertPath string `yaml:"ca_cert_path" json:"ca_cert_path"`
53+
CAKeyPath string `yaml:"ca_key_path" json:"ca_key_path"`
54+
UpstreamCABundle string `yaml:"upstream_ca_bundle" json:"upstream_ca_bundle"`
55+
SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"`
56+
}
57+
58+
// Validate is called from the loader when TLSBridge != nil.
59+
func (b *TLSBridgeConfig) Validate() error {
60+
if b.Scope != "" && b.Scope != "external" && b.Scope != "all" {
61+
return fmt.Errorf("tls_bridge.scope must be 'external' or 'all', got %q", b.Scope)
62+
}
63+
if b.CASource == "file" && (b.CACertPath == "" || b.CAKeyPath == "") {
64+
return fmt.Errorf("tls_bridge.ca_source=file requires ca_cert_path and ca_key_path")
65+
}
66+
return nil
4067
}
4168

4269
// MTLSMode names the inbound + outbound TLS posture. Vocabulary
@@ -461,5 +488,11 @@ func Load(path string) (*Config, error) {
461488
return nil, err
462489
}
463490

491+
if cfg.TLSBridge != nil {
492+
if err := cfg.TLSBridge.Validate(); err != nil {
493+
return nil, err
494+
}
495+
}
496+
464497
return &cfg, nil
465498
}

authbridge/authlib/config/config_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,29 @@ spiffe: {}
727727
}
728728
}
729729

730+
// --- TLS bridge config ---
731+
732+
// The tls_bridge block decodes into TLSBridgeConfig and Load surfaces it.
733+
func TestConfig_TLSBridgeBlockDecodes(t *testing.T) {
734+
y := []byte("mode: proxy-sidecar\n" +
735+
"tls_bridge:\n" +
736+
" enabled: true\n" +
737+
" scope: external\n" +
738+
" ca_source: ephemeral\n" +
739+
" skip_hosts: [\"pinned.example.com\"]\n")
740+
p := filepath.Join(t.TempDir(), "cfg.yaml")
741+
if err := os.WriteFile(p, y, 0o600); err != nil {
742+
t.Fatal(err)
743+
}
744+
cfg, err := Load(p)
745+
if err != nil {
746+
t.Fatalf("load: %v", err)
747+
}
748+
if cfg.TLSBridge == nil || !cfg.TLSBridge.Enabled || cfg.TLSBridge.Scope != "external" {
749+
t.Fatalf("tls_bridge block did not decode: %+v", cfg.TLSBridge)
750+
}
751+
}
752+
730753
// Absent mtls block leaves cfg.MTLS nil — today's behavior, no TLS.
731754
func TestLoad_MTLS_AbsentBlock(t *testing.T) {
732755
dir := t.TempDir()

authbridge/authlib/tlsbridge/engine.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package tlsbridge
22

3-
import "net/http"
3+
import (
4+
"log/slog"
5+
"net/http"
6+
"os"
7+
"strings"
8+
)
49

510
// Engine bundles everything the forward proxy needs to bridge TLS.
611
// A nil *Engine means the bridge is disabled.
@@ -11,3 +16,25 @@ type Engine struct {
1116
Upstream *http.Client
1217
CAPEM []byte
1318
}
19+
20+
// RunTrustSelfCheck logs a loud WARN when the bridge CA is not present in the
21+
// trust file the agent runtime is told to use (SSL_CERT_FILE / NODE_EXTRA_CA_CERTS
22+
// / REQUESTS_CA_BUNDLE). A trust-miss is then a visible signal, not an opaque
23+
// in-agent handshake error. Best-effort. In Phase 1 (test-only) no agent trust
24+
// env is set, so this simply notes that egress will safely tunnel.
25+
func RunTrustSelfCheck(caPEM []byte) {
26+
want := strings.TrimSpace(string(caPEM))
27+
for _, env := range []string{"SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE"} {
28+
p := os.Getenv(env)
29+
if p == "" {
30+
continue
31+
}
32+
if b, err := os.ReadFile(p); err == nil && strings.Contains(string(b), want) {
33+
slog.Info("tls-bridge trust self-check OK", "env", env, "path", p)
34+
return
35+
}
36+
}
37+
slog.Warn("tls-bridge trust self-check: CA not found in any agent trust file " +
38+
"(SSL_CERT_FILE/NODE_EXTRA_CA_CERTS/REQUESTS_CA_BUNDLE); agent will not trust " +
39+
"minted leaves and egress will safely tunnel (expected in Phase 1 / test-only)")
40+
}

authbridge/cmd/authbridge-proxy/main.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"github.com/kagenti/kagenti-extensions/authbridge/authlib/shared"
3535
"github.com/kagenti/kagenti-extensions/authbridge/authlib/spiffe"
3636
authtls "github.com/kagenti/kagenti-extensions/authbridge/authlib/tls"
37+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/tlsbridge"
3738

3839
// Only HTTP listeners are compiled in: no extproc/extauthz
3940
// (no gRPC, no envoy types).
@@ -247,6 +248,49 @@ func main() {
247248
slog.Info("mTLS disabled (no mtls block in config)")
248249
}
249250

251+
// TLS bridge: when enabled, the forward proxy terminates agent outbound
252+
// TLS (MITM) so the outbound pipeline sees decrypted HTTPS. Constructed
253+
// here and set on fpSrv below (mirroring fpSrv.SkipHosts / fpSrv.Shared).
254+
// A nil *Engine leaves today's blind-tunnel behavior intact.
255+
var bridge *tlsbridge.Engine
256+
if cfg.TLSBridge != nil && cfg.TLSBridge.Enabled {
257+
var src tlsbridge.CASource
258+
if cfg.TLSBridge.CASource == "file" {
259+
src, err = tlsbridge.NewFileSource(cfg.TLSBridge.CACertPath, cfg.TLSBridge.CAKeyPath)
260+
} else {
261+
src, err = tlsbridge.NewEphemeralSource()
262+
}
263+
if err != nil {
264+
log.Fatalf("tls-bridge CA init failed: %v", err)
265+
}
266+
var extra []byte
267+
if cfg.TLSBridge.UpstreamCABundle != "" {
268+
if extra, err = os.ReadFile(cfg.TLSBridge.UpstreamCABundle); err != nil {
269+
log.Fatalf("tls-bridge upstream_ca_bundle read failed: %v", err)
270+
}
271+
}
272+
up, uerr := tlsbridge.NewUpstreamClient(extra)
273+
if uerr != nil {
274+
log.Fatalf("tls-bridge upstream client failed: %v", uerr)
275+
}
276+
minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{})
277+
scope := tlsbridge.ScopeExternal
278+
if cfg.TLSBridge.Scope == "all" {
279+
scope = tlsbridge.ScopeAll
280+
}
281+
bridge = &tlsbridge.Engine{
282+
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
283+
Scope: scope, InternalCIDRs: cfg.TLSBridge.InternalCIDRs, SkipHosts: cfg.TLSBridge.SkipHosts,
284+
}),
285+
Term: tlsbridge.NewTerminator(minter),
286+
Skip: tlsbridge.NewSkipSet(),
287+
Upstream: up,
288+
CAPEM: src.CACertPEM(),
289+
}
290+
tlsbridge.RunTrustSelfCheck(bridge.CAPEM)
291+
slog.Info("tls-bridge enabled", "scope", cfg.TLSBridge.Scope, "ca_source", cfg.TLSBridge.CASource)
292+
}
293+
250294
// Proxy-sidecar: reverse proxy on the inbound path + forward proxy
251295
// on the outbound path.
252296
rpSrv, err := reverseproxy.NewServer(inboundH, sessions, cfg.Listener.ReverseProxyBackend, rpMTLS)
@@ -266,6 +310,7 @@ func main() {
266310
log.Fatalf("listener.skip_hosts: %v", err)
267311
}
268312
fpSrv.SkipHosts = skipHosts
313+
fpSrv.TLSBridge = bridge
269314
sharedStore := shared.New()
270315
defer sharedStore.Close() // stop the TTL janitor on normal main return
271316
rpSrv.Shared = sharedStore

0 commit comments

Comments
 (0)