Skip to content

refactor(networkpolicy): consolidate egress destination parsing - #5223

Open
tianfeng92 wants to merge 8 commits into
tigera:masterfrom
tianfeng92:EV-6963-consolidate-egress-parsing
Open

refactor(networkpolicy): consolidate egress destination parsing#5223
tianfeng92 wants to merge 8 commits into
tigera:masterfrom
tianfeng92:EV-6963-consolidate-egress-parsing

Conversation

@tianfeng92

@tianfeng92 tianfeng92 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Description

ParseExternalDestination and ExternalDestinationEntityRule already turn an endpoint into the tightest egress rule available. Only the OTel Collector used them. Dex and Guardian carried their own copies; both now go through the shared pair.

Dex's parseHostPortFromURL was a duplicate of pkg/url.ParseHostPortFromHTTPProxyURL, which Guardian already used, so it is deleted rather than reimplemented.

Preserved: Dex's 0.0.0.0/0 and ::/0 any-destination rules, and its unconditional domain rule — it has no license input.

Changed: a <svc>.<ns>.svc destination now renders a Services match instead of a Domains rule, so it follows the Service's own ports. A Domains rule never worked for a ClusterIP. Only the domain branch needs egress-access-control; a Services match does not.

Testing

  • lint clean; render, enterprise, clusterconnection and authentication suites pass
  • new test covers an unlicensed in-cluster destination
  • draft until verified on a cluster: Dex reaching its IdP, Guardian's tunnel, with and without a proxy

Release Note

None

EV-6963

@marvin-tigera marvin-tigera added this to the v1.45.0 milestone Aug 19, 2026
@tianfeng92 tianfeng92 changed the title [Operator][Dex][Guardian] Consolidate egress destination parsing refactor(networkpolicy): consolidate egress destination parsing Aug 19, 2026
@tianfeng92
tianfeng92 marked this pull request as ready for review August 19, 2026 18:49
@tianfeng92
tianfeng92 requested a review from a team as a code owner August 19, 2026 18:49
Copilot AI lite review requested due to automatic review settings August 19, 2026 18:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors egress-destination parsing and rule construction to use shared helpers, removing duplicate logic across components while preserving existing licensing and behavior constraints for Dex and Guardian.

Changes:

  • Dex: replaces local host/port extraction and destination parsing with pkg/url.ParseHostPortFromHTTPProxyURL and networkpolicy.ParseExternalDestination + ExternalDestinationEntityRule.
  • Guardian: replaces inline tunnel-destination rule building with the shared parsing/rule helpers while preserving the “skip port-only allow when unlicensed” behavior.
  • Tests: adds coverage ensuring an in-cluster <svc>.<ns>.svc tunnel destination renders as a Service match even without the license feature.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
pkg/render/dex.go Removes duplicated URL/egress destination parsing and uses shared helpers for consistent rule rendering.
pkg/enterprise/clusterconnection/guardian.go Consolidates tunnel destination rule generation via shared parsing/entity-rule helpers while preserving licensing behavior.
pkg/enterprise/clusterconnection/guardian_render_test.go Adds a regression test for Service-match rendering of in-cluster tunnel destinations without the license feature.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/enterprise/clusterconnection/guardian.go
Comment thread pkg/enterprise/clusterconnection/guardian.go
Comment thread pkg/enterprise/clusterconnection/guardian.go Outdated
Comment thread pkg/enterprise/clusterconnection/guardian.go Outdated
Comment thread pkg/render/dex.go Outdated
tianfeng92 and others added 2 commits August 21, 2026 10:51
Dex, Guardian and the OTel Collector each turned an endpoint into an egress
rule. ParseExternalDestination and ExternalDestinationEntityRule already do
that, and only the collector used them.

Dex loses parseHostPortFromURL, a duplicate of pkg/url's
ParseHostPortFromHTTPProxyURL that Guardian already used, and builds its
specific-destination rules through the shared pair. Guardian does the same for
the tunnel destination. Its copy had moved to pkg/enterprise/clusterconnection
since the original review.

