Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/atenet/internal/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"os"

"github.com/agent-substrate/substrate/cmd/atenet/internal/router"
"github.com/agent-substrate/substrate/cmd/atenet/internal/router/egressinject"
"github.com/agent-substrate/substrate/cmd/atenet/internal/sdsmint"
"github.com/agent-substrate/substrate/internal/version"
"github.com/spf13/cobra"
Expand All @@ -42,4 +43,5 @@ func init() {
rootCmd.AddCommand(router.NewRouterCmd())
rootCmd.AddCommand(NewDnsCmd())
rootCmd.AddCommand(sdsmint.NewSdsmintCmd())
rootCmd.AddCommand(egressinject.NewCmd())
}
83 changes: 83 additions & 0 deletions cmd/atenet/internal/router/egressinject/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package egressinject

import (
"time"

"github.com/spf13/cobra"
)

type config struct {
ProviderName string
ProviderAddress string
OnNoMatch string
ExtprocPort int
MetricsAddr string
LogLevel string
DrainGrace time.Duration

// Serving TLS (the egress gateway dials this server with mTLS + SAN pin).
ServerCredBundle string
ClientCAFile string

// Client TLS presented to the credential provider.
ProviderCAFile string
ProviderClientCert string
ProviderServerName string

// ateapi Control connection: where each actor's egress policy is fetched.
AteapiAddr string
AteapiCAFile string
AteapiClientCert string
AteapiServerName string
}

// NewCmd builds the `atenet egress-inject` subcommand: the ext_proc server the
// egress gateway's MITM leg calls to inject credentials into outbound requests.
func NewCmd() *cobra.Command {
var cfg config

cmd := &cobra.Command{
Use: "egress-inject",
Short: "ext_proc server that injects policy-selected credentials into egress requests on the MITM leg",
RunE: func(cmd *cobra.Command, _ []string) error {
return run(cmd.Context(), cfg)
},
}

f := cmd.Flags()
f.StringVar(&cfg.ProviderName, "credential-provider-name", "substrate-secret://kubernetes.io", "the credential-provider this injector serves, as a substrate-secret:// class prefix (e.g. substrate-secret://kubernetes.io); a policy credential URI of any other class is refused (empty disables the check, dev only)")
f.StringVar(&cfg.ProviderAddress, "credential-provider-address", "", "address of the credential provider gRPC service; required")
f.StringVar(&cfg.OnNoMatch, "on-no-match", "allow", "what to do when no policy rule matches: allow (pass through) or deny (403)")
f.IntVar(&cfg.ExtprocPort, "port-extproc", 50051, "ext_proc gRPC listen port")
f.StringVar(&cfg.MetricsAddr, "metrics-address", ":9090", "Prometheus/health HTTP listen address")
f.StringVar(&cfg.LogLevel, "log-level", "info", "one of debug, info, warn, error")
f.DurationVar(&cfg.DrainGrace, "drain-grace", 5*time.Second, "how long to wait for in-flight RPCs on shutdown before a hard stop")

f.StringVar(&cfg.ServerCredBundle, "server-cred-bundle", "", "credential bundle (PEM key+chain) presented for serving TLS to the gateway; empty serves plaintext (dev only)")
f.StringVar(&cfg.ClientCAFile, "client-ca-file", "", "CA the gateway's client certificate must chain to; empty accepts any client when TLS is on")

f.StringVar(&cfg.ProviderCAFile, "provider-ca-file", "", "CA the credential provider's serving certificate must chain to; empty dials the provider plaintext (dev only)")
f.StringVar(&cfg.ProviderClientCert, "provider-client-cert", "", "credential bundle presented to the credential provider")
f.StringVar(&cfg.ProviderServerName, "provider-server-name", "", "expected SAN/SNI of the credential provider's serving certificate")

f.StringVar(&cfg.AteapiAddr, "ateapi-address", "dns:///api.ate-system.svc:443", "gRPC dial target for the cluster ateapi Control instance, from which each actor's egress policy is fetched")
f.StringVar(&cfg.AteapiCAFile, "ateapi-ca-file", "", "CA the ateapi serving certificate must chain to; required")
f.StringVar(&cfg.AteapiClientCert, "ateapi-client-cert", "", "credential bundle presented to ateapi as the client certificate; required")
f.StringVar(&cfg.AteapiServerName, "ateapi-server-name", "", "expected SAN/SNI of the ateapi serving certificate")

return cmd
}
264 changes: 264 additions & 0 deletions cmd/atenet/internal/router/egressinject/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package egressinject implements the ext_proc handler that runs on the egress
// gateway's decrypted MITM leg: it fetches the requesting actor's egress policy
// from the ateapi control plane, matches each outbound request against it, and
// on a match that carries a credential injection fetches the credential from the
// credential provider and injects it as a request header. Actor identity comes
// from the CA-signed client cert the gateway verified on the CONNECT leg,
// relayed here as the ate.actor.identity filter-state attribute — never from a
// request header.
package egressinject

