Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 19 additions & 0 deletions components/egress/docs/opentelemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@ Metric export is enabled only when at least one OTLP endpoint is set.

If both are unset, egress keeps metrics local (no OTLP export).

### Automatic Egress Allow Rule

When an OTLP endpoint is configured, egress automatically injects an
always-allow egress rule for the endpoint host (domain or IP, any port), so
telemetry export works under the default deny-all policy without manually
managing allowlist rules. This also covers the egress sidecar's own metric
export, which shares the sandbox network namespace and would otherwise be
blocked by its own egress chain.

- The rule follows the standard precedence: `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`
wins over `OTEL_EXPORTER_OTLP_ENDPOINT`.
- The host is taken from the endpoint URL (`https://host:4318/v1/metrics`),
`host:port`, or bare `host` forms.
- The rule lives in the always-allow layer: it survives user `POST`/`PATCH`/`DELETE`
policy updates and always-rule file reloads. Operators can still block the target
with `deny.always`, which takes precedence.
- Rules are host-scoped (any port), matching the egress rule model; ports are not
enforced per rule.

### Minimal Example

```bash
Expand Down
1 change: 1 addition & 0 deletions components/egress/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func main() {
if err != nil {
log.Fatalf("failed to load always allow/deny rule files: %v", err)
}
alwaysAllow = withTelemetryAllow(alwaysAllow)

allowIPs := allowIps()
mode := parseMode()
Expand Down
2 changes: 2 additions & 0 deletions components/egress/policy_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,8 @@ func (s *policyServer) reloadAlwaysRules() (bool, error) {
if !changed {
return false, nil
}
allow = withTelemetryAllow(allow)
s.setAlwaysRules(deny, allow)
s.proxy.UpdateAlwaysRules(deny, allow)
return true, nil
}
Expand Down
54 changes: 54 additions & 0 deletions components/egress/telemetry_allow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// 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 main

import (
"github.com/alibaba/opensandbox/egress/pkg/log"
"github.com/alibaba/opensandbox/egress/pkg/policy"
inttelemetry "github.com/alibaba/opensandbox/internal/telemetry"
)

// telemetryAllowRules returns an always-allow egress rule for the configured OTLP
// endpoint (OTEL_EXPORTER_OTLP_METRICS_ENDPOINT / OTEL_EXPORTER_OTLP_ENDPOINT), so
// metric export works under the default deny-all policy without operator-provided
// allowlist rules. The rule targets the endpoint host (any port), matching the
// egress rule model. Operators can still block the target via deny.always, which
// takes precedence. Returns nil when no endpoint is configured.
func telemetryAllowRules() []policy.EgressRule {
host, port, ok := inttelemetry.OTLPEndpointHostPort()
if !ok {
return nil
}
rule, err := policy.ParseValidatedEgressRule(policy.ActionAllow, host)
Comment thread
Pangjiping marked this conversation as resolved.
if err != nil {
log.Warnf("telemetry: skipping auto egress allow for OTLP endpoint host %q: %v", host, err)
return nil
}
log.Infof("telemetry: auto-allowing egress to OTLP endpoint %s:%s (deny.always can override)", host, port)
return []policy.EgressRule{rule}
}

// withTelemetryAllow appends the auto-generated OTLP allow rule(s) to the
// always-allow list so every effective-policy merge (startup, policy updates,
// always-file reloads) keeps telemetry egress open.
func withTelemetryAllow(allow []policy.EgressRule) []policy.EgressRule {
rules := telemetryAllowRules()
if len(rules) == 0 {
return allow
}
out := make([]policy.EgressRule, 0, len(allow)+len(rules))
out = append(out, allow...)
return append(out, rules...)
}
90 changes: 90 additions & 0 deletions components/egress/telemetry_allow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// 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 main

import (
"testing"

"github.com/alibaba/opensandbox/egress/pkg/policy"
"github.com/stretchr/testify/require"
)

func TestTelemetryAllowRulesUnconfigured(t *testing.T) {
t.Setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "")
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
require.Nil(t, telemetryAllowRules())

existing := []policy.EgressRule{{Action: policy.ActionAllow, Target: "a.example.com"}}
require.Equal(t, existing, withTelemetryAllow(existing), "no telemetry rules must not mutate the input")
}

func TestTelemetryAllowRulesFromMetricsEndpoint(t *testing.T) {
t.Setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "https://collector.example:4318/v1/metrics")
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
rules := telemetryAllowRules()
require.Len(t, rules, 1)
require.Equal(t, policy.ActionAllow, rules[0].Action)
require.Equal(t, "collector.example", rules[0].Target)

merged := policy.MergeAlwaysOverlay(policy.DefaultDenyPolicy(), nil, rules)
require.Equal(t, policy.ActionAllow, merged.Evaluate("collector.example."), "domain rule must allow DNS resolution")
allowV4, allowV6, _, _ := merged.StaticIPSets()
require.Empty(t, allowV4)
require.Empty(t, allowV6)
}

func TestTelemetryAllowRulesFallbackEndpoint(t *testing.T) {
t.Setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "")
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "otel-collector:4318")
rules := telemetryAllowRules()
require.Len(t, rules, 1)
require.Equal(t, "otel-collector", rules[0].Target)
}

func TestTelemetryAllowRulesIPEndpoint(t *testing.T) {
t.Setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://10.0.0.5:4317")
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
rules := telemetryAllowRules()
require.Len(t, rules, 1)
require.Equal(t, "10.0.0.5", rules[0].Target)

merged := policy.MergeAlwaysOverlay(policy.DefaultDenyPolicy(), nil, rules)
allowV4, allowV6, _, _ := merged.StaticIPSets()
require.Equal(t, []string{"10.0.0.5"}, allowV4, "IP target must land in the static allow v4 set")
require.Empty(t, allowV6)
}

func TestTelemetryAllowRulesInvalidEndpoint(t *testing.T) {
t.Setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://")
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
require.Nil(t, telemetryAllowRules())
}

func TestWithTelemetryAllowAppends(t *testing.T) {
t.Setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "collector.example:4318")
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
existingRule, err := policy.ParseValidatedEgressRule(policy.ActionAllow, "a.example.com")
require.NoError(t, err)
existing := []policy.EgressRule{existingRule}
rules := withTelemetryAllow(existing)
require.Len(t, rules, 2)
require.Equal(t, "a.example.com", rules[0].Target)
require.Equal(t, "collector.example", rules[1].Target)

merged := policy.MergeAlwaysOverlay(policy.DefaultDenyPolicy(), nil, rules)
require.Equal(t, policy.ActionDeny, merged.Evaluate("other.example.com."))
require.Equal(t, policy.ActionAllow, merged.Evaluate("a.example.com."))
require.Equal(t, policy.ActionAllow, merged.Evaluate("collector.example."))
}
80 changes: 80 additions & 0 deletions components/internal/telemetry/endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// 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 telemetry

import (
"net"
"net/url"
"strings"
)

// OTLPEndpointHostPort returns the host and port of the configured OTLP
// endpoint. Endpoint precedence matches the exporters:
// OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, then OTEL_EXPORTER_OTLP_ENDPOINT.
// A missing port falls back to the scheme default (https->443, http->80);
// a bare host:port or host without a scheme is treated as https. ok is false
// when no endpoint is configured or it cannot be parsed.
func OTLPEndpointHostPort() (host, port string, ok bool) {
raw := otlpEndpointFromEnv()
if raw == "" {
return "", "", false
Comment thread
Pangjiping marked this conversation as resolved.
}
return parseOTLPEndpoint(raw)
}

func parseOTLPEndpoint(raw string) (host, port string, ok bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", false
}
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil {
return "", "", false
}
host = strings.TrimSpace(u.Hostname())
if host == "" {
return "", "", false
}
port = u.Port()
if port == "" {
port = defaultPortForScheme(u.Scheme)
}
return host, port, true
}
if h, p, err := net.SplitHostPort(raw); err == nil {
host, port = strings.TrimSpace(h), strings.TrimSpace(p)
Comment thread
Pangjiping marked this conversation as resolved.
Outdated
if host == "" {
return "", "", false
}
return host, port, true
}
host = strings.TrimSpace(raw)
if host == "" {
return "", "", false
}
// No scheme: per OTLP spec the https scheme (port 443) is assumed.
return host, "443", true
}

func defaultPortForScheme(scheme string) string {
switch strings.ToLower(scheme) {
case "https":
return "443"
case "http":
return "80"
}
return ""
}
82 changes: 82 additions & 0 deletions components/internal/telemetry/endpoint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// 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 telemetry

import "testing"

func TestParseOTLPEndpoint(t *testing.T) {
cases := []struct {
name string
raw string
host string
port string
ok bool
}{
{name: "empty", raw: "", ok: false},
{name: "whitespace", raw: " ", ok: false},
{name: "url with port and path", raw: "https://collector.example:4318/v1/metrics", host: "collector.example", port: "4318", ok: true},
{name: "url without port", raw: "https://collector.example/v1/metrics", host: "collector.example", port: "443", ok: true},
{name: "http url without port", raw: "http://collector.example/v1/metrics", host: "collector.example", port: "80", ok: true},
{name: "ip url", raw: "http://10.0.0.1:4317", host: "10.0.0.1", port: "4317", ok: true},
{name: "ipv6 url", raw: "http://[::1]:4318/v1/metrics", host: "::1", port: "4318", ok: true},
{name: "host port", raw: "collector.example:4318", host: "collector.example", port: "4318", ok: true},
{name: "ip port", raw: "10.0.0.1:4318", host: "10.0.0.1", port: "4318", ok: true},
{name: "bare host", raw: "collector.example", host: "collector.example", port: "443", ok: true},
{name: "bare ip", raw: "10.0.0.1", host: "10.0.0.1", port: "443", ok: true},
{name: "scheme only", raw: "http://", ok: false},
{name: "malformed url", raw: "https://:443", ok: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
host, port, ok := parseOTLPEndpoint(tc.raw)
if ok != tc.ok {
t.Fatalf("parseOTLPEndpoint(%q) ok=%v, want %v", tc.raw, ok, tc.ok)
}
if host != tc.host || port != tc.port {
t.Fatalf("parseOTLPEndpoint(%q) = (%q, %q), want (%q, %q)", tc.raw, host, port, tc.host, tc.port)
}
})
}
}

func TestOTLPEndpointHostPortPrecedence(t *testing.T) {
t.Setenv(envOTLPMetricsEndpoint, "")
t.Setenv(envOTLPEndpoint, "")
host, _, ok := OTLPEndpointHostPort()
if ok {
t.Fatal("expected no endpoint when both env vars are unset")
}
if host != "" {
t.Fatalf("expected empty host, got %q", host)
}

t.Setenv(envOTLPEndpoint, "fallback.example:4318")
host, port, ok := OTLPEndpointHostPort()
if !ok || host != "fallback.example" || port != "4318" {
t.Fatalf("fallback endpoint parsed as (%q, %q, %v)", host, port, ok)
}

t.Setenv(envOTLPMetricsEndpoint, "https://primary.example:4317/v1/metrics")
host, port, ok = OTLPEndpointHostPort()
if !ok || host != "primary.example" || port != "4317" {
t.Fatalf("metrics endpoint should win; parsed as (%q, %q, %v)", host, port, ok)
}

t.Setenv(envOTLPMetricsEndpoint, " ")
host, port, ok = OTLPEndpointHostPort()
if !ok || host != "fallback.example" || port != "4318" {
t.Fatalf("blank metrics endpoint should fall back; parsed as (%q, %q, %v)", host, port, ok)
}
}
Loading