Two behaviours are kept rather than folded into the helper's defaults. Guardian
skips a named destination when the EgressAccessControl feature is absent
instead of falling back to port-only: the trailing Pass rule already governs
the tunnel, and a port-only allow would be wider than what ships today. Dex
renders the domain rule unconditionally because it has no license input, and
narrowing it would change IdP egress.

Dex's 0.0.0.0/0 and ::/0 any-destination rules are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review catch. The consolidation gated the whole rule on
EgressAccessControl whenever the destination was not a literal IP, but
ExternalDestinationEntityRule has three branches and only the domain one needs
the feature. An in-cluster management address renders a service match, which
does not, so an unlicensed cluster lost the rule for its own tunnel.

Ask for the rule with the feature we actually have and skip it only when that
left nothing to match on. Covered by a test that fails against the previous
gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 21, 2026 17:55
@tianfeng92
tianfeng92 force-pushed the EV-6963-consolidate-egress-parsing branch from 58fe5e5 to 458b68c Compare August 21, 2026 17:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Comment thread pkg/render/common/networkpolicy/networkpolicy.go
Comment thread pkg/render/otelcollector/component.go
Comment thread pkg/enterprise/clusterconnection/guardian.go Outdated
Restructures the helper the way the review asked: EntityRuleForDestination
parses with ParseHostPort and builds with EntityRuleForHostPort, so a component
holding a host:port string makes one call and the ExternalDestination type is
gone. manager.go and k8s-endpoint.go now build their rules the same way.

The helper no longer fails open. It always constrains the destination, and a
component that cannot use the rule decides what to do rather than inheriting a
ports-only allow: Guardian drops a domain rule it has no licence for, which
also retires the guard that existed to detect the fail-open.

Service matching is strict. The host must be <service>.<namespace>.svc, with
the cluster domain and a trailing dot optional, so an external name that merely
contains an svc label is left as a domain. That needs the cluster domain to
disambiguate, which is why it is threaded through; Guardian and the apiserver
endpoint pass none, since neither destination is in-cluster.

Port parsing is back on numorstring.PortFromString, and ParseHostPort refuses a
URL rather than quietly taking the scheme's default port -- Guardian's field is
documented as host:port, and a URL there was silently becoming 443.

A proxy value Dex cannot parse now costs only its own rule. It used to abandon
the whole pod before the catch-alls were appended, so one bad string removed
that pod's IdP egress entirely.

Adds the first tests for this package, covering the strict matching and the
absence of a ports-only rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 21, 2026 18:02
@tianfeng92
tianfeng92 force-pushed the EV-6963-consolidate-egress-parsing branch from 458b68c to 224963e Compare August 21, 2026 18:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (1)

pkg/render/common/networkpolicy/networkpolicy.go:353

  • ParseHostPort can successfully parse destinations like ":443" (net.SplitHostPort allows an empty host) and return an empty host. That can later produce an invalid EntityRule (e.g., Domains: [""]). Reject empty-host destinations explicitly so misconfigurations fail loudly.
func ParseHostPort(destination string) (string, numorstring.Port, error) {
	host, portStr, err := net.SplitHostPort(destination)
	if err != nil {
		return "", numorstring.Port{}, err
	}
	port, err := numorstring.PortFromString(portStr)
	if err != nil {
		return "", numorstring.Port{}, err
	}
	return host, port, nil

Copilot AI review requested due to automatic review settings August 21, 2026 18:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (4)

Previously missed (3) — in code that hasn't changed since the last review.

pkg/render/otelcollector/component.go:853

  • exporterDestination claims to default the port from the URL scheme, but when the endpoint has no explicit port it always defaults to 443. If a stale CRD ever allows non-https endpoints, this will silently pick the wrong port (and the comment is inaccurate). Consider defaulting based on u.Scheme and erroring on unknown schemes so the caller can safely skip the rule.
	portStr := u.Port()
	if portStr == "" {
		// https is the only scheme the API accepts.
		portStr = "443"
	}

pkg/render/otelcollector/component.go:859

  • EntityRuleForHostPort always returns a Domains match for hostnames. The OpenTelemetry collector rendering no longer carries a license/feature flag (EgressAccessControl) to decide whether Domains rules are allowed, so an otel-collector-only license could cause the operator to render NetworkPolicy objects that are rejected/unsupported (previously this was handled via DomainEgressAllowed). Consider reintroducing a domain-egress feature gate (or dropping/errored rules when dest.Domains is set but the feature is inactive).
		return v3.EntityRule{}, err
	}
	return networkpolicy.EntityRuleForHostPort(host, clusterDomain, port), nil
}