import (
"bytes"
"context"
"fmt"
"log/slog"
"strings"

corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
envoy_type "github.com/envoyproxy/go-control-plane/envoy/type/v3"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

"github.com/agent-substrate/substrate/cmd/atenet/internal/router/extproc"
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
"github.com/agent-substrate/substrate/pkg/proto/credproviderpb"
)

// actorIdentityAttribute is the CEL request attribute the MITM ext_proc filter
// forwards, carrying the actor's verified identity URI. It is the filter-state
// object ate.actor.identity the CONNECT leg set from the peer cert's URI SAN.
const actorIdentityAttribute = "filter_state['ate.actor.identity']"

// schemeHeader is the HTTP/2 pseudo-header carrying the request scheme. On the
// MITM leg the TLS chain yields "https" and the cleartext chain "http"; the
// injector refuses to inject a credential over anything but https.
const schemeHeader = ":scheme"

// NoMatchAction is what the handler does with a request no policy rule matches.
type NoMatchAction string

const (
// NoMatchAllow lets an unmatched request proceed unchanged (no injection).
NoMatchAllow NoMatchAction = "allow"
// NoMatchDeny rejects an unmatched request with 403.
NoMatchDeny NoMatchAction = "deny"
)

// ParseNoMatchAction validates a --on-no-match flag value.
func ParseNoMatchAction(s string) (NoMatchAction, error) {
switch NoMatchAction(s) {
case NoMatchAllow:
return NoMatchAllow, nil
case NoMatchDeny:
return NoMatchDeny, nil
default:
return "", fmt.Errorf("invalid on-no-match %q (want allow or deny)", s)
}
}

// Handler injects credentials into matched egress requests.
type Handler struct {
apiClient policyClient
provider credproviderpb.CredentialProviderClient
// providerClass is the credential-provider class this injector serves (e.g.
// "kubernetes.io", parsed from the substrate-secret:// prefix). A credential
// URI of any other class is refused. Empty disables the check (dev only).
providerClass string
onNoMatch NoMatchAction
}

// New builds the injector handler. apiClient fetches an actor's egress policy
// from the ateapi control plane per request; providerClass is the credential
// provider class this injector serves, and a policy URI of another class fails
// closed.
func New(apiClient policyClient, provider credproviderpb.CredentialProviderClient, providerClass string, onNoMatch NoMatchAction) *Handler {
return &Handler{apiClient: apiClient, provider: provider, providerClass: providerClass, onNoMatch: onNoMatch}
}

func (h *Handler) Direction() extproc.Direction { return extproc.DirectionEgressInject }

