Skip to content

Commit e6ffdae

Browse files
scotwellsclaude
andcommitted
feat(extension-server): report the protections the edge was told to run
Adds a read-only endpoint that reports the set of firewall and connector protections the platform handed to the edge. Read-only and off the traffic path; it observes configuration and never touches live requests. This is the reference point for later confirming the edge runs exactly what it was given. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JbCy8vy66RdNYzGSgqH6P6
1 parent 2a12d5d commit e6ffdae

5 files changed

Lines changed: 723 additions & 1 deletion

File tree

internal/extensionserver/cmd/run.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,9 @@ func run(o options) {
450450
}
451451
})
452452
mux.Handle("/metrics", promhttp.Handler())
453+
// Debug endpoint that reports what the last build changed, so a test can
454+
// confirm the proxy is running exactly that. Read-only; keeps only the last build.
455+
mux.HandleFunc(extserver.ProgrammedSetEndpointPath, extSrv.ProgrammedSetHandler())
453456
healthServer := &http.Server{
454457
Addr: healthAddr,
455458
Handler: mux,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package server
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"sync"
7+
"time"
8+
9+
clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
10+
listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
11+
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
12+
hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
13+
)
14+
15+
// programmedSetEndpointPath is a read-only debug endpoint that reports the exact
16+
// set of changes the last build intended to make, so a test can confirm the
17+
// proxy is running that set and not merely the right number of things.
18+
const programmedSetEndpointPath = "/debug/programmed-set"
19+
20+
// Marker strings, kept in sync with where the configuration is written, used to
21+
// recognize each change when reading the configuration back.
22+
const (
23+
datumGatewayMetaKey = "datum-gateway"
24+
connectorInternalTransport = "envoy.transport_sockets.internal_upstream"
25+
hcmNetworkFilterName = "envoy.filters.network.http_connection_manager"
26+
)
27+
28+
// Family names a kind of change recorded in a snapshot. The strings are stable
29+
// JSON keys the parity test depends on; do not rename.
30+
type Family string
31+
32+
const (
33+
FamilyWAFRoute Family = "waf_route"
34+
FamilyWAFHCM Family = "waf_hcm"
35+
FamilyLocalReply Family = "local_reply"
36+
FamilyConnectorCluster Family = "connector_cluster"
37+
FamilyConnectorRoute Family = "connector_route"
38+
FamilyConnectorOffline Family = "connector_offline"
39+
FamilyTLSPrune Family = "tls_prune"
40+
)
41+
42+
// ProgrammedSet is a snapshot of one build's intended changes. It is served as
43+
// JSON and read back by the parity test.
44+
type ProgrammedSet struct {
45+
// BuildID increments once per build so a caller can tell two successive
46+
// snapshots apart and know a fresh build has happened.
47+
BuildID uint64 `json:"buildID"`
48+
// CapturedAt is when the snapshot was recorded.
49+
CapturedAt time.Time `json:"capturedAt"`
50+
// Keys is, per kind of change, the identity of each thing changed. Each value
51+
// is sorted and de-duplicated. The identity format is described on the
52+
// record helpers below and mirrored by the parity test.
53+
Keys map[Family][]string `json:"keys"`
54+
// Counts holds the size of each set in Keys, plus the removed-certificate
55+
// outcomes below that have no per-item identity. Used as a secondary check.
56+
Counts map[Family]int `json:"counts"`
57+
// Removing invalid TLS certificates is confirmed by counting outcomes rather
58+
// than by identity, so the raw numbers are carried here.
59+
TLSPrunedChains int `json:"tlsPrunedChains"`
60+
TLSPrunedSecrets int `json:"tlsPrunedSecrets"`
61+
TLSListenersLeftIntact int `json:"tlsListenersLeftIntact"`
62+
}
63+
64+
// programmedRecorder holds the most recent snapshot under a lock, shared between
65+
// the build that writes it and the endpoint that reads it. Only the last build
66+
// is kept; each recording replaces the previous one.
67+
type programmedRecorder struct {
68+
mu sync.RWMutex
69+
buildID uint64
70+
last *ProgrammedSet
71+
}
72+
73+
// newProgrammedRecorder returns an empty recorder. Before the first build it
74+
// reports a valid empty snapshot with a zero build count, so callers can tell
75+
// "no build yet" from a real build.
76+
func newProgrammedRecorder() *programmedRecorder {
77+
return &programmedRecorder{}
78+
}
79+
80+
// snapshot returns the last recorded set, or an empty one if no build has run.
81+
// The returned value is a copy safe to serialize without the lock.
82+
func (r *programmedRecorder) snapshot() ProgrammedSet {
83+
r.mu.RLock()
84+
defer r.mu.RUnlock()
85+
if r.last == nil {
86+
return ProgrammedSet{Keys: map[Family][]string{}, Counts: map[Family]int{}}
87+
}
88+
return *r.last
89+
}
90+
91+
// record reads the configuration the build just produced and stores a snapshot
92+
// of it. It only reads, never changes the configuration, and holds the write
93+
// lock just long enough to store the result so it doesn't slow the build.
94+
func (r *programmedRecorder) record(
95+
listeners []*listenerv3.Listener,
96+
routes []*routev3.RouteConfiguration,
97+
clusters []*clusterv3.Cluster,
98+
corazaFilterName string,
99+
tlsPrunedChains, tlsPrunedSecrets, tlsListenersLeftIntact int,
100+
) {
101+
ps := buildProgrammedSet(listeners, routes, clusters, corazaFilterName,
102+
tlsPrunedChains, tlsPrunedSecrets, tlsListenersLeftIntact)
103+
104+
r.mu.Lock()
105+
r.buildID++
106+
ps.BuildID = r.buildID
107+
r.last = &ps
108+
r.mu.Unlock()
109+
}
110+
111+
// buildProgrammedSet walks the produced configuration and extracts an identity
112+
// for each change. It is a pure function so it can be unit tested directly. The
113+
// identity formats must match what the parity test looks for; keep both sides
114+
// in lockstep.
115+
func buildProgrammedSet(
116+
listeners []*listenerv3.Listener,
117+
routes []*routev3.RouteConfiguration,
118+
clusters []*clusterv3.Cluster,
119+
corazaFilterName string,
120+
tlsPrunedChains, tlsPrunedSecrets, tlsListenersLeftIntact int,
121+
) ProgrammedSet {
122+
keys := map[Family][]string{}
123+
add := func(f Family, k string) { keys[f] = append(keys[f], k) }
124+
125+
for _, rc := range routes {
126+
rcName := rc.GetName()
127+
for _, vh := range rc.GetVirtualHosts() {
128+
vhName := vh.GetName()
129+
for _, rt := range vh.GetRoutes() {
130+
// The identity includes the protection policy governing the route,
131+
// so a route protected by the wrong policy shows up as a mismatch.
132+
if ns, name, mode, ok := datumGatewayTPP(rt); ok {
133+
add(FamilyWAFRoute, wafRouteKey(rcName, vhName, rt.GetName(), ns, name, mode))
134+
}
135+
if isConnectRoute(rt) {
136+
add(FamilyConnectorRoute, connectorRouteKey(rcName, vhName, rt.GetName()))
137+
}
138+
if isOfflineDirectResponse(rt) {
139+
add(FamilyConnectorOffline, connectorRouteKey(rcName, vhName, rt.GetName()))
140+
}
141+
}
142+
}
143+
}
144+
145+
for _, cl := range clusters {
146+
if isReplacedConnectorCluster(cl) {
147+
add(FamilyConnectorCluster, cl.GetName())
148+
}
149+
}
150+
151+
for _, l := range listeners {
152+
lName := l.GetName()
153+
eachHCM(l, func(fcName string, hcm *hcmv3.HttpConnectionManager) {
154+
if corazaFilterName != "" && hcmHasFilterAtZero(hcm, corazaFilterName) {
155+
add(FamilyWAFHCM, listenerChainKey(lName, fcName))
156+
}
157+
if hcm.GetLocalReplyConfig() != nil {
158+
add(FamilyLocalReply, listenerChainKey(lName, fcName))
159+
}
160+
})
161+
}
162+
163+
// Sort and de-duplicate so the output can be compared as a set.
164+
counts := map[Family]int{}
165+
for f := range keys {
166+
keys[f] = sortDedup(keys[f])
167+
counts[f] = len(keys[f])
168+
}
169+
// Removed certificates are recorded by count, since they have no identity.
170+
counts[FamilyTLSPrune] = tlsPrunedChains
171+
172+
return ProgrammedSet{
173+
CapturedAt: time.Now().UTC(),
174+
Keys: keys,
175+
Counts: counts,
176+
TLSPrunedChains: tlsPrunedChains,
177+
TLSPrunedSecrets: tlsPrunedSecrets,
178+
TLSListenersLeftIntact: tlsListenersLeftIntact,
179+
}
180+
}
181+
182+
// programmedSetHandler serves the latest snapshot as JSON. It is a read-only,
183+
// test-only debug endpoint and returns an empty snapshot before the first build.
184+
func (r *programmedRecorder) programmedSetHandler() http.HandlerFunc {
185+
return func(w http.ResponseWriter, _ *http.Request) {
186+
ps := r.snapshot()
187+
w.Header().Set("Content-Type", "application/json")
188+
enc := json.NewEncoder(w)
189+
enc.SetIndent("", " ")
190+
_ = enc.Encode(ps)
191+
}
192+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package server
2+
3+
import (
4+
"sort"
5+
"strings"
6+
7+
clusterv3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3"
8+
listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
9+
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
10+
hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
11+
)
12+
13+
// These read-only scanners extract an identity for each change the build made.
14+
// They mirror, in reverse, how the configuration is written, and they define
15+
// the exact identity format the parity test must reproduce. The "##" separator
16+
// is used because it cannot appear in any of the names it joins.
17+
18+
const keySep = "##"
19+
20+
// wafRouteKey is the identity of a route protected by a firewall:
21+
//
22+
// <routeConfig>##<virtualHost>##<routeName>##<policyNamespace>/<policyName>/<mode>
23+
//
24+
// The governing policy is part of the identity, so a route protected by the
25+
// wrong policy produces a different identity and is caught as a mismatch.
26+
func wafRouteKey(rc, vh, rt, tppNS, tppName, mode string) string {
27+
return strings.Join([]string{rc, vh, rt, tppNS + "/" + tppName + "/" + mode}, keySep)
28+
}
29+
30+
// connectorRouteKey is the identity of a connector route (online or offline):
31+
//
32+
// <routeConfig>##<virtualHost>##<routeName>
33+
func connectorRouteKey(rc, vh, rt string) string {
34+
return strings.Join([]string{rc, vh, rt}, keySep)
35+
}
36+
37+
// listenerChainKey is the identity of a changed listener filter chain:
38+
//
39+
// <listenerName>##<filterChainName>
40+
func listenerChainKey(listener, chain string) string {
41+
return strings.Join([]string{listener, chain}, keySep)
42+
}
43+
44+
// datumGatewayTPP returns the protection policy governing a route (namespace,
45+
// name, mode). ok is false when the route carries no such marker, meaning it is
46+
// not protected.
47+
func datumGatewayTPP(rt *routev3.Route) (ns, name, mode string, ok bool) {
48+
md := rt.GetMetadata()
49+
if md == nil {
50+
return "", "", "", false
51+
}
52+
dg := md.GetFilterMetadata()[datumGatewayMetaKey]
53+
if dg == nil {
54+
return "", "", "", false
55+
}
56+
res := dg.GetFields()["resources"].GetListValue()
57+
if res == nil || len(res.GetValues()) == 0 {
58+
return "", "", "", false
59+
}
60+
f := res.GetValues()[0].GetStructValue().GetFields()
61+
return f["namespace"].GetStringValue(),
62+
f["name"].GetStringValue(),
63+
f["mode"].GetStringValue(),
64+
true
65+
}
66+
67+
// isConnectRoute reports whether a route is an online connector tunnel route.
68+
func isConnectRoute(rt *routev3.Route) bool {
69+
ra := rt.GetRoute()
70+
if ra == nil {
71+
return false
72+
}
73+
for _, uc := range ra.GetUpgradeConfigs() {
74+
if strings.EqualFold(uc.GetUpgradeType(), "CONNECT") {
75+
return true
76+
}
77+
}
78+
return false
79+
}
80+
81+
// isOfflineDirectResponse reports whether a route directly returns the
82+
// tunnel-offline 503 response, covering both the dedicated offline route and
83+
// user-facing routes rewritten to it.
84+
func isOfflineDirectResponse(rt *routev3.Route) bool {
85+
dr := rt.GetDirectResponse()
86+
if dr == nil {
87+
return false
88+
}
89+
if dr.GetStatus() != 503 {
90+
return false
91+
}
92+
return dr.GetBody().GetInlineString() == offlineBodyMarker
93+
}
94+
95+
// offlineBodyMarker is the response body the connector offline path writes,
96+
// duplicated here so the scanner needs no import dependency.
97+
const offlineBodyMarker = "Tunnel not online"
98+
99+
// isReplacedConnectorCluster reports whether a connector cluster has been
100+
// replaced with its tunnel form. A cluster that has not been replaced means the
101+
// substitution failed.
102+
func isReplacedConnectorCluster(cl *clusterv3.Cluster) bool {
103+
if cl.GetType() != clusterv3.Cluster_STATIC {
104+
return false
105+
}
106+
return cl.GetTransportSocket().GetName() == connectorInternalTransport
107+
}
108+
109+
// eachHCM invokes fn for every connection manager across all of the listener's
110+
// filter chains, including the default chain. One that can't be decoded is
111+
// skipped, since recording must never fail the build.
112+
func eachHCM(l *listenerv3.Listener, fn func(chainName string, hcm *hcmv3.HttpConnectionManager)) {
113+
chains := make([]*listenerv3.FilterChain, 0, len(l.GetFilterChains())+1)
114+
chains = append(chains, l.GetFilterChains()...)
115+
if dfc := l.GetDefaultFilterChain(); dfc != nil {
116+
chains = append(chains, dfc)
117+
}
118+
for _, fc := range chains {
119+
for _, f := range fc.GetFilters() {
120+
if f.GetName() != hcmNetworkFilterName {
121+
continue
122+
}
123+
tc := f.GetTypedConfig()
124+
if tc == nil {
125+
continue
126+
}
127+
hcm := &hcmv3.HttpConnectionManager{}
128+
if err := tc.UnmarshalTo(hcm); err != nil {
129+
continue
130+
}
131+
fn(fc.GetName(), hcm)
132+
}
133+
}
134+
}
135+
136+
// hcmHasFilterAtZero reports whether the first filter is named filterName. The
137+
// firewall is always inserted first, so checking the first position is the
138+
// precise signal that it was injected.
139+
func hcmHasFilterAtZero(hcm *hcmv3.HttpConnectionManager, filterName string) bool {
140+
fs := hcm.GetHttpFilters()
141+
return len(fs) > 0 && fs[0].GetName() == filterName
142+
}
143+
144+
// sortDedup returns a sorted, de-duplicated copy of in.
145+
func sortDedup(in []string) []string {
146+
if len(in) == 0 {
147+
return in
148+
}
149+
seen := make(map[string]struct{}, len(in))
150+
out := make([]string, 0, len(in))
151+
for _, s := range in {
152+
if _, ok := seen[s]; ok {
153+
continue
154+
}
155+
seen[s] = struct{}{}
156+
out = append(out, s)
157+
}
158+
sort.Strings(out)
159+
return out
160+
}

0 commit comments

Comments
 (0)