pkg/render/common/networkpolicy/networkpolicy_test.go:112

  • ParseHostPort has unit tests for URL and bare-host inputs, but it doesn't currently cover the empty-host case (e.g. ":9449"), which net.SplitHostPort accepts. Adding a test will prevent regressions around accidentally producing empty-domain rules.
		It("rejects a bare host", func() {
			_, _, err := networkpolicy.ParseHostPort("mgmt.example.com")
			Expect(err).To(HaveOccurred())
		})

pkg/render/common/networkpolicy/networkpolicy.go:349

  • ParseHostPort currently accepts destinations like ":9449" (empty host) because net.SplitHostPort allows it. That can flow into EntityRuleForHostPort and produce a Domains rule containing an empty string, which is likely invalid and can cause confusing failures later. Reject empty hosts explicitly here.
	host, portStr, err := net.SplitHostPort(destination)
	if err != nil {
		return "", numorstring.Port{}, err
	}
	port, err := numorstring.PortFromString(portStr)

Copilot AI review requested due to automatic review settings August 21, 2026 18:36
@tianfeng92
tianfeng92 force-pushed the EV-6963-consolidate-egress-parsing branch from a1aa678 to 79f0b26 Compare August 21, 2026 18:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (2)

pkg/render/common/networkpolicy/networkpolicy.go:353

  • ParseHostPort accepts ":" (empty host) because net.SplitHostPort returns host=="" without error. That flows into EntityRuleForHostPort and produces Domains: [""] (or other invalid rules), which can later cause render/apply failures or unintended behavior. Reject empty hosts explicitly.