// HandleRequestHeaders fetches the actor's egress policy, matches the request
// against it, and on a matched credential injection fetches the credential and
// returns a header mutation adding it. Unmatched requests follow onNoMatch; a
// policy or provider failure fails closed.
func (h *Handler) HandleRequestHeaders(ctx context.Context, md *extproc.RequestMetadata) (extproc.Result, error) {
identity := md.Attribute(actorIdentityAttribute)
host := hostFromAuthority(md.Host)

atespace, actor, err := parseActorURI(identity)
if err != nil {
// No usable identity means no policy can be fetched. Treat it as a
// non-match so onNoMatch governs, but say why at the server.
slog.WarnContext(ctx, "egress-inject: unusable actor identity", slog.String("host", host), slog.Any("err", err))
return h.noMatch(host)
}

policy, err := h.apiClient.GetActorEgressPolicy(ctx, &ateapipb.GetActorEgressPolicyRequest{
Actor: &ateapipb.ObjectRef{Atespace: atespace, Name: actor},
})
if err != nil {
if status.Code(err) == codes.NotFound {
// The actor has no egress policy: nothing to inject.
slog.InfoContext(ctx, "egress-inject: actor has no egress policy",
slog.String("atespace", atespace), slog.String("actor", actor), slog.String("host", host))
return h.noMatch(host)
}
// Fail closed: we cannot tell whether a credential was required, so the
// request must not go out potentially missing it.
slog.ErrorContext(ctx, "egress-inject: fetching egress policy failed",
slog.String("atespace", atespace), slog.String("actor", actor),
slog.String("host", host), slog.Any("err", err))
return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_ServiceUnavailable, err,
"egress-inject: egress policy unavailable for %s", host)
}

injections, matched := evaluate(policy, host)
if !matched {
slog.InfoContext(ctx, "egress-inject: no matching policy rule",
slog.String("atespace", atespace), slog.String("actor", actor), slog.String("host", host))
return h.noMatch(host)
}
if len(injections) == 0 {
// A rule matched but injects nothing: the request is authorized, pass it
// through unchanged.
return extproc.Result{Target: host, Response: &extprocv3.HeadersResponse{Response: &extprocv3.CommonResponse{}}}, nil
}

// Refuse to inject a credential over cleartext: on the cleartext MITM chain
// the request is re-originated without upstream TLS, so the secret would
// leave the pod in the clear. Test scheme != "https" (not == "http") so a
// missing or unknown scheme also fails closed. The egress-policy API has no
// per-rule cleartext opt-in, so this refusal is unconditional.
if scheme := strings.ToLower(md.Header(schemeHeader)); scheme != "https" {
slog.WarnContext(ctx, "egress-inject: refusing to inject credential over cleartext",
slog.String("atespace", atespace), slog.String("actor", actor),
slog.String("host", host), slog.String("scheme", scheme))
return extproc.Result{Target: host}, extproc.NewReqError(envoy_type.StatusCode_Forbidden,
"egress-inject: refusing to inject a credential over cleartext to %s", host)
}

setHeaders := make([]*corev3.HeaderValueOption, 0, len(injections))
for _, inj := range injections {
if err := validateInjectHeader(inj.GetHeader()); err != nil {
slog.ErrorContext(ctx, "egress-inject: policy names an unusable header",
slog.String("atespace", atespace), slog.String("actor", actor),
slog.String("host", host), slog.String("header", inj.GetHeader()), slog.Any("err", err))
return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_InternalServerError, err,
"egress-inject: policy for %s names an unusable header", host)
}

// Confirm the credential URI targets the provider class this injector
// serves before dialing it: the configured provider fronts one class, so a
// URI of another class cannot be resolved here and must fail closed rather
// than be sent to the wrong provider.
if h.providerClass != "" {
class, err := credentialURIClass(inj.GetCredentialUri())
if err != nil {
slog.ErrorContext(ctx, "egress-inject: policy names an unparseable credential URI",
slog.String("atespace", atespace), slog.String("actor", actor),
slog.String("host", host), slog.String("uri", inj.GetCredentialUri()), slog.Any("err", err))
return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_InternalServerError, err,
"egress-inject: policy for %s names an unparseable credential URI", host)
}
if class != h.providerClass {
slog.ErrorContext(ctx, "egress-inject: credential URI targets an unserved provider class",
slog.String("atespace", atespace), slog.String("actor", actor),
slog.String("host", host), slog.String("uri", inj.GetCredentialUri()),
slog.String("class", class), slog.String("provider", h.providerClass))
return extproc.Result{Target: host}, extproc.NewReqError(envoy_type.StatusCode_InternalServerError,
"egress-inject: credential URI for %s targets provider class %q, this injector serves %q", host, class, h.providerClass)
}
}

