diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server.go b/authbridge/authlib/listener/forwardproxy/server.go similarity index 100% rename from authbridge/cmd/authbridge/listener/forwardproxy/server.go rename to authbridge/authlib/listener/forwardproxy/server.go diff --git a/authbridge/cmd/authbridge/listener/forwardproxy/server_test.go b/authbridge/authlib/listener/forwardproxy/server_test.go similarity index 100% rename from authbridge/cmd/authbridge/listener/forwardproxy/server_test.go rename to authbridge/authlib/listener/forwardproxy/server_test.go diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/finisher_test.go b/authbridge/authlib/listener/reverseproxy/finisher_test.go similarity index 100% rename from authbridge/cmd/authbridge/listener/reverseproxy/finisher_test.go rename to authbridge/authlib/listener/reverseproxy/finisher_test.go diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server.go b/authbridge/authlib/listener/reverseproxy/server.go similarity index 100% rename from authbridge/cmd/authbridge/listener/reverseproxy/server.go rename to authbridge/authlib/listener/reverseproxy/server.go diff --git a/authbridge/cmd/authbridge/listener/reverseproxy/server_test.go b/authbridge/authlib/listener/reverseproxy/server_test.go similarity index 100% rename from authbridge/cmd/authbridge/listener/reverseproxy/server_test.go rename to authbridge/authlib/listener/reverseproxy/server_test.go diff --git a/authbridge/authlib/plugins/a2aparser.go b/authbridge/authlib/plugins/a2aparser/plugin.go similarity index 94% rename from authbridge/authlib/plugins/a2aparser.go rename to authbridge/authlib/plugins/a2aparser/plugin.go index a7240b96b..909e713d8 100644 --- a/authbridge/authlib/plugins/a2aparser.go +++ b/authbridge/authlib/plugins/a2aparser/plugin.go @@ -1,4 +1,4 @@ -package plugins +package a2aparser import ( "bytes" @@ -7,6 +7,8 @@ import ( "log/slog" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/internal/parsercommon" ) // A2AParser parses A2A JSON-RPC 2.0 request bodies and populates @@ -17,7 +19,7 @@ type A2AParser struct{} func NewA2AParser() *A2AParser { return &A2AParser{} } func init() { - RegisterPlugin("a2a-parser", func() pipeline.Plugin { return NewA2AParser() }) + plugins.RegisterPlugin("a2a-parser", func() pipeline.Plugin { return NewA2AParser() }) } func (p *A2AParser) Name() string { return "a2a-parser" } @@ -40,7 +42,7 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin return pipeline.Action{Type: pipeline.Continue} } - var rpc jsonRPCRequest + var rpc parsercommon.JSONRPCRequest if err := json.Unmarshal(pctx.Body, &rpc); err != nil { slog.Debug("a2a-parser: invalid JSON-RPC", "error", err, "bodyLen", len(pctx.Body)) return pipeline.Action{Type: pipeline.Continue} @@ -54,12 +56,12 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin // Extract message fields generically — any method with params.message // gets full extraction (forward-compatible with future A2A methods). // A2A spec uses "contextId" (current) or "sessionId" (older drafts). - ext.SessionID = rpc.stringParam("contextId") + ext.SessionID = rpc.StringParam("contextId") if ext.SessionID == "" { - ext.SessionID = rpc.stringParam("sessionId") + ext.SessionID = rpc.StringParam("sessionId") } - ext.TaskID = rpc.stringParam("taskId") - if msg := rpc.mapParam("message"); msg != nil { + ext.TaskID = rpc.StringParam("taskId") + if msg := rpc.MapParam("message"); msg != nil { if role, ok := msg["role"].(string); ok { ext.Role = role } @@ -82,7 +84,7 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin "parts", len(ext.Parts), ) for i, part := range ext.Parts { - slog.Debug("a2a-parser: part", "index", i, "kind", part.Kind, "content", truncate(part.Content, debugBodyMax)) + slog.Debug("a2a-parser: part", "index", i, "kind", part.Kind, "content", parsercommon.Truncate(part.Content, parsercommon.DebugBodyMax)) } pctx.Observe("matched_" + rpc.Method) return pipeline.Action{Type: pipeline.Continue} diff --git a/authbridge/authlib/plugins/a2aparser_test.go b/authbridge/authlib/plugins/a2aparser/plugin_test.go similarity index 99% rename from authbridge/authlib/plugins/a2aparser_test.go rename to authbridge/authlib/plugins/a2aparser/plugin_test.go index 245c69123..63dd1e8c9 100644 --- a/authbridge/authlib/plugins/a2aparser_test.go +++ b/authbridge/authlib/plugins/a2aparser/plugin_test.go @@ -1,4 +1,4 @@ -package plugins +package a2aparser import ( "context" diff --git a/authbridge/authlib/plugins/inferenceparser.go b/authbridge/authlib/plugins/inferenceparser/plugin.go similarity index 94% rename from authbridge/authlib/plugins/inferenceparser.go rename to authbridge/authlib/plugins/inferenceparser/plugin.go index 9271cadfb..733b5edd0 100644 --- a/authbridge/authlib/plugins/inferenceparser.go +++ b/authbridge/authlib/plugins/inferenceparser/plugin.go @@ -1,4 +1,4 @@ -package plugins +package inferenceparser import ( "bytes" @@ -8,6 +8,8 @@ import ( "strings" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/internal/parsercommon" ) // InferenceParser parses outbound OpenAI-compatible LLM inference requests @@ -17,7 +19,7 @@ type InferenceParser struct{} func NewInferenceParser() *InferenceParser { return &InferenceParser{} } func init() { - RegisterPlugin("inference-parser", func() pipeline.Plugin { return NewInferenceParser() }) + plugins.RegisterPlugin("inference-parser", func() pipeline.Plugin { return NewInferenceParser() }) } func (p *InferenceParser) Name() string { return "inference-parser" } @@ -82,7 +84,7 @@ func (p *InferenceParser) OnRequest(_ context.Context, pctx *pipeline.Context) p slog.Info("inference-parser", "model", ext.Model) slog.Debug("inference-parser: extracted", "model", ext.Model, "messages", len(ext.Messages), "stream", ext.Stream, "tools", len(ext.Tools)) for i, m := range ext.Messages { - slog.Debug("inference-parser: message", "index", i, "role", m.Role, "content", truncate(m.Content, debugBodyMax)) + slog.Debug("inference-parser: message", "index", i, "role", m.Role, "content", parsercommon.Truncate(m.Content, parsercommon.DebugBodyMax)) } pctx.Observe("matched_" + ext.Model) @@ -112,7 +114,7 @@ func (p *InferenceParser) OnResponse(_ context.Context, pctx *pipeline.Context) "promptTokens", ext.PromptTokens, "completionTokens", ext.CompletionTokens, ) - slog.Debug("inference-parser: completion", "text", truncate(ext.Completion, debugBodyMax)) + slog.Debug("inference-parser: completion", "text", parsercommon.Truncate(ext.Completion, parsercommon.DebugBodyMax)) pctx.Observe("matched_" + ext.Model + "_response") return pipeline.Action{Type: pipeline.Continue} } @@ -157,7 +159,7 @@ func parseInferenceSSE(body []byte, ext *pipeline.InferenceExtension) { } var chunk inferenceStreamChunk if err := json.Unmarshal(data, &chunk); err != nil { - slog.Debug("inference-parser: skipping malformed SSE data frame", "error", err, "data", truncate(string(data), 128)) + slog.Debug("inference-parser: skipping malformed SSE data frame", "error", err, "data", parsercommon.Truncate(string(data), 128)) continue } for _, c := range chunk.Choices { diff --git a/authbridge/authlib/plugins/inferenceparser_test.go b/authbridge/authlib/plugins/inferenceparser/plugin_test.go similarity index 99% rename from authbridge/authlib/plugins/inferenceparser_test.go rename to authbridge/authlib/plugins/inferenceparser/plugin_test.go index e99afb9d7..4bf08addf 100644 --- a/authbridge/authlib/plugins/inferenceparser_test.go +++ b/authbridge/authlib/plugins/inferenceparser/plugin_test.go @@ -1,4 +1,4 @@ -package plugins +package inferenceparser import ( "context" @@ -442,7 +442,7 @@ func TestInferenceParser_OnResponse_SSE(t *testing.T) { "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"total_tokens\":13}}\n\n" + "data: [DONE]\n\n" pctx := &pipeline.Context{ - Extensions: pipeline.Extensions{Inference: &pipeline.InferenceExtension{Model: "gpt-4", Stream: true}}, + Extensions: pipeline.Extensions{Inference: &pipeline.InferenceExtension{Model: "gpt-4", Stream: true}}, ResponseBody: []byte(body), } action := p.OnResponse(context.Background(), pctx) diff --git a/authbridge/authlib/plugins/internal/parsercommon/parsercommon.go b/authbridge/authlib/plugins/internal/parsercommon/parsercommon.go new file mode 100644 index 000000000..b50411206 --- /dev/null +++ b/authbridge/authlib/plugins/internal/parsercommon/parsercommon.go @@ -0,0 +1,46 @@ +// Package parsercommon holds small helpers shared by the in-tree +// protocol parser plugins (a2a-parser, mcp-parser, inference-parser). +// Lives under internal/ so only packages under authlib/plugins/ can +// import it — third-party plugins are expected to either depend on +// the public pipeline surface or roll their own parsing. The contents +// here are stable but intentionally undocumented as a public API. +package parsercommon + +// JSONRPCRequest is the minimal JSON-RPC 2.0 request shape the a2a and +// mcp parsers decode. Fields intentionally match the permissive +// `any`-typed variant of the protocol: the parsers never re-emit these +// on the wire so strict typing here would only cost flexibility. +type JSONRPCRequest struct { + Method string `json:"method"` + ID any `json:"id"` + Params map[string]any `json:"params"` +} + +// StringParam returns the Params[key] value cast to string, or "" if +// absent / wrong type. +func (r *JSONRPCRequest) StringParam(key string) string { + v, _ := r.Params[key].(string) + return v +} + +// MapParam returns the Params[key] value cast to map[string]any, or +// nil if absent / wrong type. +func (r *JSONRPCRequest) MapParam(key string) map[string]any { + v, _ := r.Params[key].(map[string]any) + return v +} + +// DebugBodyMax caps how many characters of a body/content string a +// parser writes into debug logs. Large enough to capture a short user +// message or a tool_call response verbatim, small enough to keep log +// lines tractable. +const DebugBodyMax = 512 + +// Truncate clips s to max characters, appending "..." when truncated. +// Shared across parsers for consistent debug-log body formatting. +func Truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/authbridge/authlib/plugins/mcpparser.go b/authbridge/authlib/plugins/mcpparser/plugin.go similarity index 79% rename from authbridge/authlib/plugins/mcpparser.go rename to authbridge/authlib/plugins/mcpparser/plugin.go index 33c6d6511..a3b33ab63 100644 --- a/authbridge/authlib/plugins/mcpparser.go +++ b/authbridge/authlib/plugins/mcpparser/plugin.go @@ -1,4 +1,4 @@ -package plugins +package mcpparser import ( "bytes" @@ -7,6 +7,8 @@ import ( "log/slog" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/internal/parsercommon" ) // MCPParser parses MCP JSON-RPC 2.0 request bodies and populates @@ -17,7 +19,7 @@ type MCPParser struct{} func NewMCPParser() *MCPParser { return &MCPParser{} } func init() { - RegisterPlugin("mcp-parser", func() pipeline.Plugin { return NewMCPParser() }) + plugins.RegisterPlugin("mcp-parser", func() pipeline.Plugin { return NewMCPParser() }) } func (p *MCPParser) Name() string { return "mcp-parser" } @@ -39,14 +41,14 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin return pipeline.Action{Type: pipeline.Continue} } - var rpc jsonRPCRequest + var rpc parsercommon.JSONRPCRequest if err := json.Unmarshal(pctx.Body, &rpc); err != nil { slog.Debug("mcp-parser: body is not valid JSON-RPC", "error", err, "bodyLen", len(pctx.Body)) return pipeline.Action{Type: pipeline.Continue} } // Empty method → body parses as JSON but isn't a JSON-RPC request // (e.g. an OpenAI chat/completions body also unmarshals into - // jsonRPCRequest with zero-value fields). Don't attach a useless + // JSONRPCRequest with zero-value fields). Don't attach a useless // MCPExtension to non-MCP traffic — downstream consumers shouldn't // see a phantom "mcp: {}" on every inference event. if rpc.Method == "" { @@ -61,7 +63,7 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin } slog.Info("mcp-parser: request", "method", rpc.Method) - slog.Debug("mcp-parser: payload", "method", rpc.Method, "body", truncate(string(pctx.Body), debugBodyMax)) + slog.Debug("mcp-parser: payload", "method", rpc.Method, "body", parsercommon.Truncate(string(pctx.Body), parsercommon.DebugBodyMax)) pctx.Observe("matched_" + rpc.Method) return pipeline.Action{Type: pipeline.Continue} @@ -98,7 +100,7 @@ func (p *MCPParser) OnResponse(_ context.Context, pctx *pipeline.Context) pipeli if rpc.Result != nil { pctx.Extensions.MCP.Result = rpc.Result slog.Info("mcp-parser: response", "method", pctx.Extensions.MCP.Method, "resultKeys", resultKeys(rpc.Result)) - slog.Debug("mcp-parser: response detail", "method", pctx.Extensions.MCP.Method, "body", truncate(string(pctx.ResponseBody), debugBodyMax)) + slog.Debug("mcp-parser: response detail", "method", pctx.Extensions.MCP.Method, "body", parsercommon.Truncate(string(pctx.ResponseBody), parsercommon.DebugBodyMax)) } pctx.Observe("matched_" + pctx.Extensions.MCP.Method + "_response") @@ -125,7 +127,7 @@ func parseMCPResponse(body []byte) (jsonRPCResponse, bool) { } var r jsonRPCResponse if err := json.Unmarshal(data, &r); err != nil { - slog.Debug("mcp-parser: skipping malformed SSE data frame", "error", err, "data", truncate(string(data), 128)) + slog.Debug("mcp-parser: skipping malformed SSE data frame", "error", err, "data", parsercommon.Truncate(string(data), 128)) continue } if r.Result != nil || r.Error != nil { @@ -154,34 +156,3 @@ func resultKeys(m map[string]any) []string { } return keys } - -type jsonRPCRequest struct { - Method string `json:"method"` - ID any `json:"id"` - Params map[string]any `json:"params"` -} - -// stringParam and mapParam are shared helpers used by both mcp-parser and a2a-parser. -func (r *jsonRPCRequest) stringParam(key string) string { - v, _ := r.Params[key].(string) - return v -} - -func (r *jsonRPCRequest) mapParam(key string) map[string]any { - v, _ := r.Params[key].(map[string]any) - return v -} - -// debugBodyMax caps how many characters of a body/content string a parser -// writes into debug logs. Large enough to capture a short user message or -// a tool_call response verbatim, small enough to keep log lines tractable. -const debugBodyMax = 512 - -// truncate clips s to max characters, appending "..." when truncated. -// Shared across parsers for consistent debug-log body formatting. -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max] + "..." -} diff --git a/authbridge/authlib/plugins/mcpparser_test.go b/authbridge/authlib/plugins/mcpparser/plugin_test.go similarity index 99% rename from authbridge/authlib/plugins/mcpparser_test.go rename to authbridge/authlib/plugins/mcpparser/plugin_test.go index 27611a9cc..7f1601cb5 100644 --- a/authbridge/authlib/plugins/mcpparser_test.go +++ b/authbridge/authlib/plugins/mcpparser/plugin_test.go @@ -1,4 +1,4 @@ -package plugins +package mcpparser import ( "context" diff --git a/authbridge/authlib/plugins/plugins_test.go b/authbridge/authlib/plugins/plugins_test.go index 9dcfa5cf1..32ddc905f 100644 --- a/authbridge/authlib/plugins/plugins_test.go +++ b/authbridge/authlib/plugins/plugins_test.go @@ -12,13 +12,44 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" // Side-effect imports register the bundled plugins. Same pattern - // main.go uses — ensures Build("jwt-validation") / Build("token-exchange") - // resolve during tests. + // main.go uses — each plugin lives in its own subpackage and + // advertises itself via init(); importing here makes the name + // resolvable to plugins.Build in these tests. + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/a2aparser" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/inferenceparser" jwtvalidation "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/mcpparser" _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenbroker" tokenexchange "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange" ) +// TestBuiltinsRegistered verifies every in-tree plugin is discoverable +// through the registry after its side-effect import. Lives in the +// external test package because importing the plugin subpackages from +// inside the plugins package would cycle (plugin subpackages import +// plugins for RegisterPlugin). The side-effect imports at the top of +// this file drive what's present in the registry during the test run. +func TestBuiltinsRegistered(t *testing.T) { + want := map[string]bool{ + "jwt-validation": true, + "token-exchange": true, + "token-broker": true, + "a2a-parser": true, + "mcp-parser": true, + "inference-parser": true, + } + got := plugins.RegisteredPlugins() + gotSet := make(map[string]bool, len(got)) + for _, n := range got { + gotSet[n] = true + } + for name := range want { + if !gotSet[name] { + t.Errorf("built-in plugin %q missing from registry; got: %v", name, got) + } + } +} + // TestAuthbridgeCombinedYAML_Loads asserts that the in-repo default // config consumed by the combined sidecar image parses and produces // working pipelines. A future rename of any plugin default constant diff --git a/authbridge/authlib/plugins/registry_test.go b/authbridge/authlib/plugins/registry_test.go index 62d02b598..70860a820 100644 --- a/authbridge/authlib/plugins/registry_test.go +++ b/authbridge/authlib/plugins/registry_test.go @@ -8,29 +8,6 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" ) -// TestBuiltinsRegistered verifies every in-tree plugin is discoverable -// through the new registry — the list is the public contract that -// operator YAML depends on, so a regression here breaks deployments. -func TestBuiltinsRegistered(t *testing.T) { - want := map[string]bool{ - "jwt-validation": true, - "token-exchange": true, - "a2a-parser": true, - "mcp-parser": true, - "inference-parser": true, - } - got := RegisteredPlugins() - gotSet := make(map[string]bool, len(got)) - for _, n := range got { - gotSet[n] = true - } - for name := range want { - if !gotSet[name] { - t.Errorf("built-in plugin %q missing from registry; got: %v", name, got) - } - } -} - // TestRegisterPlugin_DoubleRegistration_Panics locks the strict-fail // policy. Silent last-write-wins would let a deployment with two // incompatible copies of the same plugin corrupt the pipeline diff --git a/authbridge/cmd/authbridge-envoy/go.mod b/authbridge/cmd/authbridge-envoy/go.mod new file mode 100644 index 000000000..1cd98502b --- /dev/null +++ b/authbridge/cmd/authbridge-envoy/go.mod @@ -0,0 +1,40 @@ +module github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge-envoy + +go 1.24.0 + +toolchain go1.24.5 + +require ( + github.com/envoyproxy/go-control-plane/envoy v1.37.0 + github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101000000-000000000000 + github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge v0.0.0-00010101000000-000000000000 + google.golang.org/grpc v1.80.0 +) + +require ( + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/segmentio/asm v1.2.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/kagenti/kagenti-extensions/authbridge/authlib => ../../authlib + +replace github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge => ../authbridge diff --git a/authbridge/cmd/authbridge-envoy/go.sum b/authbridge/cmd/authbridge-envoy/go.sum new file mode 100644 index 000000000..f63e27c28 --- /dev/null +++ b/authbridge/cmd/authbridge-envoy/go.sum @@ -0,0 +1,85 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/authbridge/cmd/authbridge-envoy/main.go b/authbridge/cmd/authbridge-envoy/main.go new file mode 100644 index 000000000..4aeafcc2b --- /dev/null +++ b/authbridge/cmd/authbridge-envoy/main.go @@ -0,0 +1,279 @@ +// Package main is the envoy-sidecar lite binary: envoy-sidecar mode +// only (ext_proc), with jwt-validation + token-exchange as the only +// plugins. For the full-featured all-modes binary, see cmd/authbridge. +// For no-Envoy / HTTP-proxy-only, see cmd/authbridge-proxy. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/observe" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/reloader" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/sessionapi" + + // Only the ext_proc listener is compiled in (no ext_authz, no + // HTTP proxies). + "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/extproc" + + // Only two plugins: drop the parsers and token-broker. + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange" +) + +var logLevel = new(slog.LevelVar) + +func initLogging() { + switch strings.ToLower(os.Getenv("LOG_LEVEL")) { + case "debug": + logLevel.Set(slog.LevelDebug) + case "warn": + logLevel.Set(slog.LevelWarn) + case "error": + logLevel.Set(slog.LevelError) + default: + logLevel.Set(slog.LevelInfo) + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))) +} + +func startSignalToggle() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGUSR1) + go func() { + for range sigCh { + if logLevel.Level() == slog.LevelDebug { + logLevel.Set(slog.LevelInfo) + slog.Info("log level toggled to INFO (send SIGUSR1 to switch back to DEBUG)") + } else { + logLevel.Set(slog.LevelDebug) + slog.Info("log level toggled to DEBUG (send SIGUSR1 to switch back to INFO)") + } + } + }() +} + +func main() { + configPath := flag.String("config", "", "path to config YAML file") + flag.Parse() + + initLogging() + startSignalToggle() + + if *configPath == "" { + log.Fatal("--config is required") + } + + buildPipelines := func() (*pipeline.Pipeline, *pipeline.Pipeline, *config.Config, error) { + c, err := config.Load(*configPath) + if err != nil { + return nil, nil, nil, err + } + if c.Mode != "" && c.Mode != config.ModeEnvoySidecar { + return nil, nil, nil, fmt.Errorf( + "authbridge-envoy supports only mode=%q (got %q); use cmd/authbridge for other modes", + config.ModeEnvoySidecar, c.Mode) + } + c.Mode = config.ModeEnvoySidecar + config.ApplyPreset(c) + if err := config.Validate(c); err != nil { + return nil, nil, nil, err + } + in, err := plugins.Build(c.Pipeline.Inbound.Plugins) + if err != nil { + return nil, nil, nil, fmt.Errorf("inbound: %w", err) + } + out, err := plugins.Build(c.Pipeline.Outbound.Plugins) + if err != nil { + return nil, nil, nil, fmt.Errorf("outbound: %w", err) + } + return in, out, c, nil + } + + inboundPipeline, outboundPipeline, cfg, err := buildPipelines() + if err != nil { + log.Fatalf("initial pipeline build: %v", err) + } + + initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer initCancel() + if err := inboundPipeline.Start(initCtx); err != nil { + log.Fatalf("inbound pipeline Start: %v", err) + } + if err := outboundPipeline.Start(initCtx); err != nil { + log.Fatalf("outbound pipeline Start: %v", err) + } + + inboundH := pipeline.NewHolder(inboundPipeline) + outboundH := pipeline.NewHolder(outboundPipeline) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + rld := reloader.New(*configPath, inboundH, outboundH, buildPipelines, cfg) + if err := rld.Start(ctx); err != nil { + log.Fatalf("reloader: %v", err) + } + + var sessions *session.Store + if cfg.Session.SessionEnabled() { + ttl := 30 * time.Minute + if cfg.Session.TTL != "" { + if d, err := time.ParseDuration(cfg.Session.TTL); err == nil { + ttl = d + } else { + slog.Warn("invalid session.ttl, using default", "value", cfg.Session.TTL, "error", err) + } + } + maxEvents := 100 + if cfg.Session.MaxEvents > 0 { + maxEvents = cfg.Session.MaxEvents + } + maxSessions := 100 + if cfg.Session.MaxSessions > 0 { + maxSessions = cfg.Session.MaxSessions + } + sessions = session.New(ttl, maxEvents, maxSessions) + slog.Info("session tracking enabled", "ttl", ttl, "maxEvents", maxEvents, "maxSessions", maxSessions) + } else { + slog.Info("session tracking disabled") + } + + var grpcServers []*grpc.Server + grpcServers = append(grpcServers, startGRPCExtProc(inboundH, outboundH, sessions, cfg.Listener.ExtProcAddr)) + + statsProvider := func() *auth.Stats { + sources := plugins.CollectStats(inboundH.Load()) + sources = append(sources, plugins.CollectStats(outboundH.Load())...) + return auth.MergeStats(sources...) + } + statSrv := startStatServer(cfg, rld.ConfigProvider(), statsProvider, rld.Handler()) + + var sessionAPISrv *sessionapi.Server + if cfg.Listener.SessionAPIAddr != "" && sessions != nil { + sessionAPISrv = sessionapi.New( + cfg.Listener.SessionAPIAddr, + sessions, + sessionapi.WithPipelines(inboundH, outboundH), + ) + go func() { + slog.Warn("session API listening — UNAUTHENTICATED; contains raw user content; never expose via ingress", + "addr", cfg.Listener.SessionAPIAddr) + if err := sessionAPISrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("session API: %v", err) + } + }() + } + + slog.Info("authbridge-envoy starting", "mode", cfg.Mode, "logLevel", logLevel.Level().String()) + + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) { + if name := inboundH.NotReadyPlugin(); name != "" { + http.Error(w, "inbound plugin not ready: "+name, http.StatusServiceUnavailable) + return + } + if name := outboundH.NotReadyPlugin(); name != "" { + http.Error(w, "outbound plugin not ready: "+name, http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + }) + slog.Info("health server listening", "addr", ":9091") + if err := http.ListenAndServe(":9091", mux); err != nil { + slog.Warn("health server failed", "error", err) + } + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + sig := <-sigCh + slog.Info("shutting down", "signal", sig) + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + + for _, srv := range grpcServers { + go func(s *grpc.Server) { + <-shutdownCtx.Done() + s.Stop() + }(srv) + srv.GracefulStop() + } + statSrv.Shutdown(shutdownCtx) + if sessionAPISrv != nil { + sessionAPISrv.Shutdown(shutdownCtx) + } + + outboundPipeline.Stop(shutdownCtx) + inboundPipeline.Stop(shutdownCtx) + + if sessions != nil { + sessions.Close() + } +} + +func startGRPCExtProc(inbound, outbound *pipeline.Holder, sessions *session.Store, addr string) *grpc.Server { + srv := grpc.NewServer() + extprocv3.RegisterExternalProcessorServer(srv, &extproc.Server{ + InboundPipeline: inbound, + OutboundPipeline: outbound, + Sessions: sessions, + }) + registerHealth(srv) + reflection.Register(srv) + + go func() { + lis, err := net.Listen("tcp", addr) + if err != nil { + log.Fatalf("ext_proc listen %s: %v", addr, err) + } + slog.Info("ext_proc gRPC listening", "addr", addr) + if err := srv.Serve(lis); err != nil { + log.Fatalf("ext_proc serve: %v", err) + } + }() + return srv +} + +func startStatServer(cfg *config.Config, cfgProvider observe.ConfigProvider, statsProvider observe.StatsProvider, reloadStatus http.Handler) *observe.StatServer { + srv := observe.NewStatServer(cfg.Stats.StatsAddress, cfgProvider, statsProvider, + observe.WithReloadStatus(reloadStatus)) + go func() { + slog.Info("stat server listening", "addr", cfg.Stats.StatsAddress) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("stat server: %v", err) + } + }() + return srv +} + +func registerHealth(srv *grpc.Server) { + healthSrv := health.NewServer() + healthpb.RegisterHealthServer(srv, healthSrv) + healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) +} diff --git a/authbridge/cmd/authbridge-proxy/go.mod b/authbridge/cmd/authbridge-proxy/go.mod new file mode 100644 index 000000000..b6519d958 --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/go.mod @@ -0,0 +1,26 @@ +module github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge-proxy + +go 1.24.0 + +toolchain go1.24.5 + +require github.com/kagenti/kagenti-extensions/authbridge/authlib v0.0.0-00010101000000-000000000000 + +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/kagenti/kagenti-extensions/authbridge/authlib => ../../authlib diff --git a/authbridge/cmd/authbridge-proxy/go.sum b/authbridge/cmd/authbridge-proxy/go.sum new file mode 100644 index 000000000..d338f51ea --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/go.sum @@ -0,0 +1,41 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/authbridge/cmd/authbridge-proxy/main.go b/authbridge/cmd/authbridge-proxy/main.go new file mode 100644 index 000000000..b294c0200 --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/main.go @@ -0,0 +1,270 @@ +// Package main is the "lite" authbridge binary: proxy-sidecar mode +// only (no Envoy), with jwt-validation + token-exchange as the only +// plugins compiled in. Optimizes for binary size by dropping the +// gRPC / envoy go-control-plane dependency tree and the +// a2a/mcp/inference parser plugins. +// +// For the full-featured batteries-included binary, see cmd/authbridge. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/observe" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/reloader" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/sessionapi" + + // Only HTTP listeners are compiled in: no extproc/extauthz + // (no gRPC, no envoy types). + "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/forwardproxy" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/reverseproxy" + + // Only two plugins: drop the parsers and token-broker. + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange" +) + +var logLevel = new(slog.LevelVar) + +func initLogging() { + switch strings.ToLower(os.Getenv("LOG_LEVEL")) { + case "debug": + logLevel.Set(slog.LevelDebug) + case "warn": + logLevel.Set(slog.LevelWarn) + case "error": + logLevel.Set(slog.LevelError) + default: + logLevel.Set(slog.LevelInfo) + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel}))) +} + +func startSignalToggle() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGUSR1) + go func() { + for range sigCh { + if logLevel.Level() == slog.LevelDebug { + logLevel.Set(slog.LevelInfo) + slog.Info("log level toggled to INFO (send SIGUSR1 to switch back to DEBUG)") + } else { + logLevel.Set(slog.LevelDebug) + slog.Info("log level toggled to DEBUG (send SIGUSR1 to switch back to INFO)") + } + } + }() +} + +func main() { + configPath := flag.String("config", "", "path to config YAML file") + flag.Parse() + + initLogging() + startSignalToggle() + + if *configPath == "" { + log.Fatal("--config is required") + } + + // This binary is hardcoded to proxy-sidecar. Rejecting other modes + // early gives operators a clear boot-time error instead of silently + // misbehaving (e.g., YAML says envoy-sidecar but binary can't + // serve ext_proc). + buildPipelines := func() (*pipeline.Pipeline, *pipeline.Pipeline, *config.Config, error) { + c, err := config.Load(*configPath) + if err != nil { + return nil, nil, nil, err + } + if c.Mode != "" && c.Mode != config.ModeProxySidecar { + return nil, nil, nil, fmt.Errorf( + "authbridge-proxy supports only mode=%q (got %q); use cmd/authbridge for other modes", + config.ModeProxySidecar, c.Mode) + } + c.Mode = config.ModeProxySidecar + config.ApplyPreset(c) + if err := config.Validate(c); err != nil { + return nil, nil, nil, err + } + in, err := plugins.Build(c.Pipeline.Inbound.Plugins) + if err != nil { + return nil, nil, nil, fmt.Errorf("inbound: %w", err) + } + out, err := plugins.Build(c.Pipeline.Outbound.Plugins) + if err != nil { + return nil, nil, nil, fmt.Errorf("outbound: %w", err) + } + return in, out, c, nil + } + + inboundPipeline, outboundPipeline, cfg, err := buildPipelines() + if err != nil { + log.Fatalf("initial pipeline build: %v", err) + } + + initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second) + defer initCancel() + if err := inboundPipeline.Start(initCtx); err != nil { + log.Fatalf("inbound pipeline Start: %v", err) + } + if err := outboundPipeline.Start(initCtx); err != nil { + log.Fatalf("outbound pipeline Start: %v", err) + } + + inboundH := pipeline.NewHolder(inboundPipeline) + outboundH := pipeline.NewHolder(outboundPipeline) + + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + rld := reloader.New(*configPath, inboundH, outboundH, buildPipelines, cfg) + if err := rld.Start(ctx); err != nil { + log.Fatalf("reloader: %v", err) + } + + var sessions *session.Store + if cfg.Session.SessionEnabled() { + ttl := 30 * time.Minute + if cfg.Session.TTL != "" { + if d, err := time.ParseDuration(cfg.Session.TTL); err == nil { + ttl = d + } else { + slog.Warn("invalid session.ttl, using default", "value", cfg.Session.TTL, "error", err) + } + } + maxEvents := 100 + if cfg.Session.MaxEvents > 0 { + maxEvents = cfg.Session.MaxEvents + } + maxSessions := 100 + if cfg.Session.MaxSessions > 0 { + maxSessions = cfg.Session.MaxSessions + } + sessions = session.New(ttl, maxEvents, maxSessions) + slog.Info("session tracking enabled", "ttl", ttl, "maxEvents", maxEvents, "maxSessions", maxSessions) + } else { + slog.Info("session tracking disabled") + } + + var httpServers []*http.Server + + // Proxy-sidecar: reverse proxy on the inbound path + forward proxy + // on the outbound path. + rpSrv, err := reverseproxy.NewServer(inboundH, sessions, cfg.Listener.ReverseProxyBackend) + if err != nil { + log.Fatalf("creating reverse proxy: %v", err) + } + httpServers = append(httpServers, startHTTPServer("reverse-proxy", rpSrv.Handler(), cfg.Listener.ReverseProxyAddr)) + httpServers = append(httpServers, startHTTPServer("forward-proxy", forwardproxy.NewServer(outboundH, sessions).Handler(), cfg.Listener.ForwardProxyAddr)) + + statsProvider := func() *auth.Stats { + sources := plugins.CollectStats(inboundH.Load()) + sources = append(sources, plugins.CollectStats(outboundH.Load())...) + return auth.MergeStats(sources...) + } + statSrv := startStatServer(cfg, rld.ConfigProvider(), statsProvider, rld.Handler()) + + var sessionAPISrv *sessionapi.Server + if cfg.Listener.SessionAPIAddr != "" && sessions != nil { + sessionAPISrv = sessionapi.New( + cfg.Listener.SessionAPIAddr, + sessions, + sessionapi.WithPipelines(inboundH, outboundH), + ) + go func() { + slog.Warn("session API listening — UNAUTHENTICATED; contains raw user content; never expose via ingress", + "addr", cfg.Listener.SessionAPIAddr) + if err := sessionAPISrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("session API: %v", err) + } + }() + } + + slog.Info("authbridge-proxy starting", "mode", cfg.Mode, "logLevel", logLevel.Level().String()) + + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) { + if name := inboundH.NotReadyPlugin(); name != "" { + http.Error(w, "inbound plugin not ready: "+name, http.StatusServiceUnavailable) + return + } + if name := outboundH.NotReadyPlugin(); name != "" { + http.Error(w, "outbound plugin not ready: "+name, http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + }) + slog.Info("health server listening", "addr", ":9091") + if err := http.ListenAndServe(":9091", mux); err != nil { + slog.Warn("health server failed", "error", err) + } + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + sig := <-sigCh + slog.Info("shutting down", "signal", sig) + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + + for _, srv := range httpServers { + srv.Shutdown(shutdownCtx) + } + statSrv.Shutdown(shutdownCtx) + if sessionAPISrv != nil { + sessionAPISrv.Shutdown(shutdownCtx) + } + + outboundPipeline.Stop(shutdownCtx) + inboundPipeline.Stop(shutdownCtx) + + if sessions != nil { + sessions.Close() + } +} + +func startHTTPServer(name string, handler http.Handler, addr string) *http.Server { + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + slog.Info("HTTP server listening", "name", name, "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("%s serve: %v", name, err) + } + }() + return srv +} + +func startStatServer(cfg *config.Config, cfgProvider observe.ConfigProvider, statsProvider observe.StatsProvider, reloadStatus http.Handler) *observe.StatServer { + srv := observe.NewStatServer(cfg.Stats.StatsAddress, cfgProvider, statsProvider, + observe.WithReloadStatus(reloadStatus)) + go func() { + slog.Info("stat server listening", "addr", cfg.Stats.StatsAddress) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("stat server: %v", err) + } + }() + return srv +} diff --git a/authbridge/cmd/authbridge/main.go b/authbridge/cmd/authbridge/main.go index 7831aaec3..2603321d5 100644 --- a/authbridge/cmd/authbridge/main.go +++ b/authbridge/cmd/authbridge/main.go @@ -30,21 +30,25 @@ import ( "github.com/kagenti/kagenti-extensions/authbridge/authlib/observe" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" - // Bundled plugins register themselves via init() on import. These - // two moved from flat files in plugins/ to their own subpackages - // (they own private subtrees for validation / exchange / cache / - // spiffe); we opt into them here. External plugins follow the - // same pattern — drop one line in here (or a plugins_extra.go) to - // include them in this binary's build. + // Bundled plugins register themselves via init() on import. Every + // in-tree plugin now lives in its own subpackage; this main.go is + // the batteries-included "reference" binary that opts into all of + // them. External plugins follow the same pattern — add one + // side-effect import here (or in a plugins_extra.go) to include + // them in this binary's build. Slimmer binaries (e.g. + // cmd/authbridge-proxy) import only the subset they need. + "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/forwardproxy" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/reverseproxy" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/a2aparser" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/inferenceparser" _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/jwtvalidation" + _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/mcpparser" _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/tokenexchange" "github.com/kagenti/kagenti-extensions/authbridge/authlib/reloader" "github.com/kagenti/kagenti-extensions/authbridge/authlib/session" "github.com/kagenti/kagenti-extensions/authbridge/authlib/sessionapi" "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/extauthz" "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/extproc" - "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/forwardproxy" - "github.com/kagenti/kagenti-extensions/authbridge/cmd/authbridge/listener/reverseproxy" ) // logLevel is the dynamic log level, togglable at runtime via SIGUSR1. diff --git a/authbridge/go.work b/authbridge/go.work index 93e774b9f..b16b03132 100644 --- a/authbridge/go.work +++ b/authbridge/go.work @@ -5,4 +5,6 @@ use ( ./authproxy ./cmd/abctl ./cmd/authbridge + ./cmd/authbridge-envoy + ./cmd/authbridge-proxy )