Skip to content
Merged
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
57 changes: 43 additions & 14 deletions cmd/gmc/internal/controller/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ const (
dnsPodLabel = "k8s-app"
dnsPodValue = "kube-dns"

// dnsNodeLocalCIDR is the IPv4 link-local block (RFC 3927). On clusters
// running NodeLocal DNSCache (node-local-dns), pods send DNS to a link-local
// address — 169.254.20.10 by the kube-standard `__PILLAR__LOCAL__DNS__`
// convention — served by a hostNetwork DNSCache pod on each node, which the
// kube-dns podSelector cannot match. Allowing the whole 169.254.0.0/16 block
// is the simplest correct rule and stays within Q105's attribution property:
// link-local is non-routable and node-scoped, so it cannot reach an arbitrary
// external resolver — the DNS-exfiltration channel Q105 closed stays closed
// (Q136). See dnsEgressRule.
dnsNodeLocalCIDR = "169.254.0.0/16"

// npProxyName is the NetworkPolicy that restricts proxy pod egress to GitHub CIDRs.
npProxyName = gmcnames.ProxyName
// npAGCName is the NetworkPolicy that gives AGC pods Kubernetes API server access (port 443).
Expand Down Expand Up @@ -201,9 +212,16 @@ func metricsScrapeIngressRule() networkingv1.NetworkPolicyIngressRule {
}

// dnsEgressRule returns a NetworkPolicy egress rule permitting DNS (UDP/TCP 53)
// to the cluster DNS service ONLY — CoreDNS / kube-dns in kube-system — not to
// any destination. It is shared by the proxy, workload, and AGC policies so the
// DNS posture cannot drift between them.
// to the cluster DNS service ONLY — never to any destination. It is shared by
// the proxy, workload, and AGC policies so the DNS posture cannot drift between
// them. Two `To` peers (OR'd) cover the two ways a pod reaches cluster DNS:
//
// 1. The kube-dns / CoreDNS Service in kube-system, matched by an AND of
// namespace + pod selector (the direct path on a cluster without NodeLocal
// DNSCache).
// 2. The IPv4 link-local block 169.254.0.0/16, matched by an ipBlock (the path
// on a cluster running NodeLocal DNSCache, where pods send DNS to a
// link-local address served by a per-node hostNetwork cache — Q136).
//
// An unrestricted port-53 rule (To: nil ≡ any server) is an unattributed
// data-exfiltration side-channel: DNS queries can smuggle data to an
Expand All @@ -212,8 +230,10 @@ func metricsScrapeIngressRule() networkingv1.NetworkPolicyIngressRule {
// egress path forces traffic through the tenant proxy, whose source IPs are
// attributable; confining DNS to the in-cluster resolver keeps it on that
// attributable path — kube-dns recurses upstream on the pod's behalf, so the
// proxy can still resolve GitHub hostnames to do its job. Only the open "any
// resolver" breadth is removed, not legitimate resolution.
// proxy can still resolve GitHub hostnames to do its job. Both peers preserve
// that property: link-local 169.254.0.0/16 is non-routable and node-scoped, so
// it cannot reach an external resolver. Only the open "any resolver" breadth is
// removed, not legitimate resolution.
//
// kindnet does not enforce egress NetworkPolicy (see Q7b in
// docs/plan/worker-egress-proxy.md), so this restriction is guarded at the
Expand All @@ -224,17 +244,26 @@ func dnsEgressRule() networkingv1.NetworkPolicyEgressRule {
proto53UDP := corev1.ProtocolUDP
proto53TCP := corev1.ProtocolTCP
return networkingv1.NetworkPolicyEgressRule{
// A single peer with both selectors set is an AND: kube-dns pods *within*
// kube-system. Splitting them into two peers would be an OR and would also
// admit any pod labelled k8s-app=kube-dns in any namespace.
To: []networkingv1.NetworkPolicyPeer{{
NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{dnsNamespaceLabel: dnsNamespaceValue},
To: []networkingv1.NetworkPolicyPeer{
// A single peer with both selectors set is an AND: kube-dns pods *within*
// kube-system. Splitting them into two peers would be an OR and would also
// admit any pod labelled k8s-app=kube-dns in any namespace.
{
NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{dnsNamespaceLabel: dnsNamespaceValue},
},
PodSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{dnsPodLabel: dnsPodValue},
},
},
PodSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{dnsPodLabel: dnsPodValue},
// NodeLocal DNSCache path: pods reach the per-node hostNetwork cache at a
// link-local address (169.254.20.10 by convention). hostNetwork pods are
// not matched by any pod/namespace selector, so this peer is an ipBlock.
// The block is non-routable, so it does not widen the exfil surface.
{
IPBlock: &networkingv1.IPBlock{CIDR: dnsNodeLocalCIDR},
},
}},
},
Ports: []networkingv1.NetworkPolicyPort{
{Protocol: &proto53UDP, Port: ptr(intstr.FromInt32(53))},
{Protocol: &proto53TCP, Port: ptr(intstr.FromInt32(53))},
Expand Down
49 changes: 35 additions & 14 deletions cmd/gmc/internal/controller/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -596,12 +596,21 @@ func TestBuildNetworkPolicy_DNSEgressAlwaysPresent(t *testing.T) {
}
}

// TestBuildNetworkPolicy_DNSEgressRestrictedToKubeDNS locks in Q105: the port-53
// egress rule on every GMC-managed NetworkPolicy must target the cluster DNS
// service (kube-dns / CoreDNS in kube-system) and must NOT be open (To: nil ≡ any
// resolver). An open DNS path is an unattributed exfiltration side-channel that
// bypasses the per-tenant egress-IP attribution every other egress path enforces.
// This is an authoring/spec-level guard because kindnet does not enforce egress
// TestBuildNetworkPolicy_DNSEgressRestrictedToKubeDNS locks in Q105 + Q136: the
// port-53 egress rule on every GMC-managed NetworkPolicy must target cluster DNS
// only and must NOT be open (To: nil ≡ any resolver). An open DNS path is an
// unattributed exfiltration side-channel that bypasses the per-tenant egress-IP
// attribution every other egress path enforces.
//
// Q136 widened the rule to two OR'd peers so NodeLocal DNSCache clusters resolve:
//
// - the kube-dns / CoreDNS pods in kube-system (AND of namespace + pod
// selector — the direct path), and
// - the link-local block 169.254.0.0/16 (ipBlock — the node-local cache path).
//
// Both peers stay within Q105's attribution property: link-local is non-routable
// and node-scoped, so it cannot reach an external resolver. This is an
// authoring/spec-level guard because kindnet does not enforce egress
// NetworkPolicy (see Q7b) — mirroring the egress-negative guard pattern.
func TestBuildNetworkPolicy_DNSEgressRestrictedToKubeDNS(t *testing.T) {
ag := newTestAG("gateway", "team-a")
Expand All @@ -624,16 +633,28 @@ func TestBuildNetworkPolicy_DNSEgressRestrictedToKubeDNS(t *testing.T) {
rule := dnsRules[0]
require.NotEmpty(t, rule.To,
"%s port-53 rule must have a To peer — an empty To opens DNS to any resolver (Q105)", np.Name)
require.Len(t, rule.To, 1, "%s DNS rule should select kube-dns via a single peer", np.Name)

peer := rule.To[0]
assert.Nil(t, peer.IPBlock, "%s DNS peer must not be an ipBlock", np.Name)
require.NotNil(t, peer.NamespaceSelector, "%s DNS peer must select the kube-dns namespace", np.Name)
require.NotNil(t, peer.PodSelector, "%s DNS peer must select the kube-dns pods", np.Name)
assert.Equal(t, dnsNamespaceValue, peer.NamespaceSelector.MatchLabels[dnsNamespaceLabel],
require.Len(t, rule.To, 2,
"%s DNS rule must select kube-dns AND the node-local link-local block (Q136)", np.Name)

// Peer 1: the cluster DNS Service (kube-dns / CoreDNS) selector peer.
kubeDNS := rule.To[0]
assert.Nil(t, kubeDNS.IPBlock, "%s kube-dns DNS peer must not be an ipBlock", np.Name)
require.NotNil(t, kubeDNS.NamespaceSelector, "%s DNS peer must select the kube-dns namespace", np.Name)
require.NotNil(t, kubeDNS.PodSelector, "%s DNS peer must select the kube-dns pods", np.Name)
assert.Equal(t, dnsNamespaceValue, kubeDNS.NamespaceSelector.MatchLabels[dnsNamespaceLabel],
"%s DNS peer namespace selector must match kube-system", np.Name)
assert.Equal(t, dnsPodValue, peer.PodSelector.MatchLabels[dnsPodLabel],
assert.Equal(t, dnsPodValue, kubeDNS.PodSelector.MatchLabels[dnsPodLabel],
"%s DNS peer pod selector must match k8s-app=kube-dns", np.Name)

// Peer 2: the NodeLocal DNSCache link-local ipBlock (Q136).
nodeLocal := rule.To[1]
require.NotNil(t, nodeLocal.IPBlock, "%s DNS rule must allow the node-local link-local block (Q136)", np.Name)
assert.Nil(t, nodeLocal.NamespaceSelector, "%s node-local DNS peer must be a bare ipBlock", np.Name)
assert.Nil(t, nodeLocal.PodSelector, "%s node-local DNS peer must be a bare ipBlock", np.Name)
assert.Equal(t, "169.254.0.0/16", nodeLocal.IPBlock.CIDR,
"%s node-local DNS peer must be the link-local block 169.254.0.0/16", np.Name)
assert.Empty(t, nodeLocal.IPBlock.Except,
"%s node-local DNS ipBlock must not carve out exceptions", np.Name)
}
}

Expand Down
25 changes: 18 additions & 7 deletions cmd/gmc/test/e2e/provisioning_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package e2e
import (
"fmt"
"os/exec"
"regexp"
"time"

. "github.com/onsi/ginkgo/v2"
Expand Down Expand Up @@ -334,8 +335,10 @@ spec:
Expect(workloadYAML).NotTo(MatchRegexp(`(?m)^\s*ingress:`),
"workload NP must carry no ingress rules — an ingress: block means inbound was allowed to worker pods (Q128):\n%s", workloadYAML)

// DNS egress rule: port 53 on both UDP and TCP, no `to:` peers
// (allows DNS to any destination).
// DNS egress rule: port 53 on both UDP and TCP. DNS is confined to
// cluster DNS (kube-dns / CoreDNS in kube-system) plus the node-local
// link-local block — not "any resolver" (Q105/Q136); the peer shape is
// asserted below.
Expect(workloadYAML).To(MatchRegexp(`(?s)port:\s*53\b.*protocol:\s*UDP`),
"workload NP missing DNS UDP egress rule:\n%s", workloadYAML)
Expect(workloadYAML).To(MatchRegexp(`(?s)port:\s*53\b.*protocol:\s*TCP`),
Expand All @@ -350,11 +353,19 @@ spec:
Expect(workloadYAML).To(MatchRegexp(`(?s)port:\s*8080.*podSelector:.*matchLabels:.*app:\s*actions-gateway-proxy`),
"workload NP port-8080 egress rule missing podSelector app=actions-gateway-proxy (regression: rule broadened to any destination):\n%s", workloadYAML)

// The workload NP must NOT contain any egress to GitHub CIDRs — that
// is the proxy NP's job. The most likely regression is an ipBlock
// peer (any IPv4 cidr) appearing in the workload egress.
Expect(workloadYAML).NotTo(ContainSubstring("ipBlock:"),
"workload NP must not list any ipBlock egress peers (GitHub CIDRs belong on the proxy NP):\n%s", workloadYAML)
// The workload NP must NOT contain egress to GitHub CIDRs — that is the
// proxy NP's job. The only ipBlock permitted is the link-local block
// 169.254.0.0/16 used for NodeLocal DNSCache DNS egress (Q136): it is
// non-routable and node-scoped, so it cannot reach GitHub or an external
// resolver and preserves the per-tenant egress-IP attribution (Q105).
// Any other (routable) cidr — e.g. a GitHub CIDR leaking onto the
// workload NP — is a regression.
Expect(workloadYAML).To(ContainSubstring("cidr: 169.254.0.0/16"),
"workload NP missing the node-local DNS link-local ipBlock 169.254.0.0/16 (Q136):\n%s", workloadYAML)
for _, m := range regexp.MustCompile(`cidr:\s*(\S+)`).FindAllStringSubmatch(workloadYAML, -1) {
Expect(m[1]).To(Equal("169.254.0.0/16"),
"workload NP egress has an unexpected ipBlock cidr %q — only the node-local DNS link-local block is allowed; GitHub CIDRs belong on the proxy NP (Q136):\n%s", m[1], workloadYAML)
}

By("dumping the AGC NetworkPolicy as YAML")
agcYAML, err := utils.Run(exec.Command("kubectl", "get", "networkpolicy", agcName,
Expand Down
1 change: 0 additions & 1 deletion docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ Specific actionable items in priority order. Pick from the top; skip 🚫 items
| <a id="Q131"></a>Q131 | Flake: TestListener_IdleNotShutdownIfLast poll-count timing | `tests` `bug` | 🔲 | S | goroutine_test.go:419 asserted poll count ≥5 but got 3 ("poll past threshold when last listener") on a local `make check`; passed on rerun (-count=3) w/o code change. Timing-sensitive idle-shutdown test; tighten synchronization, not the count. |
| <a id="Q113"></a>Q113 | Flake: eviction integration tests time out in waitForWorkerPod | `tests` `bug` | 🔲 | S | EvictionTriggersRequeue + EvictionBudgetExhausted (failure_recovery_test.go:142) timed out (20s) on CI run 27383065643, passed on rerun w/o code change. Suspects: sessions[len-1] pick on shared brokerStub; 20s budget on 2-vCPU runner. |
| <a id="Q120"></a>Q120 | Flake: SIGTERM integration test misses session-delete budget | `tests` `bug` | 🔲 | S | TestAGC_SIGTERM_DeletesAllSessions: session-39 missed the 10s WaitForSessionDelete on CI run 27422248358 (PR 209), passed on rerun w/o code change. ~40 concurrent teardowns on a 2-vCPU runner; same shared-brokerStub/budget class as Q113. |
| <a id="Q136"></a>Q136 | node-local-dns (NodeLocal DNSCache) support for tenant DNS egress | `security` `infra` `1.0-gate` | 🔲 | S | Q105 DNS rule (kube-dns podSelector) breaks NodeLocal DNSCache: pods query link-local 169.254.20.10 (hostNetwork pod) → dropped on enforcing CNI, incl proxy. Fix: also allow port-53 to 169.254.0.0/16 (link-local, non-routable, keeps Q105). |
| <a id="Q137"></a>Q137 | AGC RunnerGroup not re-reconciled after a non-retriable baseline-listener exit | `bug` `infra` | 🔲 | S | runnergroup_controller.go returns RequeueAfter=reapAfter (0 with no worker pods); if the permanent baseline exits non-retriably the L317 restart only fires on the next watch event — up to the 10h resync. Requeue when ActiveCount<desired. |
| <a id="Q138"></a>Q138 | Bounded-by-default HTTP clients — retrofit http.DefaultClient fallbacks + lint gate | `infra` `bug` `tests` | 🔲 | M | ~8 prod clients default to http.DefaultClient (no read timeout); a slow peer wedges the goroutine (Q134 class). Add a bounded-by-default httpx client, make long-poll the explicit exception, and gate new uses with forbidigo+noctx. |
| <a id="Q98"></a>Q98 | Helm chart distribution/publishing pipeline | `infra` `1.0-gate` | 🔲 | M | Pipeline shipped: publish.yml chart-publish job packages, pushes (oci://ghcr/charts), and cosign-signs the chart. Remaining (first v* tag): live publish proof, flip prerelease annotation, oci:// in upgrade.md/README, Artifact Hub listing. |
Expand Down
Loading
Loading