This is an automated email from the ASF dual-hosted git repository.
mrproliu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-rover.git
The following commit(s) were added to refs/heads/main by this push:
new 9ea70d3 Enhance log module and enhance address resolver in access log
module (#213)
9ea70d3 is described below
commit 9ea70d387712770584a412587782ce462428763d
Author: mrproliu <[email protected]>
AuthorDate: Wed Jul 15 23:01:56 2026 +0800
Enhance log module and enhance address resolver in access log module (#213)
---
.gitignore | 4 +-
.golangci.yml | 11 +
CHANGES.md | 8 +
bpf/accesslog/ambient/ztunnel.c | 58 ++-
bpf/accesslog/syscalls/connect_conntrack.c | 6 +-
configs/rover_configs.yaml | 5 +
pkg/accesslog/collector/collector.go | 3 -
pkg/accesslog/collector/connection.go | 16 +-
pkg/accesslog/collector/ztunnel.go | 528 +++++++++++++++++++--
pkg/accesslog/collector/ztunnel_accesslog.go | 301 ++++++++++++
pkg/accesslog/collector/ztunnel_accesslog_test.go | 130 +++++
pkg/accesslog/collector/ztunnel_admin.go | 296 ++++++++++++
pkg/accesslog/collector/ztunnel_flush_test.go | 170 +++++++
pkg/accesslog/collector/ztunnel_resolution_test.go | 109 +++++
pkg/accesslog/common/connection.go | 347 +++++++++++++-
pkg/accesslog/common/connection_resolution_test.go | 105 ++++
pkg/accesslog/runner.go | 12 +
pkg/logger/logger.go | 56 ++-
pkg/logger/logger_test.go | 142 ++++++
pkg/logger/settings.go | 38 +-
pkg/process/finders/kubernetes/template.go | 10 +
pkg/tools/btf/linker.go | 7 +
pkg/tools/host/file.go | 19 +-
pkg/tools/ip/conntrack.go | 32 +-
pkg/tools/ip/tcpresolver.go | 3 +
pkg/tools/netns/netns.go | 77 +++
pkg/tools/netns/netns_test.go | 173 +++++++
27 files changed, 2575 insertions(+), 91 deletions(-)
diff --git a/.gitignore b/.gitignore
index 56cd228..8d6f6f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,6 @@ pkg/**/bpf_*.go
pkg/tools/btf/files/**/*.btf
-.config
\ No newline at end of file
+.config.omc/
+.omc/
+.config/
diff --git a/.golangci.yml b/.golangci.yml
index d66521a..13aa755 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -78,6 +78,17 @@ linters:
- third_party$
- builtin$
- examples$
+ rules:
+ # test code is held to looser style rules: table-driven tests naturally
repeat
+ # literal fixture values, use wide data rows, and keep fixed-argument
helpers for
+ # readability, which the style linters below would otherwise flag.
+ - path: _test\.go
+ linters:
+ - goconst
+ - gocyclo
+ - lll
+ - unparam
+ - dupl
formatters:
enable:
- gofmt
diff --git a/CHANGES.md b/CHANGES.md
index 63f97d4..1f2ca99 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -32,11 +32,19 @@ Release Notes.
* Aggregate dropped perf event sample warnings periodically instead of logging
every burst.
* Lower the log level for connection query errors of short-lived exited
processes.
* Enable pprof by default but bind it to 127.0.0.1 so it is not exposed on the
network.
+* Support collecting the ztunnel outbound connection mappings from the ztunnel
admin config dump, as a symbol-independent fallback of the uprobe based events
in the access log module.
+* Support cross-checking the ztunnel proxied connection count from the ztunnel
prometheus metrics in the access log module.
+* Report a periodic info-level summary of un-resolved remote addresses,
including the conntrack and ztunnel correlation statistics, in the access log
module.
+* Attach all matched ztunnel `track_outbound` symbol copies when attaching the
uprobe in the access log module.
+* Add success/failure counters to the conntrack real peer address queries.
#### Bug Fixes
* Fix the base image cannot run in the arm64.
* Fix process fork tracepoint reporting thread TID instead of process TGID,
causing repeated process detected/dead churn.
* Fix panic in the access log module when handling HTTP/2 streams without a
body.
+* Fix the ztunnel event reader and process finder not being registered when
the ztunnel process starts after the rover.
+* Fix ghost connections created by failed accept syscalls with a negative
socket fd in the access log module.
+* Remove the ineffective `ctnetlink_fill_info` kprobe and fix an always-true
condition in the conntrack BPF program.
#### Documentation
* Add a dead link checker in the CI.
diff --git a/bpf/accesslog/ambient/ztunnel.c b/bpf/accesslog/ambient/ztunnel.c
index e1fc44a..ba46060 100644
--- a/bpf/accesslog/ambient/ztunnel.c
+++ b/bpf/accesslog/ambient/ztunnel.c
@@ -41,12 +41,64 @@ int connection_manager_track_outbound(struct pt_regs* ctx) {
return 0;
}
bool success = true;
- success = get_socket_addr_ip_in_ztunnel(success, (void
*)PT_REGS_PARM3(ctx), &event->orginal_src_ip, &event->src_port);
- success = get_socket_addr_ip_in_ztunnel(success, (void
*)PT_REGS_PARM4(ctx), &event->original_dst_ip, &event->dst_port);
- success = get_socket_addr_ip_in_ztunnel(success, (void
*)PT_REGS_PARM5(ctx), &event->lb_dst_ip, &event->lb_dst_port);
+ // track_outbound(&self, src, original_dst, actual_dst) returns a large
ConnectionResult
+ // struct via a hidden sret pointer. On x86-64 SysV the sret pointer
occupies the first
+ // integer arg register(PARM1), so &self is PARM2 and the three SocketAddr
args are PARM3/4/5.
+ // On AArch64 AAPCS64 the sret pointer is passed in x8, which is OUTSIDE
the PARM1..8 arg
+ // registers, so the arguments are NOT shifted: &self is PARM1 and the
args are PARM2/3/4.
+ // original_dst is the service ClusterIP, actual_dst is the load balanced
real pod.
+#if defined(bpf_target_x86)
+ void *src_arg = (void *)PT_REGS_PARM3(ctx);
+ void *original_dst_arg = (void *)PT_REGS_PARM4(ctx);
+ void *actual_dst_arg = (void *)PT_REGS_PARM5(ctx);
+#else
+ void *src_arg = (void *)PT_REGS_PARM2(ctx);
+ void *original_dst_arg = (void *)PT_REGS_PARM3(ctx);
+ void *actual_dst_arg = (void *)PT_REGS_PARM4(ctx);
+#endif
+ success = get_socket_addr_ip_in_ztunnel(success, src_arg,
&event->orginal_src_ip, &event->src_port);
+ success = get_socket_addr_ip_in_ztunnel(success, original_dst_arg,
&event->original_dst_ip, &event->dst_port);
+ success = get_socket_addr_ip_in_ztunnel(success, actual_dst_arg,
&event->lb_dst_ip, &event->lb_dst_port);
if (!success) {
return 0;
}
bpf_perf_event_output(ctx, &ztunnel_lb_socket_mapping_event_queue,
BPF_F_CURRENT_CPU, event, sizeof(*event));
return 0;
}
+
+// ConnectionResult::new(src: SocketAddr, dst: SocketAddr, hbone_target, ...)
is an
+// associated function(no &self) that ztunnel constructs UNCONDITIONALLY for
every proxied
+// connection - including the outbound legs that skip track_outbound through
an early-return
+// in proxy_to - so it is a strictly-higher-coverage, log-level-independent
source(the same
+// data ztunnel would print as the "connection complete"/"connection opened"
access log, but
+// captured at construction time regardless of the log level). It returns a
large struct via
+// the hidden sret pointer, which on x86-64 occupies PARM1 and shifts the
arguments by one
+// (src=PARM2, dst=PARM3); on AArch64 the sret pointer is in x8(not a PARM) so
the arguments
+// are not shifted(src=PARM1, dst=PARM2). src is the downstream app addr and
dst is the REAL
+// backend pod addr. There is no service ClusterIP among the arguments, so
this mapping is
+// keyed by the source address alone in user space(the app's ephemeral src
port is unique per
+// connection). original_dst_ip is left zero to mark this event as a
"src-only" mapping.
+SEC("uprobe/connection_result_new")
+int connection_result_new(struct pt_regs* ctx) {
+ struct ztunnel_socket_mapping_t *event =
create_ztunnel_socket_mapping_event();
+ if (event == NULL) {
+ return 0;
+ }
+ bool success = true;
+#if defined(bpf_target_x86)
+ void *src_arg = (void *)PT_REGS_PARM2(ctx);
+ void *dst_arg = (void *)PT_REGS_PARM3(ctx);
+#else
+ void *src_arg = (void *)PT_REGS_PARM1(ctx);
+ void *dst_arg = (void *)PT_REGS_PARM2(ctx);
+#endif
+ success = get_socket_addr_ip_in_ztunnel(success, src_arg,
&event->orginal_src_ip, &event->src_port);
+ success = get_socket_addr_ip_in_ztunnel(success, dst_arg,
&event->lb_dst_ip, &event->lb_dst_port);
+ if (!success) {
+ return 0;
+ }
+ event->original_dst_ip = 0;
+ event->dst_port = 0;
+ bpf_perf_event_output(ctx, &ztunnel_lb_socket_mapping_event_queue,
BPF_F_CURRENT_CPU, event, sizeof(*event));
+ return 0;
+}
diff --git a/bpf/accesslog/syscalls/connect_conntrack.c
b/bpf/accesslog/syscalls/connect_conntrack.c
index 8f1f5cb..7510967 100644
--- a/bpf/accesslog/syscalls/connect_conntrack.c
+++ b/bpf/accesslog/syscalls/connect_conntrack.c
@@ -93,7 +93,7 @@ static __always_inline int nf_conn_aware(struct pt_regs* ctx,
struct nf_conn *ct
}
// already contains the remote address
- if (connect_args->has_remote && &(connect_args->remote) != NULL) {
+ if (connect_args->has_remote) {
return 0;
}
@@ -142,7 +142,3 @@ int nf_confirm(struct pt_regs* ctx) {
return nf_conn_aware(ctx, (struct nf_conn*)PT_REGS_PARM3(ctx));
}
-SEC("kprobe/ctnetlink_fill_info")
-int nf_ctnetlink_fill_info(struct pt_regs* ctx) {
- return nf_conn_aware(ctx, (struct nf_conn*)PT_REGS_PARM5(ctx));
-}
\ No newline at end of file
diff --git a/configs/rover_configs.yaml b/configs/rover_configs.yaml
index a5a6b0c..68ede6d 100644
--- a/configs/rover_configs.yaml
+++ b/configs/rover_configs.yaml
@@ -18,6 +18,11 @@
logger:
# The lowest level of printing allowed.
level: ${ROVER_LOGGER_LEVEL:INFO}
+ # Comma separated list of modules(name prefix) to elevate to the debug level
+ # regardless of the level above, e.g. "accesslog.collector.ztunnel". Keeps
the
+ # high volume modules quiet(bounding the logging allocation) while still
getting
+ # debug detail for the modules under investigation.
+ debug_modules: ${ROVER_LOGGER_DEBUG_MODULES:}
core:
# The name of the cluster.
diff --git a/pkg/accesslog/collector/collector.go
b/pkg/accesslog/collector/collector.go
index 5cf4d2b..0413264 100644
--- a/pkg/accesslog/collector/collector.go
+++ b/pkg/accesslog/collector/collector.go
@@ -19,12 +19,9 @@ package collector
import (
"github.com/apache/skywalking-rover/pkg/accesslog/common"
- "github.com/apache/skywalking-rover/pkg/logger"
"github.com/apache/skywalking-rover/pkg/module"
)
-var log = logger.GetLogger("accesslog", "collector")
-
type Collector interface {
Start(mgr *module.Manager, context *common.AccessLogContext) error
Stop()
diff --git a/pkg/accesslog/collector/connection.go
b/pkg/accesslog/collector/connection.go
index 29adfa9..6621db9 100644
--- a/pkg/accesslog/collector/connection.go
+++ b/pkg/accesslog/collector/connection.go
@@ -125,9 +125,6 @@ func (c *ConnectCollector) Start(m *module.Manager, ctx
*common.AccessLogContext
_ = ctx.BPF.AddLinkOrError(link.Kprobe, map[string]*ebpf.Program{
"nf_confirm": ctx.BPF.NfConfirm,
})
- _ = ctx.BPF.AddLinkOrError(link.Kprobe, map[string]*ebpf.Program{
- "ctnetlink_fill_info": ctx.BPF.NfCtnetlinkFillInfo,
- })
return nil
}
@@ -159,6 +156,13 @@ func (c *ConnectionPartitionContext) Consume(data
interface{}) {
"pid: %d, fd: %d, role: %s: func: %s, family: %d,
success: %d, conntrack exist: %t",
event.ConID, event.RandomID, event.PID, event.SocketFD,
enums.ConnectionRole(event.Role), enums.SocketFunctionName(event.FuncName),
event.SocketFamily, event.ConnectSuccess,
event.ConnTrackUpstreamPort != 0)
+ // a negative fd means the syscall failed(e.g. non-blocking
accept returns -EAGAIN),
+ // there is no real connection behind it, so ignore the event
+ if int32(event.SocketFD) < 0 {
+ connectionLogger.Debugf("ignore the connect event with
negative socket fd, connection ID: %d, randomID: %d, "+
+ "pid: %d, fd: %d", event.ConID, event.RandomID,
event.PID, int32(event.SocketFD))
+ return
+ }
socketPair := c.BuildSocketFromConnectEvent(event)
if socketPair == nil {
connectionLogger.Debugf("cannot found the socket paire
from connect event, connection ID: %d, randomID: %d",
@@ -271,6 +275,9 @@ func (c *ConnectionPartitionContext) BuildSocketPair(event
*events.SocketConnect
if !ip.ShouldIgnoreConntrack(remoteAddr, conntrackIP,
uint16(event.ConnTrackUpstreamPort)) {
result.DestIP = conntrackIP
result.DestPort =
uint16(event.ConnTrackUpstreamPort)
+ // the BPF conntrack path rewrote the dest to
the real peer; mark it so the
+ // resolve summary counts it as
conntrack-resolved instead of unresolved
+ result.ConnTrackResolved = true
ignoredConntrack = false
}
@@ -308,6 +315,9 @@ func (c *ConnectionPartitionContext) BuildSocketPair(event
*events.SocketConnect
if !ip.ShouldIgnoreConntrack(remoteAddr, conntrackIP,
uint16(event.ConnTrackUpstreamPort)) {
result.DestIP = conntrackIP
result.DestPort =
uint16(event.ConnTrackUpstreamPort)
+ // the BPF conntrack path rewrote the dest to
the real peer; mark it so the
+ // resolve summary counts it as
conntrack-resolved instead of unresolved
+ result.ConnTrackResolved = true
ignoredConntrack = false
}
diff --git a/pkg/accesslog/collector/ztunnel.go
b/pkg/accesslog/collector/ztunnel.go
index be5c168..c36a59b 100644
--- a/pkg/accesslog/collector/ztunnel.go
+++ b/pkg/accesslog/collector/ztunnel.go
@@ -20,13 +20,17 @@ package collector
import (
"context"
"fmt"
+ "os"
+ "runtime"
"strings"
+ "sync/atomic"
"time"
"k8s.io/apimachinery/pkg/util/cache"
"github.com/apache/skywalking-rover/pkg/accesslog/common"
"github.com/apache/skywalking-rover/pkg/accesslog/events"
+ "github.com/apache/skywalking-rover/pkg/logger"
"github.com/apache/skywalking-rover/pkg/module"
"github.com/apache/skywalking-rover/pkg/tools/elf"
"github.com/apache/skywalking-rover/pkg/tools/enums"
@@ -35,18 +39,54 @@ import (
v3 "skywalking.apache.org/repo/goapi/collect/ebpf/accesslog/v3"
+ "github.com/cilium/ebpf"
"github.com/shirou/gopsutil/process"
)
var (
// ZTunnelProcessFinderInterval is the interval to find ztunnel process
ZTunnelProcessFinderInterval = time.Second * 30
+ // ztunnelMappingQueuePerCPUBufferPages /
ztunnelMappingQueueMinParallels tune the ztunnel mapping
+ // perf queue so the doubled(track_outbound + ConnectionResult) event
stream is drained promptly:
+ // a large per-CPU ring plus one reader goroutine per CPU keep the
delivery latency well within
+ // the resolution-defer window(measured via the open->resolution
latency buckets), instead of the
+ // events backing up and arriving after a short connection has already
flushed the raw ClusterIP.
+ ztunnelMappingQueuePerCPUBufferPages = 16
+ ztunnelMappingQueueMinParallels = 4
// ZTunnelTrackBoundSymbolPrefix is the prefix of the symbol name to
track outbound connections in ztunnel process
// ztunnel::proxy::connection_manager::ConnectionManager::track_outbound
ZTunnelTrackBoundSymbolPrefix =
"_ZN7ztunnel5proxy18connection_manager17ConnectionManager14track_outbound"
+ // ZTunnelConnectionResultNewSymbolPrefix is the prefix of
ztunnel::proxy::metrics::ConnectionResult::new,
+ // an associated function ztunnel constructs UNCONDITIONALLY for every
proxied connection - including the
+ // outbound legs that skip track_outbound through an early-return - so
it is a strictly-higher-coverage,
+ // log-level-independent source of the (downstream src -> real pod)
mapping(the same data ztunnel prints
+ // as the access log, captured at construction regardless of log
level). Attached best-effort alongside
+ // track_outbound so the two sources cover each other across ztunnel
versions.
+ ZTunnelConnectionResultNewSymbolPrefix =
"_ZN7ztunnel5proxy7metrics16ConnectionResult3new"
+ // ZTunnelIPMappingExpireDuration is how long an outbound(service IP ->
real pod IP)
+ // mapping captured from the ztunnel uprobe is kept before a connection
flush claims
+ // it. Empirically raising this does NOT reduce the residual unresolved
rate: the
+ // leftover misses are short-lived client connections whose uprobe
mapping event is
+ // processed slightly AFTER the connection has already flushed(a
pipeline race), not
+ // mappings that expired - so a longer TTL only grows the cache for no
benefit.
+ ZTunnelIPMappingExpireDuration = time.Minute
+ // ZTunnelSrcOnlyMappingExpireDuration is the TTL for a
source-address-only mapping(from
+ // ConnectionResult::new or the access-log fallback). The src-only
entry is written at
+ // connection-open but only CONSUMED when the connection flushes, so it
is sized generously above
+ // the flush / resolution-defer window - a connection that flushes a
little late still finds its
+ // mapping present instead of degrading to a degenerate "-|service|-"
node. A src-only key carries
+ // no destination discriminator, but a reused ephemeral src port almost
always targets the same
+ // service(whose ClusterIP load-balances over the same pods) and
overwrites the entry with its own
+ // fresh mapping anyway, so the longer TTL does not cause
mis-attribution.
+ ZTunnelSrcOnlyMappingExpireDuration = time.Minute * 5
)
-var zTunnelCollectInstance = NewZTunnelCollector(time.Minute)
+// ztunnelLog is a dedicated module("accesslog.collector.ztunnel") so the
ambient
+// correlation debug logs can be enabled on their own(logger.debug_modules)
+// without turning on the high volume accesslog.collector.* debug logs.
+var ztunnelLog = logger.GetLogger("accesslog", "collector", "ztunnel")
+
+var zTunnelCollectInstance =
NewZTunnelCollector(ZTunnelIPMappingExpireDuration)
// ZTunnelCollector is a collector for ztunnel process in the Ambient Istio
scenario
type ZTunnelCollector struct {
@@ -54,15 +94,65 @@ type ZTunnelCollector struct {
cancel context.CancelFunc
alc *common.AccessLogContext
- collectingProcess *process.Process
+ // collectingProcess is written by the finder-ticker goroutine on
(re)discovery
+ // and read concurrently by OnConnectEvent / ReadyToFlushConnection /
the netns
+ // pollers, so it is guarded by an atomic pointer(same idiom as the
counters).
+ collectingProcess atomic.Pointer[process.Process]
ipMappingCache *cache.Expiring
ipMappingExpireDuration time.Duration
+
+ // counters for observability of the ztunnel correlation pipeline
+ mappingEventCount atomic.Int64
+ mappingHitCount atomic.Int64
+ mappingMissCount atomic.Int64
+ emptyCacheMissCount atomic.Int64
+ // count of uprobe mappings rejected by the plausibility check, a
growing value
+ // signals the ztunnel binary's track_outbound ABI no longer matches
what the
+ // uprobe expects(version mismatch)
+ invalidMappingCount atomic.Int64
+
+ // resolvedBySource counts, per redundant source, whose cached mapping
actually resolved a
+ // connection at flush time(reported as a share in the periodic stats).
It holds only the
+ // four resolution sources, so a nil-safe lookup skips non-resolution
tags like sourceInbound.
+ resolvedBySource map[ztunnelMappingSource]*atomic.Int64
+ // accessLogParsedCount counts the outbound mappings the access-log
fallback tailer parsed
+ // from the ztunnel log; it proves the fallback is functional even when
the uprobes cover
+ // everything and it therefore never wins a resolution
+ accessLogParsedCount atomic.Int64
+
+ // admin/metrics pollers inside the ztunnel network namespace
+ pollersStarted bool
+ // accessLogTailerStarted guards the one-time start of the ztunnel
access-log fallback tailer
+ accessLogTailerStarted bool
+ // accessLogBacklogCutoff is set at tailer start; ztunnel access-log
lines timestamped before it
+ // are the pre-agent backlog(connections proxied before this agent and
its uprobes existed, which
+ // are not in the connection manager). They are skipped: caching them
is wasted, and worse,
+ // re-parsing the huge backlog that accumulates when the e2e starts
traffic BEFORE the agent
+ // delays the tailer from reaching the RECENT close lines(for the
startup connections still being
+ // resolved) until after they are flushed/deleted, leaving them as
degenerate nodes.
+ accessLogBacklogCutoff time.Time
+ accessLogBacklogSkipped atomic.Int64
+ adminOutboundMappingCount atomic.Int64
+ adminInboundSeenCount atomic.Int64
+ metricsOpenedConnections atomic.Int64
+
+ // count of connect events observed from the ztunnel process itself,
used to
+ // diagnose whether the BPF `tgid_is_ztunnel` gate is actually
capturing the
+ // ztunnel's connect() to the local workload(the inbound correlation
source)
+ ztunnelConnectEventSeen atomic.Int64
+ ztunnelInboundTaggedSeen atomic.Int64
}
func NewZTunnelCollector(expireTime time.Duration) *ZTunnelCollector {
return &ZTunnelCollector{
ipMappingCache: cache.NewExpiring(),
ipMappingExpireDuration: expireTime,
+ resolvedBySource: map[ztunnelMappingSource]*atomic.Int64{
+ sourceTrackOutbound: new(atomic.Int64),
+ sourceConnectionResult: new(atomic.Int64),
+ sourceAccessLog: new(atomic.Int64),
+ sourceAdminDump: new(atomic.Int64),
+ },
}
}
@@ -76,79 +166,177 @@ func (z *ZTunnelCollector) Start(_ *module.Manager, ctx
*common.AccessLogContext
return err
}
- if z.collectingProcess == nil {
- return nil
- }
-
- ctx.BPF.ReadEventAsync(ctx.BPF.ZtunnelLbSocketMappingEventQueue,
func(data interface{}) {
+ // NOTE: even if the ztunnel process not found at startup, still needs
to register
+ // the event reader and the finder ticker, the ztunnel process could
start later
+ // This queue now carries TWO events per connection(track_outbound +
ConnectionResult::new), so
+ // a single reader with the default 1-page buffer falls behind under a
high connection rate(the
+ // extra waypoint hop): the mapping events are then read tens of
seconds late, after the
+ // connection has already flushed with the raw ClusterIP - the dominant
residual "-|service|-"
+ // cause. Give it a larger per-CPU buffer and a few reader goroutines
so mappings are delivered
+ // promptly, within the connection's resolution-defer window.
+
ctx.BPF.ReadEventAsyncWithBufferSize(ctx.BPF.ZtunnelLbSocketMappingEventQueue,
func(data interface{}) {
event := data.(*events.ZTunnelSocketMappingEvent)
localIP := z.convertBPFIPToString(event.OriginalSrcIP)
localPort := event.OriginalSrcPort
+ lbIP := z.convertBPFIPToString(event.LoadBalancedDestIP)
+ z.mappingEventCount.Add(1)
+
+ // A ConnectionResult::new event has no original service
ClusterIP(OriginalDestIP == 0):
+ // it carries only the (downstream src -> real pod) pair and is
keyed by the source
+ // address alone(the app's ephemeral src port is unique per
connection). This is the
+ // higher-coverage source that also captures the outbound legs
track_outbound skips via
+ // its early-returns.
+ if event.OriginalDestIP == 0 {
+ if !isPlausibleSrcOnlyMapping(event) {
+ z.invalidMappingCount.Add(1)
+ return
+ }
+ ztunnelLog.Debugf("received ztunnel src-only mapping
event: %s:%d -> lb: %s:%d", localIP, localPort, lbIP,
event.LoadBalancedDestPort)
+ srcOnlyKey := z.buildSrcOnlyCacheKey(localIP,
int(localPort))
+ z.ipMappingCache.Set(srcOnlyKey,
&ZTunnelLoadBalanceAddress{
+ IP: lbIP,
+ Port: event.LoadBalancedDestPort,
+ From:
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_OUTBOUND_FUNC,
+ Source: sourceConnectionResult,
+ }, ZTunnelSrcOnlyMappingExpireDuration)
+ // push: resolve any connection already held in the
manager for this source right now,
+ // instead of waiting for its next flush to pull the
cache(closes the late-event race)
+ z.retroResolve(localIP, localPort)
+ return
+ }
+
remoteIP := z.convertBPFIPToString(event.OriginalDestIP)
remotePort := event.OriginalDestPort
- lbIP := z.convertBPFIPToString(event.LoadBalancedDestIP)
- log.Debugf("received ztunnel lb socket mapping event: %s:%d ->
%s:%d, lb: %s", localIP, localPort, remoteIP, remotePort, lbIP)
+ ztunnelLog.Debugf("received ztunnel lb socket mapping event:
%s:%d -> %s:%d, lb: %s", localIP, localPort, remoteIP, remotePort, lbIP)
+
+ // the uprobe reads ztunnel's version-specific Rust
internals(track_outbound
+ // arg registers + SocketAddr layout). A ztunnel that changed
the function
+ // signature or the struct layout would make it read the wrong
offsets and
+ // produce a GARBAGE mapping, which is worse than no mapping(it
would attribute
+ // traffic to a wrong/non-existent pod). Reject implausible
mappings so such a
+ // case degrades safely to "unresolved"(the raw service IP the
backend can still
+ // name at the service level) instead of silently wrong data.
+ if !isPlausibleLBMapping(event) {
+ z.invalidMappingCount.Add(1)
+ ztunnelLog.Warnf("dropping implausible ztunnel lb
mapping(possible ztunnel version/ABI mismatch): %s:%d -> %s:%d, lb: %s:%d",
+ localIP, localPort, remoteIP, remotePort, lbIP,
event.LoadBalancedDestPort)
+ return
+ }
key := z.buildIPMappingCacheKey(localIP, int(localPort),
remoteIP, int(remotePort))
z.ipMappingCache.Set(key, &ZTunnelLoadBalanceAddress{
- IP: lbIP,
- Port: event.LoadBalancedDestPort,
- From:
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_OUTBOUND_FUNC,
+ IP: lbIP,
+ Port: event.LoadBalancedDestPort,
+ From:
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_OUTBOUND_FUNC,
+ Source: sourceTrackOutbound,
}, z.ipMappingExpireDuration)
- }, func() interface{} {
+ }, os.Getpagesize()*ztunnelMappingQueuePerCPUBufferPages,
ztunnelMappingQueueParallels(), func() interface{} {
return &events.ZTunnelSocketMappingEvent{}
})
go func() {
ticker := time.NewTicker(ZTunnelProcessFinderInterval)
+ var lastMissCount, lastEmptyCacheMissCount int64
for {
select {
case <-ticker.C:
err := z.findZTunnelProcessAndCollect()
if err != nil {
- log.Error("failed to find and collect
ztunnel process: ", err)
+ ztunnelLog.Error("failed to find and
collect ztunnel process: ", err)
}
+ missCount, emptyCacheMissCount :=
z.mappingMissCount.Load(), z.emptyCacheMissCount.Load()
+ logFunc := ztunnelLog.Debugf
+ // promote to info level when new un-correlated
connections appeared in this interval,
+ // so the resolve failures are visible without
enabling the debug level
+ if missCount > lastMissCount ||
emptyCacheMissCount > lastEmptyCacheMissCount {
+ logFunc = ztunnelLog.Infof
+ }
+ logFunc("ztunnel correlation stats: uprobe
mapping events received: %d, invalid mappings dropped: %d, "+
+ "admin dump outbound mappings: %d,
admin dump inbound connections seen: %d, ztunnel-pid connect events seen: %d, "+
+ "inbound legs tagged: %d, attach hits:
%d, attach misses: %d, empty cache misses: %d, "+
+ "ztunnel reported opened
connections(metrics): %d, resolution by source: {%s}",
+ z.mappingEventCount.Load(),
z.invalidMappingCount.Load(), z.adminOutboundMappingCount.Load(),
+ z.adminInboundSeenCount.Load(),
z.ztunnelConnectEventSeen.Load(), z.ztunnelInboundTaggedSeen.Load(),
+ z.mappingHitCount.Load(), missCount,
emptyCacheMissCount, z.metricsOpenedConnections.Load(),
+ z.resolutionSourceStats())
+ lastMissCount, lastEmptyCacheMissCount =
missCount, emptyCacheMissCount
case <-z.ctx.Done():
ticker.Stop()
return
}
}
}()
+
+ // start the symbol-independent access-log fallback tailer(best-effort:
idles if the log
+ // file is not mounted / access logging is off), so a ztunnel build
where both uprobes
+ // fail to attach still resolves the ClusterIP hops
+ z.startAccessLogTailer()
return nil
}
func (z *ZTunnelCollector) OnConnectEvent(e *events.SocketConnectEvent, s
*ip.SocketPair) bool {
- if z.collectingProcess != nil && e != nil && s != nil &&
uint32(z.collectingProcess.Pid) == e.PID &&
- s.Role == enums.ConnectionRoleClient {
- // must be the client side(outbound) connect
- // revert the source and dest for the workload application
accept
- key := z.buildIPMappingCacheKey(s.DestIP, int(s.DestPort),
s.SrcIP, int(s.SrcPort))
- z.ipMappingCache.Set(key, &ZTunnelLoadBalanceAddress{
- From:
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_INBOUND_FUNC,
- }, z.ipMappingExpireDuration)
- log.Debugf("found the ztunnel outbound connection, "+
- "connection ID: %d, randomID: %d, pid: %d, fd: %d,
role: %s, local: %s:%d, remote: %s:%d",
- e.ConID, e.RandomID, e.PID, e.SocketFD,
enums.ConnectionRole(e.Role), s.SrcIP, s.SrcPort, s.DestIP, s.DestPort)
- return false
+ proc := z.collectingProcess.Load()
+ if proc == nil || e == nil || s == nil || uint32(proc.Pid) != e.PID {
+ return true
}
- return true
+ // any connect event from the ztunnel process reaching here means the
BPF
+ // `tgid_is_ztunnel` gate is armed and capturing, record it for
diagnosis
+ z.ztunnelConnectEventSeen.Add(1)
+ if s.Role != enums.ConnectionRoleClient {
+ return true
+ }
+ // this is the ztunnel client side connect to the local workload(the
inbound leg),
+ // revert the source and dest so it matches the workload application
accept, and tag
+ // the correlated connection as ztunnel inbound
+ z.ztunnelInboundTaggedSeen.Add(1)
+ key := z.buildIPMappingCacheKey(s.DestIP, int(s.DestPort), s.SrcIP,
int(s.SrcPort))
+ z.ipMappingCache.Set(key, &ZTunnelLoadBalanceAddress{
+ From:
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_INBOUND_FUNC,
+ Source: sourceInbound,
+ }, z.ipMappingExpireDuration)
+ ztunnelLog.Debugf("found the ztunnel inbound connection, "+
+ "connection ID: %d, randomID: %d, pid: %d, fd: %d, role: %s,
local: %s:%d, remote: %s:%d",
+ e.ConID, e.RandomID, e.PID, e.SocketFD,
enums.ConnectionRole(e.Role), s.SrcIP, s.SrcPort, s.DestIP, s.DestPort)
+ return false
}
func (z *ZTunnelCollector) ReadyToFlushConnection(connection
*common.ConnectionInfo, _ events.Event) {
- if connection == nil || connection.Socket == nil ||
connection.RPCConnection == nil || connection.RPCConnection.Attachment != nil ||
- z.ipMappingCache.Len() == 0 {
+ if connection == nil || connection.Socket == nil ||
connection.RPCConnection == nil || connection.RPCConnection.Attachment != nil {
+ return
+ }
+ if z.ipMappingCache.Len() == 0 {
+ if z.collectingProcess.Load() != nil {
+ z.emptyCacheMissCount.Add(1)
+ ztunnelLog.Debugf("the ztunnel IP mapping cache is
empty, cannot attach ztunnel address for connection ID: %d, random ID: %d",
+ connection.ConnectionID, connection.RandomID)
+ }
return
}
key := z.buildIPMappingCacheKey(connection.Socket.SrcIP,
int(connection.Socket.SrcPort),
connection.Socket.DestIP, int(connection.Socket.DestPort))
lbIPObj, found := z.ipMappingCache.Get(key)
if !found {
- log.Debugf("there no ztunnel mapped IP address found for
connection ID: %d, random ID: %d",
+ // fall back to the source-only mapping(from
ConnectionResult::new / the access-log
+ // fallback), which resolves the outbound connections that
skipped track_outbound and
+ // therefore have no src+ClusterIP entry - this is what lifts
the coverage from
+ // track_outbound's ~90% towards 100%
+ lbIPObj, found =
z.ipMappingCache.Get(z.buildSrcOnlyCacheKey(connection.Socket.SrcIP,
int(connection.Socket.SrcPort)))
+ }
+ if !found {
+ z.mappingMissCount.Add(1)
+ ztunnelLog.Debugf("there no ztunnel mapped IP address found for
connection ID: %d, random ID: %d",
connection.ConnectionID, connection.RandomID)
return
}
+ z.mappingHitCount.Add(1)
address := lbIPObj.(*ZTunnelLoadBalanceAddress)
- log.Debugf("found the ztunnel load balanced IP for the connection: %s,
connectionID: %d, randomID: %d",
- address.String(), connection.ConnectionID, connection.RandomID)
+ // attribute the resolution to the source whose cached mapping actually
won, so the periodic
+ // stats can report each source's share(non-resolution tags like
sourceInbound are absent
+ // from the map and skipped)
+ if c := z.resolvedBySource[address.Source]; c != nil {
+ c.Add(1)
+ }
+ ztunnelLog.Debugf("found the ztunnel load balanced IP for the
connection: %s(source: %s), connectionID: %d, randomID: %d",
+ address.String(), address.Source, connection.ConnectionID,
connection.RandomID)
securityPolicy := v3.ZTunnelAttachmentSecurityPolicy_NONE
// if the target port is 15008, this mean ztunnel have use mTLS
if address.From ==
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_OUTBOUND_FUNC && address.Port
== 15008 {
@@ -163,6 +351,103 @@ func (z *ZTunnelCollector)
ReadyToFlushConnection(connection *common.ConnectionI
},
},
}
+ // diagnostic: record how long this connection took from open to
resolution, to LOCATE the
+ // late-mapping-event problem(a mass in the >=15s bucket = events
delivered too slowly)
+ if z.alc != nil && z.alc.ConnectionMgr != nil {
+ z.alc.ConnectionMgr.RecordResolutionLatency(connection)
+ }
+ // NOTE: the src-only entry is deliberately NOT evicted on consume.
Evicting it would stop a
+ // later connection that reuses the same ephemeral src port from
borrowing this mapping - but a
+ // reused port almost always targets the SAME service, whose ClusterIP
load-balances over the
+ // same pods, so the "stale" entry still resolves that connection to a
correct pod. What
+ // eviction reliably DOES cause is a miss for the reused-port
connection when its own uprobe
+ // event has not landed yet, leaving it unresolved as a degenerate
"-|service|-" node. The
+ // short ZTunnelSrcOnlyMappingExpireDuration TTL bounds the staleness
instead. (Measured:
+ // evict-on-consume raised the unresolved rate ~0.65% -> ~1.7%, all to
single-replica services.)
+}
+
+// IsResolutionPending implements common.ResolutionAwareFlusher. It reports
true while a
+// connection's real destination could still be filled by the ztunnel outbound
lb mapping
+// but has not been yet, so the runner defers the connection's logs a little
instead of
+// emitting them with the raw service IP(which would create a degenerate
"-|service|-"
+// entity). It is deliberately conservative - only the client(outbound) leg to
a raw,
+// not-yet-attached, not-conntrack-resolved remote qualifies - and is bounded
by the
+// per-connection grace deadline in ShouldDeferForResolution so a genuinely
external
+// destination is delayed at most one grace period.
+func (z *ZTunnelCollector) IsResolutionPending(connection
*common.ConnectionInfo) bool {
+ // only meaningful while a ztunnel is actually being collected on this
node, otherwise
+ // no mapping will ever arrive and deferring would only add latency
+ if z.collectingProcess.Load() == nil {
+ return false
+ }
+ if connection == nil || connection.RPCConnection == nil ||
connection.Socket == nil {
+ return false
+ }
+ // already correlated to the real destination
+ if connection.RPCConnection.Attachment != nil {
+ return false
+ }
+ // only the client(outbound) leg goes through the ztunnel outbound lb
mapping
+ if connection.Socket.Role != enums.ConnectionRoleClient {
+ return false
+ }
+ // the conntrack query already rewrote the address to the real peer, no
ztunnel wait needed
+ if connection.Socket.ConnTrackResolved {
+ return false
+ }
+ // a remote resolved to a local monitored pod is a Kubernetes
address(no raw IP); only a
+ // raw IP remote(the service VIP) is a candidate for the ztunnel lb
mapping
+ remote := connection.RPCConnection.GetRemote()
+ if remote == nil || remote.GetIp() == nil {
+ return false
+ }
+ return true
+}
+
+// UnresolvedReason implements common.ResolutionAwareFlusher: it categorizes
WHY a connection that
+// reached the end of its lifetime without a ztunnel attachment is still
unresolved, so the periodic
+// resolve summary can point at the environment/source that did not provide
the mapping.
+func (z *ZTunnelCollector) UnresolvedReason(connection *common.ConnectionInfo)
string {
+ if z.collectingProcess.Load() == nil {
+ // no ztunnel process was discovered on this node, so the
ambient outbound mapping can never
+ // exist here - from this agent's point of view the remote is
genuinely just a raw IP
+ return "no-ztunnel-process-on-node"
+ }
+ if !z.IsResolutionPending(connection) {
+ // only a raw-IP client(outbound) leg goes through the ztunnel
outbound lb mapping; a server
+ // leg / conntrack-resolved / already-attached / non-raw remote
is not a ztunnel miss
+ return "not-a-ztunnel-outbound-leg"
+ }
+ if z.ipMappingCache.Len() == 0 {
+ // ztunnel is being collected but NO mapping was ever captured
- the whole correlation source
+ // is producing nothing(uprobes not attached / not firing and
the log/admin fallbacks empty)
+ return "ztunnel-mapping-cache-empty"
+ }
+ // ztunnel is collecting and mappings exist, but none matched this
socket(neither the src+dst
+ // track_outbound key nor the src-only ConnectionResult / access-log
key) before the connection
+ // reached the end of its lifetime: the correlation gap is on the
event-capture side - ztunnel
+ // emitted no usable event for this source(a track_outbound
early-return AND no ConnectionResult,
+ // or a BPF miss).
+ return "no-ztunnel-mapping-for-socket"
+}
+
+// retroResolve pushes a just-cached source mapping to any connection still
held in the manager for
+// that source, so a late mapping event does not miss the connection's
flush(see RetroResolveBySrc).
+// It nil-guards the context so unit tests that construct a bare collector do
not panic.
+// ztunnelMappingQueueParallels is the reader-goroutine count for the mapping
perf queue: one per
+// CPU(floored at ztunnelMappingQueueMinParallels) so the doubled event stream
is drained in parallel.
+func ztunnelMappingQueueParallels() int {
+ if n := runtime.NumCPU(); n > ztunnelMappingQueueMinParallels {
+ return n
+ }
+ return ztunnelMappingQueueMinParallels
+}
+
+func (z *ZTunnelCollector) retroResolve(srcIP string, srcPort uint16) {
+ if z.alc == nil || z.alc.ConnectionMgr == nil {
+ return
+ }
+ z.alc.ConnectionMgr.RetroResolveBySrc(srcIP, srcPort)
}
func (z *ZTunnelCollector) convertBPFIPToString(ipAddr uint32) string {
@@ -173,6 +458,68 @@ func (z *ZTunnelCollector) buildIPMappingCacheKey(localIP
string, localPort int,
return fmt.Sprintf("%s:%d-%s:%d", localIP, localPort, remoteIP,
remotePort)
}
+// buildSrcOnlyCacheKey keys a mapping by the downstream source address alone,
used by the
+// ConnectionResult::new source(which carries the real pod but no original
service ClusterIP,
+// so it cannot be keyed by src+dst like track_outbound). The "src:" prefix
keeps it in a
+// distinct namespace from the src+dst keys.
+func (z *ZTunnelCollector) buildSrcOnlyCacheKey(localIP string, localPort int)
string {
+ return fmt.Sprintf("src:%s:%d", localIP, localPort)
+}
+
+// resolutionSourceOrder fixes the print order of the per-source shares in the
stats line.
+var resolutionSourceOrder = []ztunnelMappingSource{
+ sourceTrackOutbound, sourceConnectionResult, sourceAccessLog,
sourceAdminDump,
+}
+
+// resolutionSourceStats reports the share each redundant source contributed
to the resolved
+// connections, plus how many mappings the access-log fallback tailer
parsed(its liveness even
+// when the uprobes win every resolution).
+func (z *ZTunnelCollector) resolutionSourceStats() string {
+ var total int64
+ for _, s := range resolutionSourceOrder {
+ total += z.resolvedBySource[s].Load()
+ }
+ pct := func(v int64) float64 {
+ if total == 0 {
+ return 0
+ }
+ return float64(v) * 100 / float64(total)
+ }
+ parts := make([]string, 0, len(resolutionSourceOrder))
+ for _, s := range resolutionSourceOrder {
+ v := z.resolvedBySource[s].Load()
+ parts = append(parts, fmt.Sprintf("%s: %d(%.1f%%)", s, v,
pct(v)))
+ }
+ return fmt.Sprintf("%s; access_log lines parsed(fallback live): %d,
pre-agent backlog skipped: %d",
+ strings.Join(parts, ", "), z.accessLogParsedCount.Load(),
z.accessLogBacklogSkipped.Load())
+}
+
+// isPlausibleLBMapping sanity-checks a ztunnel lb socket mapping decoded from
the
+// uprobe. The uprobe reads ztunnel's version-specific Rust ABI(the
track_outbound
+// argument registers and the SocketAddr byte layout); if a future ztunnel
changes
+// either, the decode yields garbage. Rejecting an implausible mapping keeps a
+// version mismatch from silently attributing traffic to a wrong pod - it
degrades
+// to unresolved instead. A valid mapping has non-zero src / original-dst /
lb-dst
+// addresses and ports, and the lb destination(the selected pod) is never
loopback.
+func isPlausibleLBMapping(e *events.ZTunnelSocketMappingEvent) bool {
+ // a valid lb mapping is a valid src-only mapping(non-zero src / lb-dst
addr+port, non-loopback
+ // pod) plus a non-zero original service ClusterIP destination
+ return isPlausibleSrcOnlyMapping(e) && e.OriginalDestIP != 0 &&
e.OriginalDestPort != 0
+}
+
+// isPlausibleSrcOnlyMapping sanity-checks a ConnectionResult::new
mapping(downstream src ->
+// real pod), which has no original ClusterIP. Same guards as
isPlausibleLBMapping minus the
+// original-dest checks, so a ztunnel ABI change degrades to unresolved
instead of wrong data.
+func isPlausibleSrcOnlyMapping(e *events.ZTunnelSocketMappingEvent) bool {
+ if e.OriginalSrcIP == 0 || e.LoadBalancedDestIP == 0 {
+ return false
+ }
+ if e.OriginalSrcPort == 0 || e.LoadBalancedDestPort == 0 {
+ return false
+ }
+ return e.LoadBalancedDestIP>>24 != 127
+}
+
func (z *ZTunnelCollector) Stop() {
if z.cancel != nil {
z.cancel()
@@ -180,14 +527,14 @@ func (z *ZTunnelCollector) Stop() {
}
func (z *ZTunnelCollector) findZTunnelProcessAndCollect() error {
- if z.collectingProcess != nil {
- running, err := z.collectingProcess.IsRunning()
+ if current := z.collectingProcess.Load(); current != nil {
+ running, err := current.IsRunning()
if err == nil && running {
// already collecting the process
- log.Debugf("found the ztunnel process and collecting
ztunnel data from pid: %d", z.collectingProcess.Pid)
+ ztunnelLog.Debugf("found the ztunnel process and
collecting ztunnel data from pid: %d", current.Pid)
return nil
}
- log.Warnf("detected ztunnel process is not running, should
re-scan process to find and collect it")
+ ztunnelLog.Warnf("detected ztunnel process is not running,
should re-scan process to find and collect it")
}
processes, err := process.Processes()
@@ -207,13 +554,26 @@ func (z *ZTunnelCollector) findZTunnelProcessAndCollect()
error {
}
if zTunnelProcess == nil {
- log.Debugf("ztunnel process not found is current node")
+ // clear a now-dead process so the netns pollers stop entering
the dead
+ // /proc/<pid>/ns/net every interval until a ztunnel comes back
+ z.collectingProcess.Store(nil)
+ ztunnelLog.Debugf("ztunnel process not found is current node")
return nil
}
- log.Infof("ztunnel process founded in current node, pid: %d",
zTunnelProcess.Pid)
- z.collectingProcess = zTunnelProcess
- return z.collectZTunnelProcess(zTunnelProcess)
+ ztunnelLog.Infof("ztunnel process founded in current node, pid: %d",
zTunnelProcess.Pid)
+ z.collectingProcess.Store(zTunnelProcess)
+ // start the admin/metrics pollers even if the uprobe attaching failed,
+ // the admin dump works without any dependency on the ztunnel binary
symbols
+ z.startNetnsPollers()
+ // a missing track_outbound symbol(stripped or symbol-renamed ztunnel
binary)
+ // must NOT be fatal: returning the error here aborts the whole
access-log
+ // module on this node and defeats the symbol-independent admin-dump
fallback
+ // started above. Log it and keep the event reader / pollers running.
+ if err := z.collectZTunnelProcess(zTunnelProcess); err != nil {
+ ztunnelLog.Warnf("failed to attach the ztunnel uprobe, the
admin-dump fallback still runs: %v", err)
+ }
+ return nil
}
func (z *ZTunnelCollector) collectZTunnelProcess(p *process.Process) error {
@@ -222,27 +582,93 @@ func (z *ZTunnelCollector) collectZTunnelProcess(p
*process.Process) error {
if err != nil {
return fmt.Errorf("read executable file error: %v", err)
}
- trackBoundSymbol := elfFile.FilterSymbol(func(name string) bool {
- return strings.HasPrefix(name, ZTunnelTrackBoundSymbolPrefix)
- }, true)
- if len(trackBoundSymbol) == 0 {
- return fmt.Errorf("failed to find track outbound symbol in
ztunnel process")
+ // the Rust compiler may emit multiple monomorphized/cloned copies of a
symbol, so attach
+ // to all of them to avoid missing the actually-called copy
+ uprobeFile := z.alc.BPF.OpenUProbeExeFile(pidExeFile)
+ // if the executable could not be opened the AddLink calls below are
silent no-ops; fail here
+ // instead of counting "symbols found" as attached and arming the BPF
pid gate with zero
+ // uprobes actually installed(which would log success while capturing
nothing)
+ if !uprobeFile.Found() {
+ return fmt.Errorf("cannot open the ztunnel executable %s for
uprobe attaching", pidExeFile)
+ }
+ attached := 0
+ // attach the enter uprobe to every ELF symbol whose name starts with
prefix, counting the
+ // attaches so the caller can tell whether a source was found at all
+ attach := func(prefix, label string, prog *ebpf.Program) {
+ // The prefix also matches same-name NON-function symbols the
Rust `tracing` macros emit -
+ // the "__CALLSITE"/"__CALLSITE4META" static data symbols(e.g.
+ // ...ConnectionResult3new10__CALLSITE17h..E) and inner
"{{closure}}" bodies. Attaching a
+ // uprobe to a DATA symbol fails and aborts the whole
access_log module, and a closure
+ // reads the wrong registers. A real function copy has the Rust
legacy-mangled hash segment
+ // "17h<hash>E" IMMEDIATELY after the demangled path, so
require "17h" right after the
+ // prefix: that keeps every monomorphized/cloned FUNCTION
copy(onlyOneResult=false, so a
+ // never-called copy is harmless) while excluding the
__CALLSITE/closure symbols.
+ for _, symbol := range elfFile.FilterSymbol(func(name string)
bool {
+ return strings.HasPrefix(name, prefix+"17h")
+ }, false) {
+ ztunnelLog.Infof("attaching ztunnel %s symbol: %s",
label, symbol.Name)
+ uprobeFile.AddLink(symbol.Name, prog, nil)
+ attached++
+ }
}
- uprobeFile := z.alc.BPF.OpenUProbeExeFile(pidExeFile)
- uprobeFile.AddLink(trackBoundSymbol[0].Name,
z.alc.BPF.ConnectionManagerTrackOutbound, nil)
+ // source 1: track_outbound(src, ClusterIP, real pod) - the
src+ClusterIP keyed mapping.
+ // source 2: ConnectionResult::new(src, real pod) - the
higher-coverage, src-keyed mapping
+ // that also captures the outbound legs track_outbound misses. Attached
best-effort: on a
+ // ztunnel version where either symbol is absent/renamed the other
still provides coverage.
+ // AddLink accumulates any attach failure into the shared BPF linker,
but the happy path never
+ // inspects it - and `attached` only counts SYMBOLS MATCHED, not
uprobes actually installed. So
+ // a symbol that matched yet whose uprobe failed to install(the
kernel/uprobe layer rejected it
+ // for this particular ztunnel build) is otherwise completely
invisible: the collector then
+ // receives ZERO mapping events with no clue why. Snapshot the linker
error before/after so a
+ // NEW failure attributable to these two attaches is surfaced instead
of silently swallowed.
+ attachErrBefore := fmt.Sprintf("%v", z.alc.BPF.HasError())
+ attach(ZTunnelTrackBoundSymbolPrefix, "track outbound",
z.alc.BPF.ConnectionManagerTrackOutbound)
+ attach(ZTunnelConnectionResultNewSymbolPrefix, "ConnectionResult::new",
z.alc.BPF.ConnectionResultNew)
- // setting the ztunnel pid in the BPF
+ if attached == 0 {
+ return fmt.Errorf("failed to find any ztunnel outbound mapping
symbol" +
+ "(track_outbound / ConnectionResult::new) in ztunnel
process")
+ }
+ if err := z.alc.BPF.HasError(); err != nil && fmt.Sprintf("%v", err) !=
attachErrBefore {
+ ztunnelLog.Warnf("ztunnel uprobe attach reported error(s): one
or more uprobes may NOT be "+
+ "installed, which leaves the uprobe mapping event
stream empty and makes the correlation "+
+ "fall back to the admin-dump/access-log sources only:
%v", err)
+ }
+
+ // setting the ztunnel pid in the BPF, this arms the `tgid_is_ztunnel`
gate so the
+ // ztunnel's own connect() to the local workload(the inbound leg) is
captured
if err = z.alc.BPF.ZtunnelProcessPid.Set(p.Pid); err != nil {
return fmt.Errorf("failed to set ztunnel process pid in the
BPF: %v", err)
}
+ // read back the value to confirm the BPF gate is actually armed with
the expected pid,
+ // this makes the "no ztunnel connect captured" problem diagnosable
directly from the log
+ var armedPid uint32
+ if err = z.alc.BPF.ZtunnelProcessPid.Get(&armedPid); err != nil {
+ ztunnelLog.Warnf("cannot read back the ztunnel process pid from
the BPF: %v", err)
+ } else {
+ ztunnelLog.Infof("armed the ztunnel BPF pid gate, expected pid:
%d, read back: %d", p.Pid, armedPid)
+ }
return nil
}
+// ztunnelMappingSource identifies WHICH of the redundant sources produced a
cached mapping,
+// so the periodic stats can report the resolution share of each(uprobe vs
access-log fallback).
+type ztunnelMappingSource string
+
+const (
+ sourceTrackOutbound ztunnelMappingSource = "track_outbound"
+ sourceConnectionResult ztunnelMappingSource = "connection_result"
+ sourceAccessLog ztunnelMappingSource = "access_log"
+ sourceAdminDump ztunnelMappingSource = "admin_dump"
+ sourceInbound ztunnelMappingSource = "inbound"
+)
+
type ZTunnelLoadBalanceAddress struct {
- IP string
- Port uint16
- From v3.ZTunnelAttachmentEnvironmentDetectBy
+ IP string
+ Port uint16
+ From v3.ZTunnelAttachmentEnvironmentDetectBy
+ Source ztunnelMappingSource
}
func (z *ZTunnelLoadBalanceAddress) String() string {
diff --git a/pkg/accesslog/collector/ztunnel_accesslog.go
b/pkg/accesslog/collector/ztunnel_accesslog.go
new file mode 100644
index 0000000..9febdcc
--- /dev/null
+++ b/pkg/accesslog/collector/ztunnel_accesslog.go
@@ -0,0 +1,301 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 collector
+
+import (
+ "bufio"
+ "encoding/json"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/apache/skywalking-rover/pkg/tools/host"
+
+ v3 "skywalking.apache.org/repo/goapi/collect/ebpf/accesslog/v3"
+)
+
+var (
+ // ZTunnelAccessLogPodsGlob is the per-pod sub-path(UNDER the kubelet
pod-log directory) of the
+ // ztunnel DaemonSet's access log. The pod-log directory
itself(/var/log/pods on the host) is
+ // resolved through host.GetHostVarLogPodsInHost, so the in-container
mount point is injected via
+ // ROVER_HOST_VAR_LOG_PODS_MAPPING and NOT hard-coded. The kubelet
writes every container's log
+ // under /var/log/pods for ALL CRI runtimes(containerd, CRI-O,
cri-dockerd), so this is runtime
+ // independent. Tailing it is the ultimate, symbol-independent fallback
mapping source: ztunnel
+ // emits an access log line for EVERY proxied connection("connection
complete" at INFO,
+ // "connection opened" at DEBUG) carrying src.addr and
dst.hbone_addr(the real backend pod), so
+ // it recovers the (downstream src -> real pod) mapping even on a
ztunnel build where the uprobe
+ // symbols are stripped/renamed and both uprobes fail to attach. The
container sub-dir is
+ // wildcarded so any container name matches; the ztunnel pod has a
single container.
+ ZTunnelAccessLogPodsGlob = "istio-system_ztunnel-*/*/*.log"
+ // ZTunnelAccessLogPollInterval is how often the tailer polls for new
log content / rotation
+ ZTunnelAccessLogPollInterval = time.Second
+)
+
+const (
+ // the two access log messages ztunnel emits per proxied outbound
connection(see the
+ // ZTunnelAccessLogPodsGlob doc): "connection complete" at INFO,
"connection opened" at DEBUG
+ msgConnectionComplete = "connection complete"
+ msgConnectionOpened = "connection opened"
+)
+
+// startAccessLogTailer starts a background goroutine that tails the local
ztunnel access log
+// and feeds the (downstream src -> real pod) mappings into the same
ipMappingCache the uprobe
+// fills, keyed by the source address alone(like the ConnectionResult::new
source). It is a
+// best-effort fallback: if the log file is absent(no mount / logging
disabled) it simply idles.
+//
+//nolint:gocyclo // linear tail state machine(rotation/truncation/EOF);
splitting obscures the flow
+func (z *ZTunnelCollector) startAccessLogTailer() {
+ if z.accessLogTailerStarted {
+ return
+ }
+ z.accessLogTailerStarted = true
+ // lines older than this are the pre-agent backlog and are skipped(see
accessLogBacklogCutoff).
+ z.accessLogBacklogCutoff = time.Now().Add(-30 * time.Second)
+ // resolve the full pod-log glob through the host
mapping(ROVER_HOST_VAR_LOG_PODS_MAPPING) so
+ // the /var/log/pods mount point is configurable and not hard-coded
into the binary.
+ glob := host.GetHostVarLogPodsInHost(ZTunnelAccessLogPodsGlob)
+ go func() {
+ var current string // currently tailed file path
+ var reader *bufio.Reader
+ var file *os.File
+ var offset int64
+ defer func() {
+ if file != nil {
+ _ = file.Close()
+ }
+ }()
+ ticker := time.NewTicker(ZTunnelAccessLogPollInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-z.ctx.Done():
+ return
+ case <-ticker.C:
+ }
+
+ // nothing to tail until a ztunnel is being collected
on this node; skip the glob so a
+ // non-ztunnel node does not scan the log dir every
tick(the ztunnel log only exists
+ // where a ztunnel runs, so this changes no outcome,
only avoids the wasted stat)
+ if z.collectingProcess.Load() == nil {
+ continue
+ }
+
+ // (re)resolve the newest ztunnel log file; the kubelet
rotates N.log files
+ latest := newestMatch(glob)
+ if latest == "" {
+ continue
+ }
+ // decide whether to (re)open the file. Reopen on a new
newest path(rotation to a new
+ // N.log), OR when the same path is now backed by a
DIFFERENT inode: kubelet size-based
+ // rotation renames the active file and recreates it at
the same path, so keying the
+ // reopen on the path string alone(latest != current)
would leave the tailer pinned to
+ // the stale renamed inode and silently stop seeing new
lines. os.SameFile compares the
+ // underlying inode, catching that case.
+ reopen := file == nil || latest != current
+ if !reopen {
+ if latestInfo, statErr := os.Stat(latest);
statErr == nil {
+ if openInfo, err := file.Stat(); err !=
nil || !os.SameFile(latestInfo, openInfo) {
+ reopen = true
+ }
+ }
+ }
+ if reopen {
+ if file != nil {
+ _ = file.Close()
+ }
+ f, err := os.Open(latest)
+ if err != nil {
+ ztunnelLog.Debugf("cannot open ztunnel
access log %s: %v", latest, err)
+ continue
+ }
+ file, reader, current, offset = f,
bufio.NewReader(f), latest, 0
+ ztunnelLog.Infof("tailing ztunnel access log as
a fallback mapping source: %s", latest)
+ } else if file != nil {
+ // same inode: detect truncation-in-place(file
shrank) and restart from the beginning
+ if fi, err := file.Stat(); err == nil &&
fi.Size() < offset {
+ _, _ = file.Seek(0, io.SeekStart)
+ reader.Reset(file)
+ offset = 0
+ }
+ }
+ if reader == nil {
+ continue
+ }
+ for {
+ line, err := reader.ReadString('\n')
+ if line != "" {
+ offset += int64(len(line))
+ if strings.HasSuffix(line, "\n") {
+ z.handleAccessLogLine(line)
+ }
+ }
+ if err != nil {
+ break // EOF or partial trailing line:
wait for the next poll
+ }
+ }
+ }
+ }()
+}
+
+// newestMatch returns the glob match with the most recent modification time,
or "".
+func newestMatch(glob string) string {
+ matches, err := filepath.Glob(glob)
+ if err != nil || len(matches) == 0 {
+ return ""
+ }
+ var newest string
+ var newestMod time.Time
+ for _, m := range matches {
+ fi, err := os.Stat(m)
+ if err != nil {
+ continue
+ }
+ if newest == "" || fi.ModTime().After(newestMod) {
+ newest, newestMod = m, fi.ModTime()
+ }
+ }
+ return newest
+}
+
+// handleAccessLogLine parses one CRI log line and, if it is an outbound
ztunnel access log
+// event, feeds its (src -> real pod) mapping into the cache keyed by the
source address.
+//
+//nolint:gocyclo // linear parser over 2 on-disk + 2 payload formats; helpers
wouldn't cut complexity
+func (z *ZTunnelCollector) handleAccessLogLine(line string) {
+ // The kubelet writes container logs in one of two on-disk formats
depending on the runtime:
+ // - CRI(containerd / CRI-O / cri-dockerd): "<rfc3339-ts> <stream>
<F|P> <payload>", where a
+ // partial(P) line is a fragment - ztunnel access log lines are
short and never split, so
+ // only full(F) lines are taken;
+ // - docker json-file(legacy dockershim):
{"log":"<payload>\n","stream":"...","time":"<ts>"}.
+ // Extract (timestamp, payload) from whichever it is so the tailer is
runtime independent.
+ line = strings.TrimRight(line, "\n")
+ var lineTimestamp, payload string
+ if strings.HasPrefix(line, "{") {
+ var m map[string]string
+ if json.Unmarshal([]byte(line), &m) != nil {
+ return
+ }
+ lineTimestamp, payload = m["time"], strings.TrimRight(m["log"],
"\n")
+ } else {
+ fields := strings.SplitN(line, " ", 4)
+ if len(fields) < 4 || fields[2] != "F" {
+ return
+ }
+ lineTimestamp, payload = fields[0], fields[3]
+ }
+ if payload == "" {
+ return
+ }
+ // skip the pre-agent backlog(see accessLogBacklogCutoff):
re-parsing/caching the large history
+ // the ztunnel logged before this agent started - which the e2e
maximizes by running traffic
+ // BEFORE the agent - delays the tailer from reaching the RECENT close
lines it actually needs,
+ // past the startup connections' resolution-defer/delete window,
leaving them degenerate.
+ if !z.accessLogBacklogCutoff.IsZero() {
+ if ts, err := time.Parse(time.RFC3339Nano, lineTimestamp); err
== nil && ts.Before(z.accessLogBacklogCutoff) {
+ z.accessLogBacklogSkipped.Add(1)
+ return
+ }
+ }
+
+ var srcAddr, podAddr, direction, message string
+ if strings.HasPrefix(strings.TrimSpace(payload), "{") {
+ // ztunnel is configured with LOG_FORMAT=json: the payload is a
JSON object
+ var m map[string]interface{}
+ if json.Unmarshal([]byte(payload), &m) != nil {
+ return
+ }
+ srcAddr, _ = m["src.addr"].(string)
+ podAddr, _ = m["dst.hbone_addr"].(string)
+ if podAddr == "" {
+ podAddr, _ = m["dst.addr"].(string)
+ }
+ direction, _ = m["direction"].(string)
+ message, _ = m["message"].(string)
+ } else {
+ // default plain "key=value" istio format(tab separated header
+ space separated fields)
+ if !strings.Contains(payload, "\taccess\t") {
+ return
+ }
+ srcAddr = extractLogField(payload, "src.addr=")
+ podAddr = extractLogField(payload, "dst.hbone_addr=")
+ if podAddr == "" {
+ podAddr = extractLogField(payload, "dst.addr=")
+ }
+ direction = strings.Trim(extractLogField(payload,
"direction="), "\"")
+ if strings.Contains(payload, msgConnectionComplete) {
+ message = msgConnectionComplete
+ } else if strings.Contains(payload, msgConnectionOpened) {
+ message = msgConnectionOpened
+ }
+ }
+
+ // only the outbound leg carries (app src -> real target pod); inbound
has the reverse
+ if direction != "outbound" || srcAddr == "" || podAddr == "" {
+ return
+ }
+ if message != msgConnectionComplete && message != msgConnectionOpened {
+ return
+ }
+ srcIP, sp, err := parseZTunnelAddress(srcAddr)
+ if err != nil {
+ return
+ }
+ podIP, pp, err := parseZTunnelAddress(podAddr)
+ if err != nil {
+ return
+ }
+ if podIP == "" || strings.HasPrefix(podIP, "127.") {
+ return
+ }
+
+ // the access-log source is functional regardless of whether it ever
wins a resolution;
+ // count every parsed outbound mapping so the stats prove the fallback
is live even when
+ // the uprobes already cover everything
+ z.accessLogParsedCount.Add(1)
+ key := z.buildSrcOnlyCacheKey(srcIP, sp)
+ // as a fallback, only fill a gap the uprobes did not already cover: do
not overwrite a
+ // live uprobe mapping(that would mis-attribute the resolution source
and waste writes)
+ if _, exist := z.ipMappingCache.Get(key); exist {
+ return
+ }
+ z.mappingEventCount.Add(1)
+ ztunnelLog.Debugf("access-log fallback mapping resolved a gap: %s:%d ->
%s:%d", srcIP, sp, podIP, pp)
+ z.ipMappingCache.Set(key, &ZTunnelLoadBalanceAddress{
+ IP: podIP,
+ Port: uint16(pp),
+ From:
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_OUTBOUND_FUNC,
+ Source: sourceAccessLog,
+ }, ZTunnelSrcOnlyMappingExpireDuration)
+ // push: resolve any still-held connection for this source now(same as
the uprobe path)
+ z.retroResolve(srcIP, uint16(sp))
+}
+
+// extractLogField returns the value following key(e.g. "src.addr=") up to the
next space, or "".
+func extractLogField(s, key string) string {
+ i := strings.Index(s, key)
+ if i < 0 {
+ return ""
+ }
+ rest := s[i+len(key):]
+ if end := strings.IndexAny(rest, " \t"); end >= 0 {
+ return rest[:end]
+ }
+ return rest
+}
diff --git a/pkg/accesslog/collector/ztunnel_accesslog_test.go
b/pkg/accesslog/collector/ztunnel_accesslog_test.go
new file mode 100644
index 0000000..d53666b
--- /dev/null
+++ b/pkg/accesslog/collector/ztunnel_accesslog_test.go
@@ -0,0 +1,130 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 collector
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+func TestExtractLogField(t *testing.T) {
+ cases := []struct{ name, s, key, want string }{
+ {"space terminated", `x src.addr=1.2.3.4:5 y`, "src.addr=",
"1.2.3.4:5"},
+ {"tab terminated", "x\tsrc.addr=1.2.3.4:5\ty", "src.addr=",
"1.2.3.4:5"},
+ {"end of string", `x src.addr=1.2.3.4:5`, "src.addr=",
"1.2.3.4:5"},
+ {"missing key", `x dst.addr=9`, "src.addr=", ""},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := extractLogField(c.s, c.key); got != c.want {
+ t.Fatalf("extractLogField(%q, %q) = %q, want
%q", c.s, c.key, got, c.want)
+ }
+ })
+ }
+}
+
+func TestHandleAccessLogLine(t *testing.T) {
+ srcKey := func(z *ZTunnelCollector) string { return
z.buildSrcOnlyCacheKey("10.0.0.5", 45000) }
+
+ t.Run("json outbound connection complete fills the src-only mapping",
func(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ z.handleAccessLogLine(`2024-01-01T00:00:00Z stdout F
{"src.addr":"10.0.0.5:45000",` +
+
`"dst.hbone_addr":"10.244.0.20:9080","direction":"outbound","message":"connection
complete"}` + "\n")
+ obj, ok := z.ipMappingCache.Get(srcKey(z))
+ if !ok {
+ t.Fatal("expected a src-only mapping to be cached")
+ }
+ addr := obj.(*ZTunnelLoadBalanceAddress)
+ if addr.IP != "10.244.0.20" || addr.Port != 9080 || addr.Source
!= sourceAccessLog {
+ t.Fatalf("unexpected cached mapping: %+v", addr)
+ }
+ if z.accessLogParsedCount.Load() != 1 {
+ t.Fatalf("accessLogParsedCount = %d, want 1",
z.accessLogParsedCount.Load())
+ }
+ })
+
+ t.Run("dst.hbone_addr is preferred but falls back to dst.addr", func(t
*testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ z.handleAccessLogLine(`2024-01-01T00:00:00Z stdout F
{"src.addr":"10.0.0.5:45000",` +
+
`"dst.addr":"10.244.0.30:9080","direction":"outbound","message":"connection
opened"}` + "\n")
+ obj, ok := z.ipMappingCache.Get(srcKey(z))
+ if !ok || obj.(*ZTunnelLoadBalanceAddress).IP != "10.244.0.30" {
+ t.Fatal("expected the dst.addr fallback to be used when
dst.hbone_addr is absent")
+ }
+ })
+
+ t.Run("plain istio key=value format is parsed", func(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ z.handleAccessLogLine("2024-01-01T00:00:00Z stdout F
2024-01-01\tinfo\taccess\tconnection complete " +
+ "src.addr=10.0.0.5:45000
dst.hbone_addr=10.244.0.20:9080 direction=\"outbound\"\n")
+ if _, ok := z.ipMappingCache.Get(srcKey(z)); !ok {
+ t.Fatal("expected the plain-format access log line to
be parsed")
+ }
+ })
+
+ t.Run("docker json-file runtime format is parsed", func(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ // the legacy docker json-file runtime wraps each line as
{"log":"<payload>\n","stream":..,"time":..}
+ inner :=
`{"src.addr":"10.0.0.5:45000","dst.hbone_addr":"10.244.0.20:9080",` +
+ `"direction":"outbound","message":"connection
complete"}`
+ wrapper, err := json.Marshal(map[string]string{"log": inner +
"\n", "stream": "stdout", "time": "2024-01-01T00:00:00Z"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ z.handleAccessLogLine(string(wrapper) + "\n")
+ obj, ok := z.ipMappingCache.Get(srcKey(z))
+ if !ok || obj.(*ZTunnelLoadBalanceAddress).IP != "10.244.0.20" {
+ t.Fatal("expected the docker json-file wrapped access
log line to be parsed")
+ }
+ })
+
+ // none of these malformed / non-matching lines should panic or create
a cache entry
+ reject := []struct{ name, line string }{
+ {"partial CRI line (P not F)", `2024-01-01T00:00:00Z stdout P
{"src.addr":"10.0.0.5:45000","dst.addr":"10.244.0.20:9080","direction":"outbound","message":"connection
complete"}`},
+ {"too few CRI fields", `2024-01-01T00:00:00Z stdout`},
+ {"inbound direction", `2024-01-01T00:00:00Z stdout F
{"src.addr":"10.0.0.5:45000","dst.addr":"10.244.0.20:9080","direction":"inbound","message":"connection
complete"}`},
+ {"not a connection message", `2024-01-01T00:00:00Z stdout F
{"src.addr":"10.0.0.5:45000","dst.addr":"10.244.0.20:9080","direction":"outbound","message":"hello"}`},
+ {"loopback pod dropped", `2024-01-01T00:00:00Z stdout F
{"src.addr":"10.0.0.5:45000","dst.addr":"127.0.0.1:9080","direction":"outbound","message":"connection
complete"}`},
+ {"truncated json", `2024-01-01T00:00:00Z stdout F
{"src.addr":"10.0.0.5:45000"`},
+ {"missing addresses", `2024-01-01T00:00:00Z stdout F
{"direction":"outbound","message":"connection complete"}`},
+ {"empty payload", `2024-01-01T00:00:00Z stdout F `},
+ }
+ for _, c := range reject {
+ t.Run("reject "+c.name, func(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ z.handleAccessLogLine(c.line + "\n")
+ if z.ipMappingCache.Len() != 0 {
+ t.Fatalf("expected no cache entry for a
rejected line: %q", c.line)
+ }
+ })
+ }
+
+ t.Run("does not overwrite a live uprobe mapping", func(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ key := z.buildSrcOnlyCacheKey("10.0.0.5", 45000)
+ z.ipMappingCache.Set(key, &ZTunnelLoadBalanceAddress{IP:
"10.244.0.99", Port: 9080, Source: sourceConnectionResult}, time.Minute)
+ z.handleAccessLogLine(`2024-01-01T00:00:00Z stdout F
{"src.addr":"10.0.0.5:45000",` +
+
`"dst.addr":"10.244.0.20:9080","direction":"outbound","message":"connection
complete"}` + "\n")
+ obj, _ := z.ipMappingCache.Get(key)
+ addr := obj.(*ZTunnelLoadBalanceAddress)
+ if addr.IP != "10.244.0.99" || addr.Source !=
sourceConnectionResult {
+ t.Fatalf("the access-log fallback must not overwrite a
live uprobe mapping, got %+v", addr)
+ }
+ })
+}
diff --git a/pkg/accesslog/collector/ztunnel_admin.go
b/pkg/accesslog/collector/ztunnel_admin.go
new file mode 100644
index 0000000..f23179f
--- /dev/null
+++ b/pkg/accesslog/collector/ztunnel_admin.go
@@ -0,0 +1,296 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 collector
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/apache/skywalking-rover/pkg/tools/host"
+ "github.com/apache/skywalking-rover/pkg/tools/netns"
+
+ v3 "skywalking.apache.org/repo/goapi/collect/ebpf/accesslog/v3"
+)
+
+var (
+ // ZTunnelNetnsPollInterval is the interval of polling the ztunnel
admin config dump and metrics
+ ZTunnelNetnsPollInterval = time.Second * 10
+ // ZTunnelAdminConfigDumpURL is the ztunnel admin address inside the
ztunnel pod network namespace
+ ZTunnelAdminConfigDumpURL = "http://127.0.0.1:15000/config_dump"
+ // ZTunnelMetricsURL is the ztunnel prometheus metrics address inside
the ztunnel pod network namespace
+ ZTunnelMetricsURL = "http://127.0.0.1:15020/metrics"
+ // ztunnelTCPOpenedMetricName is used to cross-check how many
connections the ztunnel have proxied
+ ztunnelTCPOpenedMetricName = "istio_tcp_connections_opened_total"
+)
+
+// ztunnelConfigDump is the subset of the ztunnel admin /config_dump response,
+// the "workloadState" section is reported by the ztunnel in-pod admin handler
+// and contains the per-workload active connections tracked by the
ConnectionManager
+type ztunnelConfigDump struct {
+ WorkloadState map[string]ztunnelWorkloadState `json:"workloadState"`
+}
+
+type ztunnelWorkloadState struct {
+ Connections *ztunnelConnectionDump `json:"connections"`
+}
+
+type ztunnelConnectionDump struct {
+ Inbound []ztunnelConnection `json:"inbound"`
+ Outbound []ztunnelConnection `json:"outbound"`
+}
+
+type ztunnelConnection struct {
+ Src string `json:"src"`
+ OriginalDst string `json:"originalDst"`
+ ActualDst string `json:"actualDst"`
+ Protocol string `json:"protocol"`
+}
+
+func (z *ZTunnelCollector) startNetnsPollers() {
+ if z.pollersStarted || z.collectingProcess.Load() == nil {
+ return
+ }
+ z.pollersStarted = true
+ go func() {
+ // Run one poll cycle IMMEDIATELY at attach, before the first
tick. The connections
+ // established in the window between the app-side capture
arming and this ztunnel uprobe
+ // attaching never produced an open-time uprobe mapping event;
the admin /config_dump lists
+ // the connections ztunnel still has open, so this earliest
snapshot resolves the ones that
+ // are still alive at attach instead of waiting a full poll
interval(by which the short,
+ // keepalive-less connections are already gone).
+ if !z.runNetnsPollCycle() {
+ return
+ }
+ ticker := time.NewTicker(ZTunnelNetnsPollInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ if z.collectingProcess.Load() == nil {
+ continue
+ }
+ if !z.runNetnsPollCycle() {
+ return
+ }
+ case <-z.ctx.Done():
+ return
+ }
+ }
+ }()
+}
+
+// runNetnsPollCycle runs one admin-dump + metrics poll inside the ztunnel
network namespace and
+// returns false if the collector is shutting down. The netns work runs in a
throwaway goroutine:
+// RunInNetNS may fail to switch the OS thread back to the original namespace
and then keep it
+// locked so the Go runtime discards it - but that only happens when the
goroutine EXITS, so doing
+// each cycle in its own goroutine lets a poisoned thread be discarded instead
of pinning this
+// long-lived poller in the ztunnel namespace.
+func (z *ZTunnelCollector) runNetnsPollCycle() bool {
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ if err := z.pollAdminConnectionDump(); err != nil {
+ ztunnelLog.Warnf("failed to poll the ztunnel admin
connection dump: %v", err)
+ }
+ if err := z.pollZTunnelMetrics(); err != nil {
+ ztunnelLog.Warnf("failed to poll the ztunnel metrics:
%v", err)
+ }
+ }()
+ select {
+ case <-done:
+ return true
+ case <-z.ctx.Done():
+ return false
+ }
+}
+
+// pollAdminConnectionDump reads the active connections tracked by the ztunnel
ConnectionManager
+// through the admin API, and feeds the outbound (src, originalDst) ->
actualDst mappings into
+// the IP mapping cache, the same cache the uprobe based event fills. This
works without any
+// dependency on the ztunnel binary symbols, but only contains the connections
still alive.
+func (z *ZTunnelCollector) pollAdminConnectionDump() error {
+ body, err := z.httpGetInZTunnelNetNS(ZTunnelAdminConfigDumpURL)
+ if err != nil {
+ return err
+ }
+ dump := &ztunnelConfigDump{}
+ if err := json.Unmarshal(body, dump); err != nil {
+ return fmt.Errorf("unmarshal the config dump error: %w", err)
+ }
+
+ var aliveInboundCount int64
+ for _, workload := range dump.WorkloadState {
+ if workload.Connections == nil {
+ continue
+ }
+ for _, conn := range workload.Connections.Outbound {
+ srcIP, srcPort, err := parseZTunnelAddress(conn.Src)
+ if err != nil {
+ continue
+ }
+ origIP, origPort, err :=
parseZTunnelAddress(conn.OriginalDst)
+ if err != nil {
+ continue
+ }
+ actualIP, actualPort, err :=
parseZTunnelAddress(conn.ActualDst)
+ if err != nil {
+ continue
+ }
+
+ key := z.buildIPMappingCacheKey(srcIP, srcPort, origIP,
origPort)
+ if _, exist := z.ipMappingCache.Get(key); !exist {
+ z.adminOutboundMappingCount.Add(1)
+ ztunnelLog.Debugf("found ztunnel outbound
connection from admin dump: %s:%d -> %s:%d, actual: %s:%d",
+ srcIP, srcPort, origIP, origPort,
actualIP, actualPort)
+ }
+ // always re-set to refresh the expiration for
still-alive connections
+ z.ipMappingCache.Set(key, &ZTunnelLoadBalanceAddress{
+ IP: actualIP,
+ Port: uint16(actualPort),
+ From:
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_OUTBOUND_FUNC,
+ Source: sourceAdminDump,
+ }, z.ipMappingExpireDuration)
+ }
+ // the inbound entries contain the real client address(src),
but not the
+ // ephemeral 127.0.0.6 leg the application accepts, so they
cannot be
+ // correlated to a specific accepted connection, only count
them for stats
+ aliveInboundCount += int64(len(workload.Connections.Inbound))
+ }
+ // gauge semantic: the count of alive inbound connections seen in the
latest poll
+ z.adminInboundSeenCount.Store(aliveInboundCount)
+ return nil
+}
+
+// pollZTunnelMetrics reads the total proxied connection count from the
ztunnel prometheus
+// metrics, used as a cross-check signal in the periodic stats log: when the
ztunnel keeps
+// opening connections but the agent attaches no ztunnel mapping, the
correlation is broken
+func (z *ZTunnelCollector) pollZTunnelMetrics() error {
+ body, err := z.httpGetInZTunnelNetNS(ZTunnelMetricsURL)
+ if err != nil {
+ return err
+ }
+ sum, found := sumPrometheusCounter(string(body),
ztunnelTCPOpenedMetricName)
+ if found {
+ z.metricsOpenedConnections.Store(int64(sum))
+ }
+ return nil
+}
+
+func (z *ZTunnelCollector) httpGetInZTunnelNetNS(rawURL string) ([]byte,
error) {
+ proc := z.collectingProcess.Load()
+ if proc == nil {
+ return nil, fmt.Errorf("no ztunnel process is collecting")
+ }
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return nil, fmt.Errorf("parse url %s error: %w", rawURL, err)
+ }
+ netnsPath := host.GetHostProcInHost(fmt.Sprintf("%d/ns/net", proc.Pid))
+
+ var body []byte
+ err = netns.RunInNetNS(netnsPath, func() error {
+ // IMPORTANT: dial synchronously on THIS(setns'd) OS thread.
net/http.Transport
+ // dials new connections in a separate goroutine("go
dialConnFor"), which runs
+ // on another thread that is NOT switched into the ztunnel
network namespace, so
+ // the socket would be created in the agent's(host) netns and
the connect would
+ // be refused. net.DialTimeout to a literal IP:port dials
inline on the calling
+ // goroutine, keeping the socket in the target namespace.
+ conn, err := net.DialTimeout("tcp", parsed.Host, time.Second*5)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ if err := conn.SetDeadline(time.Now().Add(time.Second * 5));
err != nil {
+ return err
+ }
+
+ req, err := http.NewRequest(http.MethodGet, rawURL, http.NoBody)
+ if err != nil {
+ return err
+ }
+ if err := req.Write(conn); err != nil {
+ return err
+ }
+ resp, err := http.ReadResponse(bufio.NewReader(conn), req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d",
resp.StatusCode)
+ }
+ body, err = io.ReadAll(resp.Body)
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ return body, nil
+}
+
+func parseZTunnelAddress(addr string) (ip string, port int, err error) {
+ if addr == "" {
+ return "", 0, fmt.Errorf("empty address")
+ }
+ ip, portStr, err := net.SplitHostPort(addr)
+ if err != nil {
+ return "", 0, err
+ }
+ port, err = strconv.Atoi(portStr)
+ if err != nil {
+ return "", 0, err
+ }
+ return ip, port, nil
+}
+
+// sumPrometheusCounter sums all samples of the given counter family
+// from a prometheus text format payload
+func sumPrometheusCounter(body, metricName string) (float64, bool) {
+ var sum float64
+ var found bool
+ for _, line := range strings.Split(body, "\n") {
+ if !strings.HasPrefix(line, metricName) {
+ continue
+ }
+ // require a metric-name boundary after the prefix so a sibling
series that
+ // merely starts with the same name(e.g. <name>_created,
<name>_bucket) is not
+ // summed in: the counter name is followed by '{'(labels) or
whitespace
+ if rest := line[len(metricName):]; rest != "" && rest[0] != '{'
&& rest[0] != ' ' && rest[0] != '\t' {
+ continue
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 2 {
+ continue
+ }
+ value, err := strconv.ParseFloat(fields[len(fields)-1], 64)
+ if err != nil {
+ continue
+ }
+ sum += value
+ found = true
+ }
+ return sum, found
+}
diff --git a/pkg/accesslog/collector/ztunnel_flush_test.go
b/pkg/accesslog/collector/ztunnel_flush_test.go
new file mode 100644
index 0000000..cfbccc6
--- /dev/null
+++ b/pkg/accesslog/collector/ztunnel_flush_test.go
@@ -0,0 +1,170 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 collector
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apache/skywalking-rover/pkg/accesslog/common"
+ "github.com/apache/skywalking-rover/pkg/accesslog/events"
+ "github.com/apache/skywalking-rover/pkg/tools/enums"
+ "github.com/apache/skywalking-rover/pkg/tools/ip"
+
+ v3 "skywalking.apache.org/repo/goapi/collect/ebpf/accesslog/v3"
+)
+
+func clientConn(srcIP string, srcPort uint16, dstIP string, dstPort uint16)
*common.ConnectionInfo {
+ return &common.ConnectionInfo{
+ Socket: &ip.SocketPair{
+ Role: enums.ConnectionRoleClient,
+ SrcIP: srcIP,
+ SrcPort: srcPort,
+ DestIP: dstIP,
+ DestPort: dstPort,
+ },
+ RPCConnection: &v3.AccessLogConnection{},
+ }
+}
+
+func TestReadyToFlushConnectionSrcDstHitRetained(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ key := z.buildIPMappingCacheKey("10.0.0.5", 45000, "10.96.0.10", 9080)
+ z.ipMappingCache.Set(key, &ZTunnelLoadBalanceAddress{
+ IP: "10.244.0.20", Port: 9080,
+ From:
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_OUTBOUND_FUNC, Source:
sourceTrackOutbound,
+ }, time.Minute)
+
+ conn := clientConn("10.0.0.5", 45000, "10.96.0.10", 9080)
+ z.ReadyToFlushConnection(conn, nil)
+
+ att := conn.RPCConnection.Attachment.GetZTunnel()
+ if att == nil || att.RealDestinationIp != "10.244.0.20" {
+ t.Fatalf("expected the src+dst mapping to attach the real pod,
got %+v", conn.RPCConnection.Attachment)
+ }
+ // a src+dst mapping is discriminated by the ClusterIP, so it is safe
to keep after use
+ if _, ok := z.ipMappingCache.Get(key); !ok {
+ t.Fatal("a src+dst mapping should be retained after resolving a
connection")
+ }
+}
+
+func TestReadyToFlushConnectionSrcOnlyFallbackRetainsEntry(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ srcKey := z.buildSrcOnlyCacheKey("10.0.0.5", 45000)
+ z.ipMappingCache.Set(srcKey, &ZTunnelLoadBalanceAddress{
+ IP: "10.244.0.21", Port: 15008,
+ From:
v3.ZTunnelAttachmentEnvironmentDetectBy_ZTUNNEL_OUTBOUND_FUNC, Source:
sourceConnectionResult,
+ }, time.Minute)
+
+ // no src+dst entry exists for this connection, so it must fall back to
the src-only mapping
+ conn := clientConn("10.0.0.5", 45000, "10.96.0.11", 9080)
+ z.ReadyToFlushConnection(conn, nil)
+
+ att := conn.RPCConnection.Attachment.GetZTunnel()
+ if att == nil || att.RealDestinationIp != "10.244.0.21" {
+ t.Fatalf("expected the src-only fallback to attach the real
pod, got %+v", conn.RPCConnection.Attachment)
+ }
+ if att.SecurityPolicy != v3.ZTunnelAttachmentSecurityPolicy_MTLS {
+ t.Fatalf("expected MTLS for the port-15008 outbound leg, got
%v", att.SecurityPolicy)
+ }
+ // the src-only entry is retained after consume(NOT evicted): a later
connection that reuses
+ // the same ephemeral src port to the same service must still resolve
from it instead of
+ // becoming a degenerate "-|service|-" node while its own uprobe event
is still in flight
+ if _, ok := z.ipMappingCache.Get(srcKey); !ok {
+ t.Fatal("a src-only mapping should be retained after resolving
a connection, not evicted")
+ }
+}
+
+func TestReadyToFlushConnectionNoMappingLeavesUnattached(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ // a non-empty cache(so the empty-cache short circuit does not fire)
with no matching key
+ z.ipMappingCache.Set("src:9.9.9.9:1", &ZTunnelLoadBalanceAddress{IP:
"10.244.0.99"}, time.Minute)
+
+ conn := clientConn("10.0.0.5", 45000, "10.96.0.12", 9080)
+ z.ReadyToFlushConnection(conn, nil)
+ if conn.RPCConnection.Attachment != nil {
+ t.Fatal("a connection with no matching mapping must be left
unattached(raw service IP)")
+ }
+}
+
+func TestReadyToFlushConnectionAlreadyAttachedIsNoop(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ srcKey := z.buildSrcOnlyCacheKey("10.0.0.5", 45000)
+ z.ipMappingCache.Set(srcKey, &ZTunnelLoadBalanceAddress{IP:
"10.244.0.21", Source: sourceConnectionResult}, time.Minute)
+
+ conn := clientConn("10.0.0.5", 45000, "10.96.0.11", 9080)
+ existing := &v3.ConnectionAttachment{}
+ conn.RPCConnection.Attachment = existing
+ z.ReadyToFlushConnection(conn, nil)
+
+ if conn.RPCConnection.Attachment != existing {
+ t.Fatal("an already-attached connection must not be
re-resolved")
+ }
+ // it must not consume(evict) the src-only entry either, since it
early-returned
+ if _, ok := z.ipMappingCache.Get(srcKey); !ok {
+ t.Fatal("the src-only mapping must be untouched when the
connection was already attached")
+ }
+}
+
+func mappingEvent(srcIP uint32, srcPort uint16, origIP uint32, origPort
uint16, lbIP uint32, lbPort uint16) *events.ZTunnelSocketMappingEvent {
+ return &events.ZTunnelSocketMappingEvent{
+ OriginalSrcIP: srcIP, OriginalSrcPort: srcPort,
+ OriginalDestIP: origIP, OriginalDestPort: origPort,
+ LoadBalancedDestIP: lbIP, LoadBalancedDestPort: lbPort,
+ }
+}
+
+func TestIsPlausibleMapping(t *testing.T) {
+ // IPs are stored high-byte-first(see convertBPFIPToString):
0x0A....=10.x, 0x7F...=127.x
+ full := mappingEvent(0x0A000005, 45000, 0x0A600010, 9080, 0x0AF40014,
9080)
+ if !isPlausibleLBMapping(full) || !isPlausibleSrcOnlyMapping(full) {
+ t.Fatal("a fully populated, non-loopback mapping should be
plausible")
+ }
+
+ if isPlausibleLBMapping(mappingEvent(0, 45000, 0x0A600010, 9080,
0x0AF40014, 9080)) {
+ t.Fatal("a zero source IP must be rejected")
+ }
+ if isPlausibleSrcOnlyMapping(mappingEvent(0x0A000005, 45000, 0, 0, 0,
9080)) {
+ t.Fatal("a zero load-balanced IP must be rejected for a
src-only mapping")
+ }
+
+ loopback := mappingEvent(0x0A000005, 45000, 0x0A600010, 9080,
0x7F000001, 9080)
+ if isPlausibleLBMapping(loopback) ||
isPlausibleSrcOnlyMapping(loopback) {
+ t.Fatal("a loopback(127.x) load-balanced destination must be
rejected(ABI-mismatch guard)")
+ }
+
+ // a src-only mapping relaxes exactly the two original-ClusterIP checks
+ srcOnly := mappingEvent(0x0A000005, 45000, 0, 0, 0x0AF40014, 9080)
+ if !isPlausibleSrcOnlyMapping(srcOnly) {
+ t.Fatal("a src-only mapping tolerates a zero original
ClusterIP")
+ }
+ if isPlausibleLBMapping(srcOnly) {
+ t.Fatal("an lb mapping requires a non-zero original ClusterIP")
+ }
+}
+
+func TestBuildSrcOnlyCacheKey(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ if got := z.buildSrcOnlyCacheKey("10.0.0.5", 45000); got !=
"src:10.0.0.5:45000" {
+ t.Fatalf("buildSrcOnlyCacheKey = %q, want src:10.0.0.5:45000",
got)
+ }
+ // the src-only key lives in a distinct namespace from the src+dst key
so they never collide
+ if z.buildSrcOnlyCacheKey("10.0.0.5", 45000) ==
z.buildIPMappingCacheKey("10.0.0.5", 45000, "10.0.0.5", 45000) {
+ t.Fatal("the src-only and src+dst keys must not collide")
+ }
+}
diff --git a/pkg/accesslog/collector/ztunnel_resolution_test.go
b/pkg/accesslog/collector/ztunnel_resolution_test.go
new file mode 100644
index 0000000..a218717
--- /dev/null
+++ b/pkg/accesslog/collector/ztunnel_resolution_test.go
@@ -0,0 +1,109 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 collector
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apache/skywalking-rover/pkg/accesslog/common"
+ "github.com/apache/skywalking-rover/pkg/tools/enums"
+ "github.com/apache/skywalking-rover/pkg/tools/ip"
+
+ v3 "skywalking.apache.org/repo/goapi/collect/ebpf/accesslog/v3"
+
+ "github.com/shirou/gopsutil/process"
+)
+
+func rawIPRemoteConnection() *common.ConnectionInfo {
+ return &common.ConnectionInfo{
+ Socket: &ip.SocketPair{Role: enums.ConnectionRoleClient},
+ RPCConnection: &v3.AccessLogConnection{
+ Remote: &v3.ConnectionAddress{
+ Address: &v3.ConnectionAddress_Ip{
+ Ip: &v3.IPAddress{Host: "10.96.0.10",
Port: 9080},
+ },
+ },
+ },
+ }
+}
+
+func TestIsResolutionPending(t *testing.T) {
+ // a collector with no ztunnel process being collected never reports
pending
+ t.Run("no ztunnel process is not pending", func(t *testing.T) {
+ z := NewZTunnelCollector(time.Minute)
+ if z.IsResolutionPending(rawIPRemoteConnection()) {
+ t.Fatal("should not be pending when no ztunnel process
is collecting")
+ }
+ })
+
+ // arm a collecting process for the remaining cases
+ armed := func() *ZTunnelCollector {
+ z := NewZTunnelCollector(time.Minute)
+ z.collectingProcess.Store(&process.Process{Pid: 4242})
+ return z
+ }
+
+ t.Run("raw IP client leg without attachment is pending", func(t
*testing.T) {
+ if !armed().IsResolutionPending(rawIPRemoteConnection()) {
+ t.Fatal("an unresolved raw-IP client leg should be
pending while ztunnel is active")
+ }
+ })
+
+ t.Run("nil connection is not pending", func(t *testing.T) {
+ if armed().IsResolutionPending(nil) {
+ t.Fatal("nil connection should not be pending")
+ }
+ })
+
+ t.Run("already attached is not pending", func(t *testing.T) {
+ conn := rawIPRemoteConnection()
+ conn.RPCConnection.Attachment = &v3.ConnectionAttachment{}
+ if armed().IsResolutionPending(conn) {
+ t.Fatal("an already-attached connection should not be
pending")
+ }
+ })
+
+ t.Run("server leg is not pending", func(t *testing.T) {
+ conn := rawIPRemoteConnection()
+ conn.Socket.Role = enums.ConnectionRoleServer
+ if armed().IsResolutionPending(conn) {
+ t.Fatal("the server(inbound) leg does not go through
the outbound lb mapping")
+ }
+ })
+
+ t.Run("conntrack-resolved is not pending", func(t *testing.T) {
+ conn := rawIPRemoteConnection()
+ conn.Socket.ConnTrackResolved = true
+ if armed().IsResolutionPending(conn) {
+ t.Fatal("a conntrack-resolved connection needs no
ztunnel wait")
+ }
+ })
+
+ t.Run("kubernetes-resolved remote is not pending", func(t *testing.T) {
+ conn := rawIPRemoteConnection()
+ conn.RPCConnection.Remote = &v3.ConnectionAddress{
+ Address: &v3.ConnectionAddress_Kubernetes{
+ Kubernetes:
&v3.KubernetesProcessAddress{ServiceName: "reviews"},
+ },
+ }
+ if armed().IsResolutionPending(conn) {
+ t.Fatal("a remote already resolved to a local pod is
not a raw service IP")
+ }
+ })
+}
diff --git a/pkg/accesslog/common/connection.go
b/pkg/accesslog/common/connection.go
index b00ffab..decfce9 100644
--- a/pkg/accesslog/common/connection.go
+++ b/pkg/accesslog/common/connection.go
@@ -21,7 +21,10 @@ import (
"context"
"errors"
"fmt"
+ "sort"
+ "strings"
"sync"
+ "sync/atomic"
"time"
"github.com/sirupsen/logrus"
@@ -52,11 +55,36 @@ const (
// clean the active connection in BPF interval
cleanActiveConnectionInterval = time.Second * 20
- // in case the reading the data from BPF queue is disordered, so add a
delay time to delete the connection information
+ // in case the reading the data from BPF queue is disordered, so add a
delay time to delete the
+ // connection information. It is also the upper bound the
resolution-defer grace must stay under
+ // (see resolutionGraceFor): a connection has to survive in the manager
for the whole time its
+ // access log may be held waiting for the ztunnel lb mapping.
connectionDeleteDelayTime = time.Second * 20
// the connection check exist time
connectionCheckExistTime = time.Second * 30
+
+ // the interval of reporting the un-resolved remote address summary
+ unresolvedRemoteReportInterval = time.Second * 30
+ // the max count of distinct un-resolved remote addresses to track per
interval
+ unresolvedRemoteMaxTrack = 100
+ // the max count of top un-resolved remote addresses in the report log
+ unresolvedRemoteTopCount = 10
+
+ // resolutionDeferMargin is added to the flush period to bound how long
a connection whose remote
+ // is still being resolved is held before its access log is sent with
the raw service IP. It only
+ // absorbs the short async delivery jitter of the ztunnel
ConnectionResult mapping event(which is
+ // promptly delivered now that the mapping perf queue is drained by
several reader goroutines,
+ // ztunnelMappingQueueParallels, and applied by the
pull-per-flush-cycle plus the push in
+ // RetroResolveBySrc). It is deliberately SHORT: a LONGER hold was
measured to REGRESS the
+ // strict no-degenerate check(grace 30s and 50s both produced MORE
failures than 15s), because a
+ // connection that still does not resolve emits its raw-IP log in a
LATER minute-bucket, which
+ // then lingers in the topology query window longer. So the residual is
minimized at the source
+ // (parallel readers + push), not by holding longer.
+ resolutionDeferMargin = time.Second * 10
+ // defaultFlushPeriod is used to derive the resolution grace period
when the configured
+ // flush period cannot be parsed
+ defaultFlushPeriod = time.Second * 5
)
type ConnectEventWithSocket struct {
@@ -78,6 +106,20 @@ type FlusherListener interface {
ReadyToFlushConnection(connection *ConnectionInfo,
getConnectionFromEvent events.Event)
}
+// ResolutionAwareFlusher is an optional interface a FlusherListener may
implement to
+// report that a connection's remote address is still being resolved(e.g. the
ztunnel
+// lb mapping has not been correlated yet). When any listener reports pending,
the runner
+// defers the connection's logs for a bounded grace period instead of emitting
them with
+// the raw service IP, which would leave a degenerate "-|service|-" entity in
the backend.
+type ResolutionAwareFlusher interface {
+ IsResolutionPending(connection *ConnectionInfo) bool
+ // UnresolvedReason returns a short category for WHY a connection
reached the end of its
+ // lifetime without its real destination being attached, so the
periodic summary can attribute
+ // the raw-IP socket pairs to a likely cause(which resolution source /
environment did not
+ // provide the mapping). An empty string means this flusher has no
opinion for the connection.
+ UnresolvedReason(connection *ConnectionInfo) string
+}
+
type ProcessListener interface {
OnNewProcessMonitoring(pid int32)
OnProcessRemoved(pid int32)
@@ -107,6 +149,28 @@ type ConnectionManager struct {
connectTracker *ip.ConnTrack
connectionProtocolBreakMap *cache.Expiring
+
+ // statistics of the remote address resolving, for the periodic summary
report
+ remoteAddressBuildCount atomic.Int64
+ unresolvedRemoteCount atomic.Int64
+ ztunnelResolvedRemoteCount atomic.Int64
+ conntrackResolvedRemoteCount atomic.Int64
+ unresolvedRemoteLock sync.Mutex
+ unresolvedRemotes map[string]int64
+ unresolvedByReason map[string]int64
+ // resolutionLatency buckets the open->resolution delay of
ztunnel-resolved connections, to
+ // LOCATE the late-mapping-event problem: a mass in the
>=15s(over-grace) bucket proves the
+ // mapping events are being delivered too slowly(perf-queue reader
backlog), which is exactly the
+ // window in which a connection would have already flushed with the raw
IP -> degenerate node.
+ resolutionLatencyUnder1s atomic.Int64
+ resolutionLatency1to5s atomic.Int64
+ resolutionLatency5to15s atomic.Int64
+ resolutionLatencyOver15s atomic.Int64
+ resolutionLatencyMaxMilli atomic.Int64
+
+ // resolutionGracePeriod is how long a connection whose remote is still
being resolved
+ // is deferred before its logs are flushed anyway; derived from the
flush period + margin
+ resolutionGracePeriod time.Duration
}
func (c *ConnectionManager) RegisterProcessor(processor ConnectionProcessor) {
@@ -131,13 +195,35 @@ type ConnectionInfo struct {
LastCheckExistTime time.Time
DeleteAfter *time.Time
ProtocolBreak bool
-}
-
-func NewConnectionManager(_ *Config, moduleMgr *module.Manager, bpfLoader
*bpf.Loader, filter MonitorFilter) *ConnectionManager {
+ // CreatedAt is when the connection was first seen by the manager; used
to measure how long a
+ // ClusterIP connection took to be resolved(open -> ztunnel mapping
applied), which locates the
+ // late-mapping-event problem: a large open-to-resolution latency means
the mapping event was
+ // delivered slowly(perf-queue reader backlog), not that it was missing.
+ CreatedAt time.Time
+ // ResolutionDeadline, once set, is the time until which this
connection's access logs
+ // are deferred waiting for a resolver(the ztunnel lb mapping) to
attach the real pod
+ // IP. It is set on the first deferred flush and bounds the wait PER
CONNECTION: after
+ // it passes the logs are emitted as-is(raw service IP) even if still
unresolved, so a
+ // genuinely un-resolvable remote is not delayed forever and a
long-lived connection is
+ // not re-deferred on every log.
+ ResolutionDeadline *time.Time
+}
+
+func NewConnectionManager(config *Config, moduleMgr *module.Manager, bpfLoader
*bpf.Loader, filter MonitorFilter) *ConnectionManager {
track, err := ip.NewConnTrack()
if err != nil {
log.Warnf("cannot create the connection tracker, %v", err)
}
+ // derive the resolution grace period from the flush period(+margin):
the ztunnel lb
+ // mapping arrives asynchronously and normally lands within one flush
period, so
+ // waiting a flush period plus a small margin lets short connections
resolve to the
+ // real pod IP instead of being reported with the raw service IP
+ flushPeriod := defaultFlushPeriod
+ if config != nil {
+ if parsed, perr := time.ParseDuration(config.Flush.Period);
perr == nil && parsed > 0 {
+ flushPeriod = parsed
+ }
+ }
mgr := &ConnectionManager{
moduleMgr: moduleMgr,
processOP:
moduleMgr.FindModule(process.ModuleName).(process.Operator),
@@ -150,12 +236,91 @@ func NewConnectionManager(_ *Config, moduleMgr
*module.Manager, bpfLoader *bpf.L
flushListeners: make([]FlusherListener, 0),
connectTracker: track,
connectionProtocolBreakMap: cache.NewExpiring(),
+ unresolvedRemotes: make(map[string]int64),
+ unresolvedByReason: make(map[string]int64),
+ resolutionGracePeriod: resolutionGraceFor(flushPeriod),
}
return mgr
}
+// resolutionGraceFor bounds the resolution-defer grace so it stays below
connectionDeleteDelayTime
+// (with one flush period of headroom). A connection is removed
connectionDeleteDelayTime after it
+// closes, and a deferred log can only be emitted at a flush cycle at or after
its resolution
+// deadline; a grace reaching past the delete time would let the connection be
deleted first and its
+// re-queued logs dropped. When even one flush period does not fit(an
unusually large configured
+// flush period), the grace is zero and ShouldDeferForResolution does not
defer at all - the log is
+// then emitted immediately with the raw IP, which is unresolved but never
dropped.
+func resolutionGraceFor(flushPeriod time.Duration) time.Duration {
+ grace := flushPeriod + resolutionDeferMargin
+ if maxGrace := connectionDeleteDelayTime - flushPeriod; grace >
maxGrace {
+ grace = maxGrace
+ }
+ if grace < 0 {
+ grace = 0
+ }
+ return grace
+}
+
+// ShouldDeferForResolution reports whether the connection's access logs
should be held
+// back for one more flush cycle because a resolver(the ztunnel lb mapping)
has not yet
+// attached the real destination. It is bounded per connection: the first time
a
+// connection is found pending, a deadline(now + grace) is recorded, and once
that
+// deadline passes the logs are flushed as-is even if still unresolved. This
closes the
+// short-connection race where the asynchronous ztunnel mapping event is
processed just
+// after the connection has already been flushed with the raw service IP.
+func (c *ConnectionManager) ShouldDeferForResolution(conn *ConnectionInfo)
bool {
+ // grace <= 0 means deferral is disabled(the flush period is too large
to fit a grace below
+ // connectionDeleteDelayTime, see resolutionGraceFor) - never hold a
log back in that case
+ if conn == nil || c.resolutionGracePeriod <= 0 {
+ return false
+ }
+ pending := false
+ for _, l := range c.flushListeners {
+ if r, ok := l.(ResolutionAwareFlusher); ok &&
r.IsResolutionPending(conn) {
+ pending = true
+ break
+ }
+ }
+ if !pending {
+ // resolved(or no resolver is waiting) - do not defer
+ return false
+ }
+ now := time.Now()
+ if conn.ResolutionDeadline == nil {
+ deadline := now.Add(c.resolutionGracePeriod)
+ conn.ResolutionDeadline = &deadline
+ return true
+ }
+ return now.Before(*conn.ResolutionDeadline)
+}
+
+// RecordResolutionLatency records how long a connection took from open to
being resolved by the
+// ztunnel correlation, into coarse buckets, so the periodic summary can show
the open->resolution
+// delay distribution. The >=15s bucket is the diagnostic smoking gun for the
late-mapping-event
+// problem(events delivered after the resolution-defer grace would have
already flushed the raw IP).
+func (c *ConnectionManager) RecordResolutionLatency(conn *ConnectionInfo) {
+ if conn == nil || conn.CreatedAt.IsZero() {
+ return
+ }
+ d := time.Since(conn.CreatedAt)
+ switch {
+ case d < time.Second:
+ c.resolutionLatencyUnder1s.Add(1)
+ case d < 5*time.Second:
+ c.resolutionLatency1to5s.Add(1)
+ case d < 15*time.Second:
+ c.resolutionLatency5to15s.Add(1)
+ default:
+ c.resolutionLatencyOver15s.Add(1)
+ }
+ if ms := d.Milliseconds(); ms > c.resolutionLatencyMaxMilli.Load() {
+ c.resolutionLatencyMaxMilli.Store(ms)
+ }
+}
+
func (c *ConnectionManager) Start(ctx context.Context, accessLogContext
*AccessLogContext) {
c.processOP.AddListener(c)
+ c.startUnresolvedRemoteReporter(ctx)
// starting to clean up the un-active connection in BPF
go func() {
@@ -267,6 +432,7 @@ func (c *ConnectionManager) buildRemoteAddress(e
*events.SocketConnectEvent, soc
}
}
+ c.remoteAddressBuildCount.Add(1)
// found local address with pid
if pid, exist := c.localIPWithPid[socket.DestIP]; exist && pid != 0 {
return c.buildLocalAddress(uint32(pid), socket.DestPort, socket)
@@ -277,6 +443,170 @@ func (c *ConnectionManager) buildRemoteAddress(e
*events.SocketConnectEvent, soc
return c.buildAddressFromRemote(socket.DestIP, socket.DestPort)
}
+// recordConnectionResolveResult records the final remote address resolve
result of the
+// connection, it MUST be called when the connection is being deleted from the
manager,
+// at that point all resolvers are finalized: the conntrack rewriting happens
on the
+// address building, and the ztunnel correlation could attach the real
destination on
+// any later flush, so judging any earlier would mis-count the resolved
connections
+func (c *ConnectionManager) recordConnectionResolveResult(connection
*ConnectionInfo) {
+ if connection == nil || connection.RPCConnection == nil {
+ return
+ }
+ remote := connection.RPCConnection.GetRemote()
+ if remote == nil || remote.GetIp() == nil {
+ // the remote address is resolved to a local monitored process,
not a raw IP
+ return
+ }
+ if connection.RPCConnection.GetAttachment() != nil {
+ // the ztunnel correlation attached the real destination
+ c.ztunnelResolvedRemoteCount.Add(1)
+ return
+ }
+ if connection.Socket != nil && connection.Socket.ConnTrackResolved {
+ // the conntrack query already rewrote the address to the real
peer(e.g. pod IP),
+ // the address is sent as a raw IP but the backend could
resolve it
+ c.conntrackResolvedRemoteCount.Add(1)
+ return
+ }
+ // ask the resolution-aware flusher(s) WHY this connection ended up
unresolved, so the summary
+ // can group the raw-IP socket pairs by the environment/source that
failed to provide a mapping
+ reason := "unresolved"
+ for _, l := range c.flushListeners {
+ if r, ok := l.(ResolutionAwareFlusher); ok {
+ if rs := r.UnresolvedReason(connection); rs != "" {
+ reason = rs
+ break
+ }
+ }
+ }
+ src := "unknown"
+ if connection.Socket != nil {
+ src = fmt.Sprintf("%s:%d", connection.Socket.SrcIP,
connection.Socket.SrcPort)
+ }
+ c.recordUnresolvedRemote(reason, fmt.Sprintf("%s->%s:%d", src,
remote.GetIp().GetHost(), remote.GetIp().GetPort()))
+}
+
+// recordUnresolvedRemote records a socket pair(src->dst) that cannot be
resolved by any of the
+// resolvers(local process, conntrack, ztunnel correlation) - it would reach
the backend as a raw
+// IP - together with the categorized reason, for the periodic summary. The
per-reason totals are
+// always counted; the per-pair map is bounded so a flood of distinct pairs
cannot grow it without
+// limit.
+func (c *ConnectionManager) recordUnresolvedRemote(reason, socketPair string) {
+ c.unresolvedRemoteCount.Add(1)
+ c.unresolvedRemoteLock.Lock()
+ defer c.unresolvedRemoteLock.Unlock()
+ c.unresolvedByReason[reason]++
+ key := reason + " " + socketPair
+ if _, exist := c.unresolvedRemotes[key]; !exist &&
len(c.unresolvedRemotes) >= unresolvedRemoteMaxTrack {
+ return
+ }
+ c.unresolvedRemotes[key]++
+}
+
+// drainUnresolvedRemotes returns, since the last report, the top un-resolved
socket pairs(each
+// prefixed with its reason) and the full per-reason breakdown, and resets the
tracking maps.
+func (c *ConnectionManager) drainUnresolvedRemotes() (topPairs, byReason
string) {
+ c.unresolvedRemoteLock.Lock()
+ pairs := c.unresolvedRemotes
+ reasons := c.unresolvedByReason
+ c.unresolvedRemotes = make(map[string]int64)
+ c.unresolvedByReason = make(map[string]int64)
+ c.unresolvedRemoteLock.Unlock()
+
+ sortByCount := func(m map[string]int64, topN int) string {
+ if len(m) == 0 {
+ return "none"
+ }
+ type kv struct {
+ k string
+ v int64
+ }
+ sorted := make([]kv, 0, len(m))
+ for k, v := range m {
+ sorted = append(sorted, kv{k: k, v: v})
+ }
+ sort.Slice(sorted, func(i, j int) bool { return sorted[i].v >
sorted[j].v })
+ if topN > 0 && len(sorted) > topN {
+ sorted = sorted[:topN]
+ }
+ items := make([]string, 0, len(sorted))
+ for _, s := range sorted {
+ items = append(items, fmt.Sprintf("%s=%d", s.k, s.v))
+ }
+ return strings.Join(items, ", ")
+ }
+ return sortByCount(pairs, unresolvedRemoteTopCount),
sortByCount(reasons, 0)
+}
+
+// startUnresolvedRemoteReporter periodically reports the summary of remote
addresses
+// that cannot be resolved(by neither the conntrack nor the ztunnel
correlation) at the info level
+func (c *ConnectionManager) startUnresolvedRemoteReporter(ctx context.Context)
{
+ go func() {
+ ticker := time.NewTicker(unresolvedRemoteReportInterval)
+ defer ticker.Stop()
+ var lastUnresolvedCount int64
+ for {
+ select {
+ case <-ticker.C:
+ unresolvedCount :=
c.unresolvedRemoteCount.Load()
+ if unresolvedCount == lastUnresolvedCount {
+ continue
+ }
+ intervalUnresolved := unresolvedCount -
lastUnresolvedCount
+ lastUnresolvedCount = unresolvedCount
+ conntrackStats := "not available"
+ if c.connectTracker != nil {
+ conntrackStats =
c.connectTracker.StatsString()
+ }
+ topPairs, byReason := c.drainUnresolvedRemotes()
+ // the built/resolved/unresolved totals and the
latency buckets are cumulative since
+ // start; the by-reason breakdown and the top
socket pairs are drained and reset every
+ // report. To keep the line internally
consistent the unresolved count is shown as the
+ // per-interval delta(which matches the
by-reason breakdown's window) next to the
+ // cumulative total, and each field states
which window it describes.
+ log.Infof("remote address resolve summary:
total remote addresses built(cumulative): %d, "+
+ "resolved by conntrack(cumulative): %d,
resolved by ztunnel correlation(cumulative): %d, "+
+ "unresolved(sent as raw IP) since last
report: %d(cumulative total: %d), "+
+ "conntrack stats(cumulative): {%s},
unresolved by reason since last report: {%s}, "+
+ "open->resolution latency
buckets(cumulative) {<1s: %d, 1-5s: %d, 5-15s: %d, >=15s: %d, max: %dms}, "+
+ "top unresolved socket pairs since last
report: %s",
+ c.remoteAddressBuildCount.Load(),
c.conntrackResolvedRemoteCount.Load(),
+ c.ztunnelResolvedRemoteCount.Load(),
intervalUnresolved, unresolvedCount,
+ conntrackStats, byReason,
+ c.resolutionLatencyUnder1s.Load(),
c.resolutionLatency1to5s.Load(),
+ c.resolutionLatency5to15s.Load(),
c.resolutionLatencyOver15s.Load(),
+ c.resolutionLatencyMaxMilli.Load(),
topPairs)
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+}
+
+// RetroResolveBySrc is the push side of the ztunnel correlation: when a
source-keyed mapping
+// arrives(a ConnectionResult / access-log event), the collector calls this so
every connection
+// still held in the manager for that source address is re-offered to the
flush listeners right
+// away, instead of waiting for the connection's own next flush cycle to pull
the cache. This
+// closes the window where a short connection's mapping event lands between
its flush cycles or
+// just as it would flush, which is the dominant residual "-|service|-" cause
under high load.
+func (c *ConnectionManager) RetroResolveBySrc(srcIP string, srcPort uint16) {
+ c.connections.IterCb(func(_ string, v interface{}) {
+ connection, ok := v.(*ConnectionInfo)
+ if !ok || connection.Socket == nil || connection.RPCConnection
== nil {
+ return
+ }
+ // only an unresolved client(outbound) leg for this exact
source is a candidate
+ if connection.RPCConnection.Attachment != nil ||
+ connection.Socket.Role != enums.ConnectionRoleClient ||
+ connection.Socket.SrcIP != srcIP ||
connection.Socket.SrcPort != srcPort {
+ return
+ }
+ for _, flush := range c.flushListeners {
+ flush.ReadyToFlushConnection(connection, nil)
+ }
+ })
+}
+
func (c *ConnectionManager) connectionPostHandle(connection *ConnectionInfo,
event events.Event) {
if connection == nil {
return
@@ -373,6 +703,7 @@ func (c *ConnectionManager) buildConnection(event
*events.SocketConnectEvent, so
PID: event.PID,
Socket: socket,
LastCheckExistTime: time.Now(),
+ CreatedAt: time.Now(),
ProtocolBreak: protocolBreak,
}
}
@@ -588,7 +919,7 @@ func (c *ConnectionManager)
updateMonitorStatusForProcess(pid int32, monitor boo
func (c *ConnectionManager) OnBuildConnectionLogFinished() {
// delete all connections which marked as deletable
// all deletable connection events been sent
- deletableConnections := make(map[string]bool)
+ deletableConnections := make(map[string]*ConnectionInfo)
now := time.Now()
c.connections.IterCb(func(key string, v interface{}) {
con, ok := v.(*ConnectionInfo)
@@ -609,11 +940,13 @@ func (c *ConnectionManager)
OnBuildConnectionLogFinished() {
}
if shouldDelete && now.After(*con.DeleteAfter) {
- deletableConnections[key] = true
+ deletableConnections[key] = con
}
})
- for key := range deletableConnections {
+ for key, con := range deletableConnections {
+ // the connection state is finalized, record the remote address
resolve result
+ c.recordConnectionResolveResult(con)
log.Debugf("deleting the connection in manager: %s", key)
c.connections.Remove(key)
}
diff --git a/pkg/accesslog/common/connection_resolution_test.go
b/pkg/accesslog/common/connection_resolution_test.go
new file mode 100644
index 0000000..4218b48
--- /dev/null
+++ b/pkg/accesslog/common/connection_resolution_test.go
@@ -0,0 +1,105 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 common
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apache/skywalking-rover/pkg/accesslog/events"
+)
+
+// fakeResolutionFlusher is a FlusherListener that also implements
ResolutionAwareFlusher
+// so the ShouldDeferForResolution listener dispatch can be exercised without
a real ztunnel
+type fakeResolutionFlusher struct {
+ pending bool
+}
+
+func (f *fakeResolutionFlusher) ReadyToFlushConnection(*ConnectionInfo,
events.Event) {}
+func (f *fakeResolutionFlusher) IsResolutionPending(*ConnectionInfo) bool
{ return f.pending }
+func (f *fakeResolutionFlusher) UnresolvedReason(*ConnectionInfo) string
{ return "test-unresolved" }
+
+// plainFlusher only implements FlusherListener(not ResolutionAwareFlusher),
it must never
+// cause a defer
+type plainFlusher struct{}
+
+func (p *plainFlusher) ReadyToFlushConnection(*ConnectionInfo, events.Event) {}
+
+func newTestManager(grace time.Duration, listeners ...FlusherListener)
*ConnectionManager {
+ return &ConnectionManager{
+ flushListeners: listeners,
+ resolutionGracePeriod: grace,
+ }
+}
+
+func TestShouldDeferForResolution(t *testing.T) {
+ t.Run("nil connection is never deferred", func(t *testing.T) {
+ mgr := newTestManager(time.Second*7,
&fakeResolutionFlusher{pending: true})
+ if mgr.ShouldDeferForResolution(nil) {
+ t.Fatal("nil connection should not be deferred")
+ }
+ })
+
+ t.Run("no resolution-aware listener is never deferred", func(t
*testing.T) {
+ mgr := newTestManager(time.Second*7, &plainFlusher{})
+ conn := &ConnectionInfo{}
+ if mgr.ShouldDeferForResolution(conn) {
+ t.Fatal("a plain flusher must not trigger a defer")
+ }
+ })
+
+ t.Run("not pending is not deferred", func(t *testing.T) {
+ mgr := newTestManager(time.Second*7,
&fakeResolutionFlusher{pending: false})
+ conn := &ConnectionInfo{}
+ if mgr.ShouldDeferForResolution(conn) {
+ t.Fatal("a not-pending connection must not be deferred")
+ }
+ if conn.ResolutionDeadline != nil {
+ t.Fatal("no deadline should be recorded when not
pending")
+ }
+ })
+
+ t.Run("pending sets a deadline and defers on the first call", func(t
*testing.T) {
+ mgr := newTestManager(time.Second*7,
&fakeResolutionFlusher{pending: true})
+ conn := &ConnectionInfo{}
+ if !mgr.ShouldDeferForResolution(conn) {
+ t.Fatal("a pending connection should be deferred on the
first flush")
+ }
+ if conn.ResolutionDeadline == nil {
+ t.Fatal("a deadline should be recorded on the first
defer")
+ }
+ })
+
+ t.Run("pending within the grace deadline keeps deferring", func(t
*testing.T) {
+ mgr := newTestManager(time.Second*7,
&fakeResolutionFlusher{pending: true})
+ future := time.Now().Add(time.Second * 3)
+ conn := &ConnectionInfo{ResolutionDeadline: &future}
+ if !mgr.ShouldDeferForResolution(conn) {
+ t.Fatal("a pending connection within its deadline
should keep deferring")
+ }
+ })
+
+ t.Run("pending past the grace deadline stops deferring", func(t
*testing.T) {
+ mgr := newTestManager(time.Second*7,
&fakeResolutionFlusher{pending: true})
+ past := time.Now().Add(-time.Second)
+ conn := &ConnectionInfo{ResolutionDeadline: &past}
+ if mgr.ShouldDeferForResolution(conn) {
+ t.Fatal("a connection past its deadline must be flushed
even if still pending")
+ }
+ })
+}
diff --git a/pkg/accesslog/runner.go b/pkg/accesslog/runner.go
index 5dce05d..6b45a33 100644
--- a/pkg/accesslog/runner.go
+++ b/pkg/accesslog/runner.go
@@ -221,6 +221,12 @@ func (r *Runner) buildProtocolLog(protocolLog
common.ProtocolLog) (*common.Conne
}
return nil, nil, nil, true
}
+ // hold the log for one more flush cycle while the ztunnel lb mapping
is still being
+ // correlated(bounded per-connection), so a short connection resolves
to the real pod
+ // IP instead of being emitted with the raw service IP
+ if r.context.ConnectionMgr.ShouldDeferForResolution(connection) {
+ return nil, nil, nil, true
+ }
kernelLogs := make([]*v3.AccessLogKernelLog, 0)
for _, kl := range protocolLog.RelateKernelLogs() {
event :=
forwarder.BuildKernelLogFromEvent(common.LogTypeKernelTransfer, kl)
@@ -248,6 +254,12 @@ func (r *Runner) buildKernelLog(kernelLog
common.KernelLog) (*common.ConnectionI
}
return nil, nil, true
}
+ // hold the log for one more flush cycle while the ztunnel lb mapping
is still being
+ // correlated(bounded per-connection), so a short connection resolves
to the real pod
+ // IP instead of being emitted with the raw service IP
+ if r.context.ConnectionMgr.ShouldDeferForResolution(connection) {
+ return nil, nil, true
+ }
event := forwarder.BuildKernelLogFromEvent(kernelLog.Type(),
kernelLog.Event())
return connection, event, false
}
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 018c5c4..c501bac 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -19,17 +19,30 @@ package logger
import (
"strings"
+ "sync"
"github.com/sirupsen/logrus"
)
var (
- root = initializeDefaultLogger()
+ // root is the base logger; its level follows the global "logger.level"
config.
+ root = initializeLogger(DefaultLoggerLevel)
+ // debugRoot is always at DebugLevel. A module elevated through
+ // "logger.debug_modules" binds its whole entry here, so ALL of its
levels
+ // (debug included) are emitted regardless of the global level.
+ debugRoot = initializeLogger(logrus.DebugLevel)
+
+ // registry keeps every logger created(most are package-level vars
created at
+ // import time, i.e. before setupLogger runs) so the debug allowlist
can be
+ // re-applied once the config is loaded.
+ registryMux sync.Mutex
+ registry []*Logger
+ debugPrefixes []string
)
type Logger struct {
*logrus.Entry
- module []string
+ moduleString string
}
// GetLogger for the module
@@ -38,9 +51,44 @@ func GetLogger(modules ...string) *Logger {
if len(modules) > 0 {
moduleString = strings.Join(modules, ".")
}
- return &Logger{Entry: root.WithField("module", moduleString), module:
modules}
+ l := &Logger{moduleString: moduleString}
+ l.apply()
+
+ registryMux.Lock()
+ registry = append(registry, l)
+ registryMux.Unlock()
+ return l
+}
+
+// apply (re)binds the underlying entry: a module in the debug allowlist logs
+// through debugRoot(always DebugLevel), every other module through root(gated
by
+// the global level). Binding the whole entry - rather than overriding only the
+// debug methods - keeps the level semantics monotonic: an elevated module
never
+// drops its own info/warn lines while still emitting debug.
+func (l *Logger) apply() {
+ base := root
+ if matchDebugModule(l.moduleString) {
+ base = debugRoot
+ }
+ l.Entry = base.WithField("module", l.moduleString)
}
func (l *Logger) Enable(level logrus.Level) bool {
- return root.IsLevelEnabled(level)
+ return l.Logger.IsLevelEnabled(level)
+}
+
+// matchDebugModule reports whether the module(or one of its parent modules) is
+// in the configured debug allowlist. Matching is on module-segment boundaries,
+// so prefix "accesslog.collector" matches "accesslog.collector" and
+// "accesslog.collector.ztunnel" but not "accesslog.collectorx".
+func matchDebugModule(moduleString string) bool {
+ for _, p := range debugPrefixes {
+ if p == "" {
+ continue
+ }
+ if moduleString == p || strings.HasPrefix(moduleString, p+".") {
+ return true
+ }
+ }
+ return false
}
diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go
new file mode 100644
index 0000000..b14a270
--- /dev/null
+++ b/pkg/logger/logger_test.go
@@ -0,0 +1,142 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 logger
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// applyConfig runs the same setup path the module bootstrap uses and redirects
+// both loggers to buffers so the emitted output can be asserted.
+func applyConfig(t *testing.T, level, debugModules string) (base, debug
*bytes.Buffer) {
+ t.Helper()
+ if err := setupLogger(&Config{Level: level, DebugModules:
debugModules}); err != nil {
+ t.Fatalf("setupLogger: %v", err)
+ }
+ base, debug = &bytes.Buffer{}, &bytes.Buffer{}
+ root.SetOutput(base)
+ debugRoot.SetOutput(debug)
+ return base, debug
+}
+
+func emitted(base, debug *bytes.Buffer, marker string) bool {
+ return strings.Contains(base.String(), marker) ||
strings.Contains(debug.String(), marker)
+}
+
+func TestDebugModulesElevatesOnlyAllowlisted(t *testing.T) {
+ // only the ztunnel module is elevated; the connection module stays at
INFO.
+ base, debug := applyConfig(t, "info", "accesslog.collector.ztunnel")
+
+ ztunnel := GetLogger("accesslog", "collector", "ztunnel")
+ conn := GetLogger("access_log", "collector", "connection")
+
+ ztunnel.Debugf(" zt-debug %d", 1)
+ conn.Debugf("conn-debug %d", 2)
+
+ if !emitted(base, debug, "zt-debug") {
+ t.Errorf("expected the allowlisted ztunnel module debug log to
be emitted")
+ }
+ if emitted(base, debug, "conn-debug") {
+ t.Errorf("expected the non-allowlisted connection module debug
log to be suppressed at info level")
+ }
+}
+
+func TestDebugModulesPrefixMatchesChildrenNotSiblings(t *testing.T) {
+ // a parent prefix elevates its child module, but not a same-prefixed
sibling.
+ base, debug := applyConfig(t, "info", "accesslog.collector")
+
+ child := GetLogger("accesslog", "collector", "ztunnel")
+ // "accesslog.collectorx" must NOT match the "accesslog.collector"
prefix.
+ sibling := GetLogger("accesslog", "collectorx")
+
+ child.Debugf("child-debug")
+ sibling.Debugf("sibling-debug")
+ if !emitted(base, debug, "child-debug") {
+ t.Errorf("expected the child module debug log to be emitted")
+ }
+ if emitted(base, debug, "sibling-debug") {
+ t.Errorf("expected the sibling module debug log to be
suppressed")
+ }
+}
+
+func TestGlobalDebugLevelEmitsAllModules(t *testing.T) {
+ // with no allowlist but a global debug level, every module logs at
debug.
+ base, debug := applyConfig(t, "debug", "")
+
+ anyMod := GetLogger("access_log", "collector", "connection")
+ anyMod.Debugf("global-debug")
+ if !emitted(base, debug, "global-debug") {
+ t.Errorf("expected debug logs when the global level is debug")
+ }
+}
+
+func TestInfoStillEmittedForNonAllowlistedModule(t *testing.T) {
+ // elevating one module must not suppress the info logs of the others.
+ base, debug := applyConfig(t, "info", "accesslog.collector.ztunnel")
+
+ conn := GetLogger("access_log", "collector", "connection")
+ conn.Infof("conn-info")
+ if !emitted(base, debug, "conn-info") {
+ t.Errorf("expected info logs to be emitted for non-allowlisted
modules")
+ }
+}
+
+// TestElevatedModuleDoesNotDropInfoAboveGlobalLevel guards the
severity-inversion
+// bug: an elevated module must emit its OWN info/warn lines even when the
global
+// level is higher(here warn), not just its debug lines.
+func TestElevatedModuleDoesNotDropInfoAboveGlobalLevel(t *testing.T) {
+ base, debug := applyConfig(t, "warn", "accesslog.collector.ztunnel")
+
+ zt := GetLogger("accesslog", "collector", "ztunnel")
+ zt.Debugf("zt-elevated-debug")
+ zt.Infof("zt-elevated-info")
+ zt.Warnf("zt-elevated-warn")
+
+ if !emitted(base, debug, "zt-elevated-debug") {
+ t.Errorf("expected the elevated module to emit debug even when
global level is warn")
+ }
+ if !emitted(base, debug, "zt-elevated-info") {
+ t.Errorf("expected the elevated module to emit info even when
global level is warn(severity inversion)")
+ }
+ if !emitted(base, debug, "zt-elevated-warn") {
+ t.Errorf("expected the elevated module to emit warn")
+ }
+
+ // a non-elevated module still respects the global(warn) level: info
dropped.
+ other := GetLogger("access_log", "collector", "connection")
+ other.Infof("other-info-dropped")
+ if emitted(base, debug, "other-info-dropped") {
+ t.Errorf("expected a non-elevated module info to be dropped at
global warn level")
+ }
+}
+
+// TestSetupReAppliesToLoggersCreatedBeforeConfig covers the registry re-apply
+// loop, the sole mechanism that elevates package-level loggers created at
import
+// time(before setupLogger runs).
+func TestSetupReAppliesToLoggersCreatedBeforeConfig(t *testing.T) {
+ // create the logger BEFORE the config that should elevate it exists.
+ pre := GetLogger("accesslog", "collector", "ztunnel")
+
+ base, debug := applyConfig(t, "info", "accesslog.collector.ztunnel")
+ pre.Debugf("pre-created-debug")
+ if !emitted(base, debug, "pre-created-debug") {
+ t.Errorf("expected setupLogger to re-apply the debug allowlist
to a logger created before it ran")
+ }
+}
diff --git a/pkg/logger/settings.go b/pkg/logger/settings.go
index 7705bb9..59531ba 100644
--- a/pkg/logger/settings.go
+++ b/pkg/logger/settings.go
@@ -18,6 +18,8 @@
package logger
import (
+ "strings"
+
"github.com/sirupsen/logrus"
)
@@ -26,26 +28,48 @@ const (
)
type Config struct {
+ // Level is the global lowest level that is allowed to be printed.
Level string `mapstructure:"level"`
+ // DebugModules is a comma separated list of module(name prefix) to
elevate to
+ // the debug level regardless of Level, e.g.
"accesslog.collector.ztunnel".
+ // This keeps the high volume modules quiet(bounding the logging
allocation
+ // churn) while still getting debug detail for the modules under
investigation.
+ DebugModules string `mapstructure:"debug_modules"`
}
// setupLogger when Bootstrap
func setupLogger(config *Config) (err error) {
- return updateLogger(root, config)
-}
-
-func updateLogger(log *logrus.Logger, config *Config) error {
level, err := logrus.ParseLevel(config.Level)
if err != nil {
return err
}
- log.SetLevel(level)
+ root.SetLevel(level)
+ // debugRoot stays at DebugLevel; only the allowlist decides who uses
it.
+
+ debugPrefixes = parseDebugModules(config.DebugModules)
+ // re-apply the allowlist to the loggers created before the config was
loaded
+ // (package-level GetLogger vars run at import time).
+ registryMux.Lock()
+ for _, l := range registry {
+ l.apply()
+ }
+ registryMux.Unlock()
return nil
}
-func initializeDefaultLogger() *logrus.Logger {
+func parseDebugModules(value string) []string {
+ res := make([]string, 0)
+ for _, p := range strings.Split(value, ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ res = append(res, p)
+ }
+ }
+ return res
+}
+
+func initializeLogger(level logrus.Level) *logrus.Logger {
l := logrus.New()
- l.SetLevel(DefaultLoggerLevel)
+ l.SetLevel(level)
l.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
DisableColors: true,
diff --git a/pkg/process/finders/kubernetes/template.go
b/pkg/process/finders/kubernetes/template.go
index cea08f9..617f82b 100644
--- a/pkg/process/finders/kubernetes/template.go
+++ b/pkg/process/finders/kubernetes/template.go
@@ -151,6 +151,16 @@ func (t *TemplatePodJudgment) HasContainer(name string)
bool {
return true
}
}
+ // native sidecars(e.g. istio-proxy on k8s 1.28+) run as
restartPolicy:Always initContainers,
+ // so a sidecar-injected pod carries the sidecar in InitContainers
rather than Containers. Check
+ // both, otherwise the "exclude pods that have <sidecar>" filter
silently fails to match a
+ // native-sidecar pod and the pod(its app process) gets monitored
anyway.
+ // nolint
+ for _, c := range t.pc.Pod.Spec.InitContainers {
+ if c.Name == name {
+ return true
+ }
+ }
return false
}
diff --git a/pkg/tools/btf/linker.go b/pkg/tools/btf/linker.go
index 271a10d..f8aaee9 100644
--- a/pkg/tools/btf/linker.go
+++ b/pkg/tools/btf/linker.go
@@ -247,6 +247,13 @@ func (m *Linker) OpenUProbeExeFile(path string)
*UProbeExeFile {
}
}
+// Found reports whether the executable was opened successfully. When it is
false the AddLink*
+// calls are silent no-ops, so a caller that must not proceed(e.g. arm a BPF
gate) without a real
+// uprobe attached should check this instead of assuming AddLink succeeded.
+func (u *UProbeExeFile) Found() bool {
+ return u.found
+}
+
func (u *UProbeExeFile) AddLink(symbol string, enter, exit *ebpf.Program) {
u.AddLinkWithType(symbol, true, enter)
u.AddLinkWithType(symbol, false, exit)
diff --git a/pkg/tools/host/file.go b/pkg/tools/host/file.go
index ac02ec1..b376288 100644
--- a/pkg/tools/host/file.go
+++ b/pkg/tools/host/file.go
@@ -23,8 +23,9 @@ import (
)
var (
- hostProcMappingPath string
- hostEtcMappingPath string
+ hostProcMappingPath string
+ hostEtcMappingPath string
+ hostVarLogPodsMappingPath string
)
func init() {
@@ -34,6 +35,7 @@ func init() {
os.Setenv("HOST_PROC", hostProcMappingPath)
}
hostEtcMappingPath = os.Getenv("ROVER_HOST_ETC_MAPPING")
+ hostVarLogPodsMappingPath = os.Getenv("ROVER_HOST_VAR_LOG_PODS_MAPPING")
}
func GetHostProcInHost(procSubPath string) string {
@@ -50,6 +52,19 @@ func GetHostEtcInHost(etcSubPath string) string {
return cleanPath("/etc/" + etcSubPath)
}
+// GetHostVarLogPodsInHost resolves a path under the kubelet pod-log
directory(/var/log/pods on the
+// host) as seen from inside the agent container. That directory is where the
kubelet writes every
+// pod's container logs for ALL CRI runtimes(containerd, CRI-O, cri-dockerd),
so it is the runtime
+// independent place to tail the ztunnel access log. The host mount point is
injected through
+// ROVER_HOST_VAR_LOG_PODS_MAPPING(the same pattern as
ROVER_HOST_PROC_MAPPING), so it is not
+// hard-coded; when unset the real host path /var/log/pods is used.
+func GetHostVarLogPodsInHost(subPath string) string {
+ if hostVarLogPodsMappingPath != "" {
+ return cleanPath(hostVarLogPodsMappingPath + "/" + subPath)
+ }
+ return cleanPath("/var/log/pods/" + subPath)
+}
+
func cleanPath(p string) string {
return path.Clean(p)
}
diff --git a/pkg/tools/ip/conntrack.go b/pkg/tools/ip/conntrack.go
index 831d2cc..e0b17b6 100644
--- a/pkg/tools/ip/conntrack.go
+++ b/pkg/tools/ip/conntrack.go
@@ -20,6 +20,7 @@ package ip
import (
"fmt"
"net"
+ "sync/atomic"
"syscall"
"github.com/florianl/go-conntrack"
@@ -42,6 +43,12 @@ var numberStrategies = []struct {
type ConnTrack struct {
tracker *conntrack.Nfct
+
+ // counters for quantifying the conntrack resolve success/miss rate
+ queryCount atomic.Int64
+ resolvedCount atomic.Int64
+ notFoundCount atomic.Int64
+ ignoredCount atomic.Int64
}
func NewConnTrack() (*ConnTrack, error) {
@@ -60,6 +67,7 @@ func (c *ConnTrack) UpdateRealPeerAddress(addr *SocketPair)
error {
}
tuple := c.parseSocketToTuple(addr)
+ c.queryCount.Add(1)
for _, info := range numberStrategies {
tuple.Proto.Number = &(info.proto)
@@ -67,25 +75,39 @@ func (c *ConnTrack) UpdateRealPeerAddress(addr *SocketPair)
error {
session, e := c.tracker.Get(conntrack.Conntrack, family,
conntrack.Con{Origin: tuple})
if e != nil {
// try to get the reply session, if the info not exists
or from accept events, have error is normal
- return fmt.Errorf("cannot get the conntrack session,
type: %s, family: %d, origin src: %s:%d, origin dest: %s:%d, error: %v",
info.name,
- family, tuple.Src, *tuple.Proto.SrcPort,
tuple.Dst, *tuple.Proto.DstPort, e)
+ c.notFoundCount.Add(1)
+ return fmt.Errorf("cannot get the conntrack session,
type: %s, family: %d, origin src: %s:%d, origin dest: %s:%d, error: %v, stats:
%s", info.name,
+ family, tuple.Src, *tuple.Proto.SrcPort,
tuple.Dst, *tuple.Proto.DstPort, e, c.StatsString())
}
if res := c.filterValidateReply(session, tuple); res != nil {
if !ShouldIgnoreConntrack(addr.DestIP,
res.Src.String(), *res.Proto.SrcPort) {
+ c.resolvedCount.Add(1)
addr.DestIP = res.Src.String()
addr.NeedConnTrack = false
- log.Debugf("update real peer address from
conntrack: %s:%d", addr.DestIP, addr.DestPort)
+ addr.ConnTrackResolved = true
+ log.Debugf("update real peer address from
conntrack: %s:%d, stats: %s", addr.DestIP, addr.DestPort, c.StatsString())
} else {
- log.Debugf("ignore conntrack, original dest IP:
%s:%d, conntrack IP: %s:%d",
- addr.DestIP, addr.DestPort,
res.Src.String(), *res.Proto.SrcPort)
+ c.ignoredCount.Add(1)
+ log.Debugf("ignore conntrack, original dest IP:
%s:%d, conntrack IP: %s:%d, stats: %s",
+ addr.DestIP, addr.DestPort,
res.Src.String(), *res.Proto.SrcPort, c.StatsString())
}
return nil
}
}
+ c.notFoundCount.Add(1)
+ log.Debugf("no matched conntrack reply tuple found, origin src: %s:%d,
origin dest: %s:%d, stats: %s",
+ addr.SrcIP, addr.SrcPort, addr.DestIP, addr.DestPort,
c.StatsString())
return nil
}
+// StatsString returns the cumulative conntrack query result counters,
+// used to quantify the resolve miss rate from logs
+func (c *ConnTrack) StatsString() string {
+ return fmt.Sprintf("queries: %d, resolved: %d, not found: %d, ignored:
%d",
+ c.queryCount.Load(), c.resolvedCount.Load(),
c.notFoundCount.Load(), c.ignoredCount.Load())
+}
+
func ShouldIgnoreConntrack(originalDestIP, conntrackIP string, conntrackPort
uint16) bool {
// if the original dest IP is not local host
// and the conntrack IP is local host, and port is 15001, such as
127.0.0.1:15001, means the conntrack is to istio-proxy
diff --git a/pkg/tools/ip/tcpresolver.go b/pkg/tools/ip/tcpresolver.go
index 9f09309..357ec98 100644
--- a/pkg/tools/ip/tcpresolver.go
+++ b/pkg/tools/ip/tcpresolver.go
@@ -36,6 +36,9 @@ type SocketPair struct {
DestPort uint16
NeedConnTrack bool
+ // ConnTrackResolved is true when the conntrack query successfully
rewrote
+ // the DestIP to the real peer address
+ ConnTrackResolved bool
}
func (s *SocketPair) IsValid() bool {
diff --git a/pkg/tools/netns/netns.go b/pkg/tools/netns/netns.go
new file mode 100644
index 0000000..45dfd84
--- /dev/null
+++ b/pkg/tools/netns/netns.go
@@ -0,0 +1,77 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 netns
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+
+ "golang.org/x/sys/unix"
+)
+
+// RunInNetNS executes fn with the current OS thread switched into the network
+// namespace referenced by netnsPath(e.g. /proc/<pid>/ns/net), and restores the
+// original network namespace afterwards.
+//
+// Requires CAP_SYS_ADMIN. Everything that must happen inside the target
+// namespace(creating sockets, dialing, reading responses) should be done
+// inside fn, since only the calling OS thread is switched.
+//
+// Failure contract: if switching BACK to the original namespace fails,
RunInNetNS
+// returns the error but deliberately does NOT unlock the OS thread - it
leaves the
+// goroutine locked to that thread so the Go runtime destroys the
thread(instead of
+// reusing it for other goroutines) while it is still in the target namespace.
The
+// caller MUST therefore treat a restore error as terminal for the goroutine
and
+// return/exit promptly, doing no further work that could run in the wrong
namespace.
+// Run RunInNetNS from a dedicated, short-lived goroutine so a poisoned thread
is
+// discarded when that goroutine exits.
+func RunInNetNS(netnsPath string, fn func() error) error {
+ target, err := os.Open(netnsPath)
+ if err != nil {
+ return fmt.Errorf("open target netns %s error: %w", netnsPath,
err)
+ }
+ defer target.Close()
+
+ runtime.LockOSThread()
+ // NOTE: open through the container's own procfs instead of the host
proc mapping:
+ // thread-self is a kernel magic symlink which always resolves to the
calling thread
+ // itself on any procfs instance, the host proc mapping is only
required when
+ // accessing OTHER processes' entries
+ origin, err := os.Open("/proc/thread-self/ns/net")
+ if err != nil {
+ runtime.UnlockOSThread()
+ return fmt.Errorf("open current netns error: %w", err)
+ }
+ defer origin.Close()
+
+ if err := unix.Setns(int(target.Fd()), unix.CLONE_NEWNET); err != nil {
+ runtime.UnlockOSThread()
+ return fmt.Errorf("enter netns %s error: %w", netnsPath, err)
+ }
+
+ fnErr := fn()
+
+ if err := unix.Setns(int(origin.Fd()), unix.CLONE_NEWNET); err != nil {
+ // cannot switch back to the original namespace, keep the
thread locked
+ // so the runtime destroys it instead of reusing it for other
goroutines
+ return fmt.Errorf("restore original netns error: %v, fn error:
%v", err, fnErr)
+ }
+ runtime.UnlockOSThread()
+ return fnErr
+}
diff --git a/pkg/tools/netns/netns_test.go b/pkg/tools/netns/netns_test.go
new file mode 100644
index 0000000..774a5ec
--- /dev/null
+++ b/pkg/tools/netns/netns_test.go
@@ -0,0 +1,173 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you 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 netns
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "syscall"
+ "testing"
+
+ "golang.org/x/sys/unix"
+)
+
+const currentThreadNetNSPath = "/proc/thread-self/ns/net"
+
+func netnsInode(t *testing.T, path string) uint64 {
+ t.Helper()
+ stat, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat %s error: %v", path, err)
+ }
+ sys, ok := stat.Sys().(*syscall.Stat_t)
+ if !ok {
+ t.Fatalf("unexpected stat type for %s", path)
+ }
+ return sys.Ino
+}
+
+func TestRunInNetNSErrorPaths(t *testing.T) {
+ invalidFile := filepath.Join(t.TempDir(), "not-a-netns")
+ if err := os.WriteFile(invalidFile, []byte("test"), 0o600); err != nil {
+ t.Fatalf("prepare the invalid target file error: %v", err)
+ }
+ cases := []struct {
+ name string
+ target string
+ }{
+ {"target path does not exist",
"/proc/not-exist-process/ns/net"},
+ {"target is not a network namespace", invalidFile},
+ }
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ executed := false
+ err := RunInNetNS(tt.target, func() error {
+ executed = true
+ return nil
+ })
+ if err == nil {
+ t.Fatalf("expected an error when %s", tt.name)
+ }
+ if executed {
+ t.Fatalf("the fn must not run when entering the
netns failed(%s)", tt.name)
+ }
+ })
+ }
+}
+
+func TestRunInNetNSSameNamespace(t *testing.T) {
+ // lock the thread so the baseline and the after-check read the same
thread
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ originInode := netnsInode(t, currentThreadNetNSPath)
+
+ executed := false
+ err := RunInNetNS(currentThreadNetNSPath, func() error {
+ executed = true
+ if inode := netnsInode(t, currentThreadNetNSPath); inode !=
originInode {
+ return fmt.Errorf("the fn is not running in the target
netns, inode: %d, want: %d", inode, originInode)
+ }
+ return nil
+ })
+ if err != nil {
+ // entering a netns(even the same one) requires CAP_SYS_ADMIN
+ if errors.Is(err, syscall.EPERM) {
+ t.Skipf("requires CAP_SYS_ADMIN to run: %v", err)
+ }
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !executed {
+ t.Fatal("the fn is not executed")
+ }
+ if inode := netnsInode(t, currentThreadNetNSPath); inode != originInode
{
+ t.Fatalf("the original netns is not restored after RunInNetNS")
+ }
+
+ // the error of the fn should be propagated to the caller
+ sentinel := errors.New("sentinel error")
+ if err := RunInNetNS(currentThreadNetNSPath, func() error { return
sentinel }); !errors.Is(err, sentinel) {
+ t.Fatalf("the fn error is not propagated, got: %v", err)
+ }
+}
+
+// TestRunInNetNSEntersDistinctNamespace enters a genuinely DIFFERENT network
+// namespace and asserts the inode observed inside fn changes(setns took
effect)
+// and reverts afterwards(restore took effect). Unlike the same-namespace case,
+// this fails if setns or the restore were silently dropped.
+func TestRunInNetNSEntersDistinctNamespace(t *testing.T) {
+ // build a distinct netns on a dedicated OS thread and expose its
procfs path;
+ // the thread stays parked(via release) so the namespace is kept alive.
+ type nsRef struct {
+ path string
+ inode uint64
+ err error
+ }
+ ready := make(chan nsRef, 1)
+ release := make(chan struct{})
+ go func() {
+ // permanently pin(and never unlock) this thread: after Unshare
it lives in
+ // the new netns and must be discarded, not reused by other
goroutines.
+ runtime.LockOSThread()
+ if err := unix.Unshare(unix.CLONE_NEWNET); err != nil {
+ ready <- nsRef{err: err}
+ return
+ }
+ path := fmt.Sprintf("/proc/self/task/%d/ns/net", unix.Gettid())
+ stat, err := os.Stat(path)
+ if err != nil {
+ ready <- nsRef{err: err}
+ return
+ }
+ ready <- nsRef{path: path, inode:
stat.Sys().(*syscall.Stat_t).Ino}
+ <-release
+ }()
+ ns := <-ready
+ defer close(release)
+ if ns.err != nil {
+ if errors.Is(ns.err, syscall.EPERM) {
+ t.Skipf("requires CAP_SYS_ADMIN to create a network
namespace: %v", ns.err)
+ }
+ t.Fatalf("create a distinct netns error: %v", ns.err)
+ }
+
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ originInode := netnsInode(t, currentThreadNetNSPath)
+ if originInode == ns.inode {
+ t.Fatalf("the new namespace(inode %d) unexpectedly equals the
origin", ns.inode)
+ }
+
+ err := RunInNetNS(ns.path, func() error {
+ if inode := netnsInode(t, currentThreadNetNSPath); inode !=
ns.inode {
+ return fmt.Errorf("fn is not running in the target
netns, inode: %d, want: %d", inode, ns.inode)
+ }
+ return nil
+ })
+ if err != nil {
+ if errors.Is(err, syscall.EPERM) {
+ t.Skipf("requires CAP_SYS_ADMIN to enter a netns: %v",
err)
+ }
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if inode := netnsInode(t, currentThreadNetNSPath); inode != originInode
{
+ t.Fatalf("the original netns(inode %d) is not restored after
RunInNetNS, got %d", originInode, inode)
+ }
+}