func ParseHostPort(destination string) (string, numorstring.Port, error) {
	host, portStr, err := net.SplitHostPort(destination)
	if err != nil {
		return "", numorstring.Port{}, err
	}
	port, err := numorstring.PortFromString(portStr)
	if err != nil {
		return "", numorstring.Port{}, err
	}
	return host, port, nil

pkg/render/otelcollector/component.go:875

  • This comment claims Validate rejects endpoints that exporterDestination cannot parse, but OpenTelemetrySpec.Validate currently only enforces an https:// prefix (api/v1/logcollector_types.go:344-349) and does not validate URL parsing or port correctness. That means malformed endpoints (e.g., bad port) will hit this path and be silently skipped, making exports fail without a clear configuration error. At minimum, update the comment to reflect reality; ideally tighten Validate (or fail rendering) so users get an actionable error instead of a silent skip.
		dest, err := exporterDestination(exp, c.cfg.ClusterDomain)
		if err != nil {
			// Validate rejects an endpoint this cannot parse, so reaching here means
			// a stale CRD let one through. Skip the rule rather than widening it:
			// the default-deny then blocks that exporter, which is the safe failure.
			continue
		}

…st callers

Service matching now requires a cluster domain. Passing none gives the plain
host-to-rule behaviour -- an exact net for a literal IP, otherwise the domain --
so manager.go and k8s-endpoint.go can share the helper without their rules
changing shape. A Services match cannot carry ports, and both of those callers
have ports they need to keep: the two standard LDAP ports, and the apiserver's.

Only the OTel collector opts in, which is where in-cluster exporters are
expected and where that behaviour already shipped.

Guardian's in-cluster test went with it: it asserted a service match that only
held while the branch was unconditional. Replaced with the property that does
hold -- a literal IP is pinned to an exact net whether or not the cluster is
licensed for domain egress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 21, 2026 20:59
@tianfeng92
tianfeng92 force-pushed the EV-6963-consolidate-egress-parsing branch from 79f0b26 to d91a580 Compare August 21, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

pkg/render/otelcollector/component.go:868

  • When exporterDestination fails to parse an exporter endpoint, the error is silently ignored (continue) and no egress rule is rendered. That can leave exports blocked by default-deny with no signal in operator logs about why the destination was skipped.
		dest, err := exporterDestination(exp, c.cfg.ClusterDomain)
		if err != nil {
			continue
		}

Comment thread pkg/render/otelcollector/component.go Outdated
@tianfeng92
tianfeng92 requested a review from pasanw August 24, 2026 16:08
Comment thread pkg/render/otelcollector/component.go Outdated
Comment thread pkg/render/dex.go Outdated
Comment thread pkg/render/common/networkpolicy/networkpolicy.go Outdated
Comment thread pkg/render/dex.go Outdated
Comment thread pkg/render/otelcollector/component.go
Action: v3.Allow,
Protocol: &networkpolicy.TCPProtocol,
Destination: networkpolicy.ExternalDestinationEntityRule(exporterDestination(exp), c.cfg.DomainEgressAllowed),
Destination: dest,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we dropped the domain egress check, could we add it back into this function similar to how it is done in guardian (if len(dest.Domains) > 0 && !gpc.IncludeEgressNetworkPolicy...)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dex keeps its own parseHostPortFromURL. The shared HTTP-proxy helper rejects
any non-http(s) scheme, but Dex may be given a socks proxy, which the local
version handles when the URL carries a port.

Proxy values are no longer logged raw. HTTP_PROXY and HTTPS_PROXY can carry
credentials, so a parse failure logged the username and password; it now logs
the redacted URL, or nothing when the value could not be parsed enough to
redact it.

The exporter port comes from the URL scheme rather than a hard-coded 443, and
an unexpected scheme is an error.

OTel drops a domain rule it has no egress-access-control licence for, as
Guardian does, and logs at error level when an exporter yields no rule instead
of skipping silently. Restoring that gate also restored DomainEgressAllowed on
the configuration, which the controller had stopped populating -- without it
every domain rule would have been dropped in production while the tests passed.

Every caller now passes a cluster domain, so an in-cluster destination written
as a full service FQDN is matched by service. ServiceEndpoint is the exception:
it is a package-level value built from environment variables before any
controller exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 26, 2026 18:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment thread pkg/render/manager.go
Comment on lines 1373 to +1377
// specify a non-standard port; scoping the port too would risk denying a
// valid config), so only the host is narrowed.
dest := v3.EntityRule{Ports: networkpolicy.Ports(389, 636)}
if host := ldapEgressHost(c.cfg.Authentication.Spec.LDAP.Host); host != "" {
if ip := net.ParseIP(host); ip != nil {
suffix := "/32"
if ip.To4() == nil {
suffix = "/128"
}
dest.Nets = []string{ip.String() + suffix}
} else {
dest.Domains = []string{host}
}
dest = networkpolicy.EntityRuleForHostPort(host, c.cfg.ClusterDomain, networkpolicy.Ports(389, 636)...)
Comment thread pkg/render/dex.go
Comment on lines +611 to +613
// parseHostPortFromURL is kept local to Dex rather than using the shared
// HTTP-proxy helper: Dex may be configured with a socks proxy, which the shared
// helper rejects outright but which works here when the URL carries a port.
Expect(np.Spec.Types).To(ConsistOf(v3.PolicyTypeIngress, v3.PolicyTypeEgress))
})