resp, err := h.provider.RequestSecret(ctx, &credproviderpb.RequestSecretRequest{
Uri: inj.GetCredentialUri(),
Context: &credproviderpb.SecretRequestContext{
ActorIdentity: identity,
},
})
if err != nil {
// Fail closed: a credential we were told to inject but could not fetch
// must not let the request out without it.
slog.ErrorContext(ctx, "egress-inject: credential fetch failed",
slog.String("atespace", atespace), slog.String("actor", actor),
slog.String("host", host), slog.String("uri", inj.GetCredentialUri()), slog.Any("err", err))
return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_ServiceUnavailable, err,
"egress-inject: credential unavailable for %s", host)
}

secret, err := sanitizeSecret(resp.GetSecret())
if err != nil {
// Fail closed: an empty or malformed credential must not go upstream as
// a bare "Bearer " nor as a header value Envoy would reject.
slog.ErrorContext(ctx, "egress-inject: unusable credential",
slog.String("atespace", atespace), slog.String("actor", actor),
slog.String("host", host), slog.String("uri", inj.GetCredentialUri()), slog.Any("err", err))
return extproc.Result{Target: host}, extproc.WrapReqError(envoy_type.StatusCode_ServiceUnavailable, err,
"egress-inject: unusable credential for %s", host)
}

// Overwrite any header the actor set itself, so a client cannot pre-seed
// a value that survives injection.
setHeaders = append(setHeaders, &corev3.HeaderValueOption{
Header: &corev3.HeaderValue{Key: inj.GetHeader(), RawValue: append([]byte(inj.GetPrefix()), secret...)},
AppendAction: corev3.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD,
})
}

slog.InfoContext(ctx, "egress-inject: injecting credential(s)",
slog.String("atespace", atespace), slog.String("actor", actor),
slog.String("host", host), slog.Int("headers", len(setHeaders)))

return extproc.Result{
Target: host,
Response: &extprocv3.HeadersResponse{
Response: &extprocv3.CommonResponse{
HeaderMutation: &extprocv3.HeaderMutation{SetHeaders: setHeaders},
},
},
}, nil
}

// sanitizeSecret prepares resolved secret bytes for use as (part of) an HTTP
// header value. It trims a trailing newline — a Kubernetes Secret created from a
// file commonly carries one — then rejects an empty secret or one containing any
// control character, which Envoy would reject as an invalid header value and, in
// the case of CR/LF, would allow header injection.
func sanitizeSecret(secret []byte) ([]byte, error) {
secret = bytes.TrimRight(secret, "\r\n")
if len(secret) == 0 {
return nil, fmt.Errorf("resolved credential is empty")
}
for _, b := range secret {
if b < 0x20 || b == 0x7f {
return nil, fmt.Errorf("resolved credential contains a control character")
}
}
return secret, nil
}

// noMatch applies the configured no-match action.
func (h *Handler) noMatch(host string) (extproc.Result, error) {
if h.onNoMatch == NoMatchDeny {
return extproc.Result{Target: host}, extproc.NewReqError(envoy_type.StatusCode_Forbidden,
"egress-inject: no policy permits egress to %s", host)
}
// Allow: proceed unchanged.
return extproc.Result{Target: host, Response: &extprocv3.HeadersResponse{Response: &extprocv3.CommonResponse{}}}, nil
}
Loading
Loading