It("should parse the port from bare host:port exporter endpoints", func() {
Deriving the port from a URL scheme sat in the OTel component, which put the
rule in a component instead of alongside the host:port entry point where the
next URL-based caller would look for it.

EntityRuleForURL joins ParseHostPort in the shared package: callers whose field
is host:port use one, callers whose field is a URL use the other, and https to
443 is defined once. exporterDestination is now a call into it, and the
component no longer imports net/url or numorstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 26, 2026 19:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

pkg/render/manager.go:1374

  • The LDAP/AD egress comment no longer matches the implementation: EntityRuleForHostPort can now render an in-cluster Service match (dropping ports) when Authentication.spec.ldap.host is ..svc[.]. Please update the comment so it accurately describes the possible destination rule types (service, exact net, or domain).
		// being configured on the Authentication CR. The destination is scoped to
		// Authentication.spec.ldap.host — a domain match for a hostname or a
		// /32/128 for a literal IP. Both standard ports stay open (the host may
		// specify a non-standard port; scoping the port too would risk denying a
		// valid config), so only the host is narrowed.

pkg/render/dex.go:614

  • The PR description says Dex's parseHostPortFromURL was deleted in favor of pkg/url.ParseHostPortFromHTTPProxyURL, but this PR still keeps a local parseHostPortFromURL implementation. Please reconcile the PR description with the actual approach (either update the description, or switch to the shared helper if that’s the intended end state).
// parseHostPortFromURL is kept local to Dex rather than using the shared
// HTTP-proxy helper: Dex may be configured with a socks proxy, which the shared
// helper rejects outright but which works here when the URL carries a port.
func parseHostPortFromURL(url *url.URL) (string, error) {

ServiceEndpoint was the last caller sending an empty cluster domain, so a
management or apiserver address written as a service FQDN was treated as an
external host. The blocker looked like the package-level value built from
environment variables, but every caller of DestinationEntityRule has a cluster
domain to hand: apiserver and kube-controllers carry one on their config, and
the non-cluster-host Typha policy gets one from render.Inputs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 26, 2026 19:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

pkg/render/dex.go:618

  • parseHostPortFromURL returns host:port strings for proxy URLs. The default-port branch (the switch below) uses net.JoinHostPort(url.Host, ...) which can double-bracket IPv6 hosts because url.Host may already include brackets (e.g. "[2001:db8::1]"). Consider using url.Hostname() when joining so IPv6 proxy URLs without an explicit port are rendered correctly.
func parseHostPortFromURL(url *url.URL) (string, error) {
	if url.Port() != "" {
		return url.Host, nil
	}

pkg/render/dex.go:613

  • PR description says Dex's parseHostPortFromURL was deleted in favor of a shared helper, but this change keeps a Dex-local parseHostPortFromURL (and even adds a comment explaining why). Consider updating the PR description so it matches the implementation and review/testing expectations.
// parseHostPortFromURL is kept local to Dex rather than using the shared
// HTTP-proxy helper: Dex may be configured with a socks proxy, which the shared
// helper rejects outright but which works here when the URL carries a port.

Comment thread pkg/render/manager.go
Comment on lines 1375 to 1379
dest := v3.EntityRule{Ports: networkpolicy.Ports(389, 636)}
if host := ldapEgressHost(c.cfg.Authentication.Spec.LDAP.Host); host != "" {
if ip := net.ParseIP(host); ip != nil {
suffix := "/32"
if ip.To4() == nil {
suffix = "/128"
}
dest.Nets = []string{ip.String() + suffix}
} else {
dest.Domains = []string{host}
}
dest = networkpolicy.EntityRuleForHostPort(host, c.cfg.ClusterDomain, networkpolicy.Ports(389, 636)...)
}
egressRules = append(egressRules, v3.Rule{
…lper

Every caller now passes a cluster domain, so the helper no longer treats
Service matching as opt-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 26, 2026 20:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

pkg/render/dex.go:617

  • parseHostPortFromURL returns url.Host when a port is present, but for the no-port case it later uses net.JoinHostPort(url.Host, ...). For IPv6 literals, url.Host includes brackets, so JoinHostPort will produce an invalid "[[...]]:port". Use url.Hostname() (bracket-free) when constructing host:port strings so IPv6 proxy URLs are handled correctly.
func parseHostPortFromURL(url *url.URL) (string, error) {
	if url.Port() != "" {
		return url.Host, nil
	}

pkg/render/dex.go:613

  • The PR description says Dex's parseHostPortFromURL was deleted in favor of a shared helper, but this PR keeps a local parseHostPortFromURL implementation. Please update the PR description to match the implementation, or switch Dex to the shared helper if that was the intent.
// parseHostPortFromURL is kept local to Dex rather than using the shared
// HTTP-proxy helper: Dex may be configured with a socks proxy, which the shared
// helper rejects outright but which works here when the URL carries a port.

@tianfeng92
tianfeng92 requested a review from pasanw August 26, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants