The story: services accumulate ports over time. A listener added for a new feature. A debug port that was handy in staging. A port some integration used before it was retired. Each one widens the AuthorizationPolicy in front of the service, and the policy almost never gets tightened back. The platform team runs ambient, mTLS is everywhere, an AuthorizationPolicy says svc-a may talk to svc-b, and still nobody can answer three basic questions: which ports are open between these services, which are actually carrying traffic, and which should be closed?
Ambient already produces every byte of data the audit needs. ztunnel writes an access log line for every connection it carries, with the destination port, the workload, the service and the caller's mTLS identity in it, and it enforces port-scoped L4 policy itself, no waypoint required. What is missing is only the plumbing: something that turns a stream of per-connection log lines into a durable, queryable answer. That plumbing is this lab, and it is deliberately boring: a DaemonSet, a ConfigMap and a CronJob.
What actually enforces a port in ambient
Enrolling a namespace in ambient gives every pod an mTLS identity and puts every connection inside an HBONE
tunnel. It does not restrict anything: ambient is default-allow, and any pod can reach any port on any other
pod until a policy says otherwise. Port-level control is standard Istio AuthorizationPolicy, and
the port granularity lives in spec.rules[].to[].operation.ports:
yamlyaml/20-policy/authz-svc-a-to-svc-b.yaml
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-svc-a-to-svc-b
namespace: port-audit
spec:
selector:
matchLabels:
app: svc-b
action: ALLOW
rules:
- from:
- source:
principals:
- port-audit/ns/port-audit/sa/svc-a
to:
- operation:
ports: ["8080", "8081", "8082", "8083", "8084",
"9090", "9091", "9092", "9093", "9094"]
Look closely at the principal: the trust domain is port-audit, not cluster.local.
Whatever installs your mesh decides that trust domain, so always check what yours actually is. This lab
installs the mesh with the Gloo Operator, where one ServiceMeshController CR renders istiod,
istio-cni and ztunnel (the single object STEP 1 applies) and the operator sets the trust domain to the SMC's
.spec.cluster, here port-audit, so every identity is
spiffe://port-audit/ns/…. You do not need the operator for any of this. The audit is plain
Kubernetes plus ztunnel behaviour and runs the same on any Solo Istio install, istioctl or Helm, where the
trust domain might be cluster.local or a value you chose. Either way, write the principal with
the wrong trust domain and it matches nothing, and because an ALLOW policy now selects the workload, svc-b
goes default-deny for everything, including the traffic you meant to allow. The fastest diagnosis is
the one this lab is built on: read src.identity in the ztunnel access log of a denied connection
and compare it, character by character, with the principal in the policy.
Everything in that policy — source identity, destination ports — is L4, and ztunnel enforces it directly on the destination node. No waypoint is involved anywhere in this lab. The moment an ALLOW policy selects a workload, everything the policy does not match is denied, which is why port 7070 (exposed by the Service but absent from the list above) becomes a hard deny the instant this policy lands.
One consequence worth spelling out for anyone pointing a scanner at the nodes: between nodes the only port
actually open on the wire is 15008, the HBONE tunnel. The application ports travel inside it.
Network-level tooling (netflow, port scans) sees one encrypted port and goes blind; port-level visibility has
to come from ztunnel itself. That is not a limitation of the audit, it is the reason the audit reads ztunnel's
logs.
Where the usage data comes from
ztunnel writes an access log entry for every connection it opens and completes, on both the source and the
destination node. With LOG_FORMAT=json set on the DaemonSet the entries are structured JSON, and
the fields carry everything the audit needs:
| Field | What it gives the audit |
|---|---|
dst.addr / dst.hbone_addr | the destination pod IP and port — the number the whole lab is about |
dst.service, dst.workload, dst.namespace | which Service and which pod the port belongs to — the per-service and per-pod report rows |
src.identity | the caller's SPIFFE identity, present when the connection is mesh mTLS — also how the collector skips kubelet probes |
direction | inbound vs outbound — the collector keeps inbound only, so a connection is counted once, on the node that owns the destination pod |
error | a denied connection completes with connection closed due to policy rejection — attempted-but-denied ports, for free |
The Prometheus side (istio_tcp_connections_opened_total and friends) identifies which workload
pairs talk and whether the traffic is mTLS, and it is the right tool for dashboards and alerting on flows.
The port number itself lives in the access log fields above, which is why the collector reads logs rather
than scraping metrics. For a live snapshot at the terminal there is also
istioctl ztunnel-config connections (open connections per node) and
istioctl ztunnel-config policies (the compiled port matchers ztunnel is enforcing).
The architecture
Two workers, one svc-b replica pinned to each by a topology spread constraint, so both node ztunnels have inbound connections to report and the per-node design has something real to show.
The port story the report has to catch, all on svc-b:
| Port | In the Service | In the policy | svc-a calls it | Report verdict |
|---|---|---|---|---|
8080 http-api | yes | yes | yes | used |
8081 inventory | yes | yes | yes | used |
8082 orders | yes | yes | yes | used |
9090 metrics | yes | yes | yes | used |
9091 health | yes | yes | yes | used |
9092 events | yes | yes | yes | used |
8083 admin | yes | yes | no | authz_allowed_never_used |
8084 debug | yes | yes | no | authz_allowed_never_used |
9093 profiling | yes | yes | no | authz_allowed_never_used |
9094 legacy-sync | yes | yes | no | authz_allowed_never_used |
7070 legacy | yes | no | probed once | denied_attempts |
One ConfigMap, one key per node
The report lives in a single central ConfigMap, but no component ever writes the whole object. Each collector owns exactly one data key, named after its node, and updates it with a JSON merge patch:
httpthe write — a merge patch on one key (from collector.py)
PATCH /api/v1/namespaces/port-audit-system/configmaps/port-audit-report
Content-Type: application/merge-patch+json
{"data": {"port-audit-worker": "H4sIAF...=="}} # gzip+base64 of {services, pods, denied}
That value is not raw JSON, it is gzip+base64 of the node's {services, pods, denied}. A whole
ConfigMap is a single etcd object with a hard 1 MiB budget shared across every key (the API server rejects
the object once the sum of its values passes 1,048,576 bytes), and port and pod sets compress heavily, 60×
and more on a real fleet, so the collector packs each key and the aggregator unpacks it. base64 keeps the
gzipped bytes a valid string so the key still lives in data and merge-patches like any other. The
aggregator's report.json stays plain JSON on purpose, so a human, jq, or the bonus
reporter agent can read the answer without unpacking anything.
Why this shape holds up with many concurrent writers:
- A merge patch is applied server-side, atomically, against the current object. It touches only the keys present in the patch body. Two nodes patching two different keys can land in any order and neither can overwrite the other. There is no client-side read-modify-write of the whole object anywhere in the flow, so there is nothing to race on.
-
The update-with-resourceVersion dance is the mechanism for the other pattern. If a writer
did GET the object, modify it and PUT it back, it would need optimistic concurrency: send the
resourceVersionit read, get a 409 Conflict if someone wrote in between, retry. The audit never needs that loop, precisely because no writer touches a key it does not own: collectors write their node key, the aggregator writesreport.json. -
The discipline is per key, not per object. Merge patch is last-write-wins per key with no
conflict detection, so two writers on the SAME key would silently clobber each other. One-writer-per-key is
the contract that makes the whole design safe. If you ever need conflict detection on a patch, include
metadata.resourceVersionin the patch body and the API server rejects it on mismatch; Server-Side Apply with a per-node--field-manageris the heavier alternative that tracks field ownership properly. - Why not one ConfigMap per node? It works, but it just moves the merge problem onto every reader. One object with per-node keys solves the merge exactly once, in the aggregator, and gives every consumer a single thing to fetch.
The other half of the design is which connections a node records. Each collector reads only its
local ztunnel (matched on spec.nodeName) and keeps only direction: inbound entries,
so a node's key means "ports that received traffic on pods living on this node" and a cross-node connection
is never double-counted from its client side. Entries without src.identity are dropped too,
which keeps kubelet health probes out of the data: a port only kubelet talks to is not a port your services
use.
Run the lab
Cluster, operator, ambient mesh, JSON logs
One script: kind (1 control-plane + 2 workers), Gateway API CRDs, the Solo Istio images pre-loaded, the
Gloo Operator, a ServiceMeshController with dataplaneMode: Ambient, and
LOG_FORMAT=json patched onto the ztunnel DaemonSet. The SMC has no env passthrough field, so
the patch targets the rendered DaemonSet directly, the same pattern the lab uses to wire the license env
onto istiod.
shterminal
export SECRETS_FILE=~/path/to/secrets.sh # exports SOLO_ISTIO_LICENSE_KEY
./scripts/setup-cluster.sh
ends with Cluster ready. Solo Istio 1.29.3 in AMBIENT mode, JSON access logs on.
Deploy svc-a and svc-b, enrolled in ambient
The namespace carries istio.io/dataplane-mode: ambient, so every pod gets identity and L4
enforcement from its node's ztunnel with no sidecar and no restart. svc-b runs two replicas with a topology
spread constraint that puts one on each worker.
shterminal
kubectl --context kind-port-audit apply -f yaml/10-app/
kubectl --context kind-port-audit -n port-audit get pods -o wide
svc-b pods on port-audit-worker AND port-audit-worker2, svc-a logging ok: 8080 8081 8082 9090 9091 9092 every 2s.
Apply the (deliberately over-provisioned) policy
Allows svc-a's identity to ten ports: 8080-8084 and 9090-9094. Not 7070. From this moment svc-b is default-deny for anything the rule does not match.
shterminal
kubectl --context kind-port-audit apply -f yaml/20-policy/
svc-a's loop keeps running untouched — its six ports are in the allow list.
Watch a real access log line
Pick either ztunnel and filter for inbound connections to the app namespace. This is the raw material the whole audit is built from.
shterminal
ZT=$(kubectl --context kind-port-audit -n istio-system get pods -l app=ztunnel -o name | head -1)
kubectl --context kind-port-audit -n istio-system logs "$ZT" --since=30s \
| jq -c 'select(.scope == "access" and .direction == "inbound" and .["dst.namespace"] == "port-audit")' \
| head -3
jsona real line (trimmed)
{
"level": "info",
"time": "2026-07-16T16:43:49.050106Z",
"scope": "access",
"message": "connection complete",
"src.addr": "10.244.2.12:42920",
"src.workload": "svc-a-64ff6cdff6-lhhv2",
"src.namespace": "port-audit",
"src.identity": "spiffe://port-audit/ns/port-audit/sa/svc-a",
"dst.addr": "10.244.1.6:15008",
"dst.hbone_addr": "10.244.1.6:8080",
"dst.service": "svc-b.port-audit.svc.cluster.local",
"dst.workload": "svc-b-6b5d6f9886-nk2h6",
"dst.namespace": "port-audit",
"dst.identity": "spiffe://port-audit/ns/port-audit/sa/default",
"direction": "inbound",
"bytes_sent": 205,
"bytes_recv": 74,
"duration": "0ms"
}
JSON entries with dst.service: svc-b.port-audit.svc.cluster.local and the port on dst.hbone_addr.
Probe the port the policy does not allow
7070 is in the Service, svc-b listens on it, and ztunnel refuses it: the TCP connection dies before a single application byte crosses, and the destination ztunnel logs the rejection with the caller's identity.
shterminal
kubectl --context kind-port-audit -n port-audit exec deploy/svc-a -- \
curl -s --max-time 3 http://svc-b:7070/ ; echo "exit=$?"
jsonthe deny, in the destination ztunnel's log
{
"level": "error",
"time": "2026-07-16T16:43:43.794875Z",
"scope": "access",
"message": "connection complete",
"src.addr": "10.244.2.12:42920",
"src.workload": "svc-a-64ff6cdff6-lhhv2",
"src.namespace": "port-audit",
"src.identity": "spiffe://port-audit/ns/port-audit/sa/svc-a",
"dst.addr": "10.244.1.6:15008",
"dst.hbone_addr": "10.244.1.6:7070",
"dst.service": "svc-b.port-audit.svc.cluster.local",
"dst.workload": "svc-b-6b5d6f9886-nk2h6",
"dst.namespace": "port-audit",
"dst.identity": "spiffe://port-audit/ns/port-audit/sa/default",
"direction": "inbound",
"bytes_sent": 0,
"bytes_recv": 0,
"duration": "0ms",
"error": "connection closed due to policy rejection: allow policies exist, but none allowed"
}
exit=52 or exit=56 (connection torn down), and an error level access log ending connection closed due to policy rejection: allow policies exist, but none allowed.
Deploy the audit stack
The report ConfigMap, tight RBAC (collectors may read ztunnel logs and patch exactly one ConfigMap; the aggregator may read Services and AuthorizationPolicies), the collector DaemonSet and the aggregator CronJob. The audit namespace is deliberately NOT enrolled in ambient, so its own API traffic never appears in the data it collects.
shterminal
./scripts/build-collector.sh # docker build collector.py + kind load
kubectl --context kind-port-audit apply -f yaml/30-audit/
kubectl --context kind-port-audit -n port-audit-system get pods
Both are short enough to read in full, both are Python, and both ship in the same image, stdlib only (the Kubernetes API is plain HTTPS plus a ServiceAccount token, so there is no pip layer). The collector holds ONE follow=true log connection per node, so every access log line is handled exactly once, and it patches its key only when a port set actually changes, debounced, with a 60-second heartbeat. Its classify() function is the entire usage taxonomy: completed without error = used, policy rejection = denied, any other error = ignored, so a refused port stays unused. The aggregator is a one-shot roll-up the CronJob runs with the same image and a different command.
pycollector/collector.py — what each DaemonSet pod runs
#!/usr/bin/env python3
"""collector.py - one per node (DaemonSet).
Streams the LOCAL ztunnel's JSON access logs over a single follow=true log
connection and merge-patches this node's key in the shared port-audit-report
ConfigMap. Compared to the polling shell version this replaced, every log
line is handled exactly once (no overlapping --since windows), the API server
holds one long-lived connection instead of a request every 30 seconds, and
the ConfigMap is only written when a port set actually changes (plus a
heartbeat so the key's `updated` stamp stays live).
Stdlib only, on purpose: the Kubernetes API is plain HTTPS with a
ServiceAccount bearer token, so the image needs no pip installs.
Classification is the report's whole taxonomy, unchanged from the shell
version:
completed without error -> used
error contains "policy rejection"-> denied
any other error (e.g. refused) -> ignored (stays in the unused column)
Entries without src.identity are dropped (kubelet probes are not usage).
"""
import base64
import gzip
import json
import os
import socket
import ssl
import time
import urllib.error
import urllib.parse
import urllib.request
API = "https://kubernetes.default.svc"
SA = "/var/run/secrets/kubernetes.io/serviceaccount"
NODE = os.environ["NODE_NAME"]
NS_APP = os.environ.get("NS_APP", "port-audit")
NS_AUDIT = os.environ.get("NS_AUDIT", "port-audit-system")
CM = os.environ.get("REPORT_CM", "port-audit-report")
ZT_NS = os.environ.get("ZTUNNEL_NAMESPACE", "istio-system")
# Debounce between change-driven patches, and the idle heartbeat interval.
DEBOUNCE = float(os.environ.get("PATCH_DEBOUNCE_SECONDS", "2"))
HEARTBEAT = int(os.environ.get("HEARTBEAT_SECONDS", "60"))
SSL_CTX = ssl.create_default_context(cafile=f"{SA}/ca.crt")
def log(msg):
print(time.strftime("%H:%M:%S", time.gmtime()), msg, flush=True)
def request(method, path, body=None, content_type="application/json", timeout=15):
# Re-read the token per request: projected SA tokens rotate.
with open(f"{SA}/token") as f:
token = f.read().strip()
req = urllib.request.Request(API + path, data=body, method=method)
req.add_header("Authorization", f"Bearer {token}")
if body is not None:
req.add_header("Content-Type", content_type)
return urllib.request.urlopen(req, context=SSL_CTX, timeout=timeout)
def find_ztunnel():
query = urllib.parse.urlencode({
"labelSelector": "app=ztunnel",
"fieldSelector": f"spec.nodeName={NODE},status.phase=Running",
})
with request("GET", f"/api/v1/namespaces/{ZT_NS}/pods?{query}") as resp:
items = json.load(resp).get("items", [])
return items[0]["metadata"]["name"] if items else None
def pack(state):
"""Serialise a node key as gzip+base64. One ConfigMap has a hard 1 MiB
budget shared across ALL its keys (the API server rejects the whole object
past 1,048,576 bytes), and port/pod sets compress heavily, so a compressed
key keeps a large fleet under the cap. base64 makes the gzip bytes a valid
UTF-8 string so it can live in `data` (not `binaryData`) and be merge-patched
per key like before."""
raw = json.dumps(state, sort_keys=True).encode()
return base64.b64encode(gzip.compress(raw)).decode()
def unpack(value):
"""Inverse of pack(); tolerates a pre-compression plain-JSON key."""
try:
return json.loads(gzip.decompress(base64.b64decode(value)))
except (ValueError, OSError):
return json.loads(value)
def seed_state():
# A restart keeps the history this node already reported.
try:
with request("GET", f"/api/v1/namespaces/{NS_AUDIT}/configmaps/{CM}") as resp:
data = json.load(resp).get("data") or {}
if NODE in data:
return unpack(data[NODE])
except (urllib.error.URLError, ValueError) as exc:
log(f"seed skipped: {exc}")
return {"node": NODE, "services": {}, "pods": {}, "denied": {}}
def patch_state(state):
# A JSON merge patch that touches ONLY this node's key: applied
# server-side and atomically, so concurrent writers on other nodes can
# never be clobbered and no resourceVersion retry loop is needed. The value
# is gzip+base64 (see pack()) to stay inside the ConfigMap's 1 MiB budget.
state["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
body = json.dumps({"data": {NODE: pack(state)}}).encode()
with request("PATCH", f"/api/v1/namespaces/{NS_AUDIT}/configmaps/{CM}",
body=body, content_type="application/merge-patch+json") as resp:
resp.read()
def classify(entry):
"""Return ('used'|'denied', service, workload, port) or None."""
if entry.get("scope") != "access" or entry.get("direction") != "inbound":
return None
if entry.get("dst.namespace") != NS_APP or not entry.get("src.identity"):
return None
addr = entry.get("dst.hbone_addr") or entry.get("dst.addr") or ""
try:
port = int(addr.rsplit(":", 1)[1])
except (IndexError, ValueError):
return None
error = entry.get("error") or ""
if "policy rejection" in error:
kind = "denied"
elif error:
return None # refused/reset etc: NOT used, NOT denied
else:
kind = "used"
return kind, entry.get("dst.service") or "unattributed", \
entry.get("dst.workload") or "unattributed", port
def record(state, kind, service, workload, port):
"""Union the observation into the state; return True if anything changed."""
changed = False
if kind == "denied":
buckets = [(state["denied"], service)]
else:
buckets = [(state["services"], service), (state["pods"], workload)]
for mapping, key in buckets:
ports = mapping.setdefault(key, [])
if port not in ports:
ports.append(port)
ports.sort()
changed = True
return changed
def stream(ztunnel, state):
"""Follow the ztunnel log stream; patch on change (debounced) and on
heartbeat. The read timeout doubles as the heartbeat timer: a quiet
stream raises timeout, we patch if needed, and the caller reconnects
with a sinceSeconds overlap (sets make the overlap harmless)."""
query = urllib.parse.urlencode({"follow": "true", "sinceSeconds": str(HEARTBEAT)})
resp = request("GET", f"/api/v1/namespaces/{ZT_NS}/pods/{ztunnel}/log?{query}",
timeout=HEARTBEAT)
log(f"streaming logs from {ztunnel}")
last_patch = 0.0
dirty = False
with resp:
for raw in resp:
try:
entry = json.loads(raw)
except ValueError:
continue
if isinstance(entry, dict):
hit = classify(entry)
if hit and record(state, *hit):
dirty = True
now = time.time()
if (dirty and now - last_patch >= DEBOUNCE) or now - last_patch >= HEARTBEAT:
patch_state(state)
last_patch = now
dirty = False
log(f"patched {CM} key={NODE}")
def main():
state = seed_state()
patch_state(state) # make the key exist (and stamp `updated`) immediately
log(f"collector up on {NODE}")
while True:
try:
ztunnel = find_ztunnel()
if not ztunnel:
log(f"no running ztunnel on {NODE} yet")
time.sleep(5)
continue
stream(ztunnel, state)
log("log stream ended (ztunnel rotated?), reconnecting")
except (urllib.error.URLError, socket.timeout, TimeoutError, OSError) as exc:
# Idle heartbeat lands here too: a quiet stream times out.
try:
patch_state(state)
except (urllib.error.URLError, OSError) as patch_exc:
log(f"heartbeat patch failed: {patch_exc}")
log(f"stream interrupted ({exc.__class__.__name__}), reconnecting")
time.sleep(2)
if __name__ == "__main__":
main()
dockercollector/Dockerfile — the whole image
# The per-node collector. Stdlib-only Python (the Kubernetes API is plain
# HTTPS + a ServiceAccount bearer token), so there is no pip layer to build
# or patch. Runs as a non-root user.
FROM python:3.12-alpine
COPY collector.py /collector.py
USER 65532:65532
ENTRYPOINT ["python3", "-u", "/collector.py"]
pycollector/aggregate.py — what the CronJob runs
#!/usr/bin/env python3
"""aggregate.py - runs as a CronJob, once a minute.
Merges every node's observations from the report ConfigMap, reads the
CONFIGURED surface (Service ports + AuthorizationPolicy allowed ports), and
writes the diff to the report.json key of the same ConfigMap: per service,
what is exposed, what is allowed, what is actually used, what is not, and
what got denied.
Same image and same stdlib-only style as collector.py; the two scripts are
the whole audit.
"""
import base64
import gzip
import json
import os
import ssl
import time
import urllib.request
API = "https://kubernetes.default.svc"
SA = "/var/run/secrets/kubernetes.io/serviceaccount"
NS_APP = os.environ.get("NS_APP", "port-audit")
NS_AUDIT = os.environ.get("NS_AUDIT", "port-audit-system")
CM = os.environ.get("REPORT_CM", "port-audit-report")
SSL_CTX = ssl.create_default_context(cafile=f"{SA}/ca.crt")
def request(method, path, body=None, content_type="application/json"):
with open(f"{SA}/token") as f:
token = f.read().strip()
req = urllib.request.Request(API + path, data=body, method=method)
req.add_header("Authorization", f"Bearer {token}")
if body is not None:
req.add_header("Content-Type", content_type)
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
return json.load(resp)
def unpack(value):
"""Node keys are written gzip+base64 by the collector (to stay inside the
ConfigMap's 1 MiB budget); decode them here. Falls back to plain JSON."""
try:
return json.loads(gzip.decompress(base64.b64decode(value)))
except (ValueError, OSError):
return json.loads(value)
def union(maps):
"""Merge a list of {key: [ports]} maps into one, unioning the port lists."""
merged = {}
for mapping in maps:
for key, ports in mapping.items():
merged[key] = sorted(set(merged.get(key, [])) | set(ports))
return merged
def allowed_ports(policies, app):
"""Ports named by ALLOW AuthorizationPolicies whose selector matches this
app label (the labelling convention this lab uses throughout)."""
ports = set()
for policy in policies:
spec = policy.get("spec", {})
if spec.get("action", "ALLOW") != "ALLOW":
continue
selector = spec.get("selector", {}).get("matchLabels", {})
if selector.get("app") != app:
continue
for rule in spec.get("rules", []):
for to in rule.get("to", []):
for port in to.get("operation", {}).get("ports", []):
ports.add(int(port))
return sorted(ports)
def main():
cm = request("GET", f"/api/v1/namespaces/{NS_AUDIT}/configmaps/{CM}")
nodes = [unpack(value) for key, value in (cm.get("data") or {}).items()
if key != "report.json"]
services = request("GET", f"/api/v1/namespaces/{NS_APP}/services")["items"]
policies = request(
"GET", f"/apis/security.istio.io/v1/namespaces/{NS_APP}/authorizationpolicies"
)["items"]
observed = union(n.get("services", {}) for n in nodes)
observed_pods = union(n.get("pods", {}) for n in nodes)
denied = union(n.get("denied", {}) for n in nodes)
rows = []
for svc in services:
name = svc["metadata"]["name"]
fqdn = f"{name}.{svc['metadata']['namespace']}.svc.cluster.local"
configured = sorted(p["port"] for p in svc["spec"].get("ports", []))
allowed = allowed_ports(policies, svc["spec"].get("selector", {}).get("app"))
used = observed.get(fqdn, [])
rows.append({
"service": name,
"configured_service_ports": configured,
"authz_allowed_ports": allowed,
"used_ports": used,
"unused_ports": [p for p in configured if p not in used],
"authz_allowed_never_used": [p for p in allowed if p not in used],
"denied_attempts": denied.get(fqdn, []),
"over_provisioned": any(p not in used for p in configured),
})
report = {
"generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"nodes_reporting": [n["node"] for n in nodes if "node" in n],
"services": rows,
"pods": observed_pods,
}
body = json.dumps({"data": {"report.json": json.dumps(report, indent=2)}}).encode()
request("PATCH", f"/api/v1/namespaces/{NS_AUDIT}/configmaps/{CM}",
body=body, content_type="application/merge-patch+json")
print("report.json updated:")
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()
one collector pod per node; a newly used port lands in the node key within ~2s of the connection completing, and the CronJob rebuilds report.json every minute.
Read a node's raw key
Each key is that node's own view: the ports that received mesh traffic on pods scheduled there. The value is written gzip+base64 (a whole ConfigMap has one 1 MiB budget across all its keys, and port sets compress heavily), so decode it to read it:
shterminal
kubectl --context kind-port-audit -n port-audit-system get cm port-audit-report -o json \
| jq -r '.data["port-audit-worker"]' | base64 -d | gunzip | jq .
jsona node key
{
"node": "port-audit-worker2",
"services": {
"svc-b.port-audit.svc.cluster.local": [8080, 8081, 8082, 9090, 9091, 9092]
},
"pods": {
"svc-b-68b48c6658-59qkv": [8080, 8081, 8082, 9090, 9091, 9092]
},
"denied": {
"svc-b.port-audit.svc.cluster.local": [7070]
},
"updated": "2026-07-16T17:05:33Z"
}
each worker's key lists the six used ports against its local replica; the denied probe appears under denied on the node that hosts the replica that took it.
Read the report
The aggregator has merged the node keys and diffed them against the configured surface. This is the artefact you would hand to the service owners.
shterminal
kubectl --context kind-port-audit -n port-audit-system get cm port-audit-report \
-o jsonpath='{.data.report\.json}' | jq .
jsonreport.json — the actual output
{
"generated": "2026-07-16T17:05:00Z",
"nodes_reporting": ["port-audit-worker", "port-audit-worker2"],
"services": [
{
"service": "svc-b",
"configured_service_ports": [7070, 8080, 8081, 8082, 8083, 8084,
9090, 9091, 9092, 9093, 9094],
"authz_allowed_ports": [8080, 8081, 8082, 8083, 8084,
9090, 9091, 9092, 9093, 9094],
"used_ports": [8080, 8081, 8082, 9090, 9091, 9092],
"unused_ports": [7070, 8083, 8084, 9093, 9094],
"authz_allowed_never_used": [8083, 8084, 9093, 9094],
"denied_attempts": [7070],
"over_provisioned": true
}
],
"pods": {
"svc-b-68b48c6658-wwfhd": [8080, 8081, 8082, 9090, 9091, 9092],
"svc-b-68b48c6658-59qkv": [8080, 8081, 8082, 9090, 9091, 9092]
}
}
six ports under used_ports, four under authz_allowed_never_used (8083, 8084, 9093, 9094), denied_attempts: [7070], both workers under nodes_reporting.
Act on it: shrink the policy to what is real
The report said 8083, 8084, 9093 and 9094 are allowed and never used, so they come out. The allowed set now
equals the observed set, and any future caller of those ports shows up in denied_attempts —
either a legitimate new consumer you then admit deliberately, or lateral movement that now goes nowhere.
shterminal
kubectl --context kind-port-audit apply -f yaml/40-remediate/
# prove it: 8083 was allowed a minute ago, now it is not
kubectl --context kind-port-audit -n port-audit exec deploy/svc-a -- \
curl -s --max-time 3 http://svc-b:8083/ ; echo "exit=$?"
the 8083 probe fails, svc-a's real traffic never blips, and the next report cycle shows the tightened ports in denied_attempts if anything keeps trying. The Service's unused port entries are the follow-up clean-up, owned by the service team.
The whole sequence, standup to assertions, is also automated:
SECRETS_FILE=… ./scripts/e2e.sh runs everything above with a five-minute traffic soak
(override with SOAK=seconds) and fails loudly unless the report says exactly what STEP 8 shows —
six ports used, four allowed-but-silent, one denied.
Bonus: an agent publishes the report to git
Just for fun, and to show off what Solo's agentic stack can do, this lab optionally deploys a
declarative AI agent on kagent. The agent calls a RemoteMCPServer wired to
GitHub's MCP server, reads report.json, and turns that structured data into clean, human-readable
markdown committed straight to a GitHub repo, prettier than a ConfigMap and only when the findings have
actually changed. No report generator, no CI job, no git plumbing in the collector, just a declarative agent
with two tool sets. It runs on OSS kagent and is entirely optional.
The GitHub MCP server is the auth answer
How does an agent get a token to push to GitHub? Through the GitHub MCP server. We use GitHub's
hosted MCP server at api.githubcopilot.com/mcp/, the local
github-mcp-server binary only speaks stdio and kagent reaches MCP over HTTP. A
Personal Access Token with Contents: read+write on the repo is injected as the
Authorization: Bearer header via RemoteMCPServer.headersFrom, read from the
github-mcp-pat Secret. The token is created from an env var at setup time and never lives in a
manifest. toolNames narrows the 44 tools the server exposes down to the two this agent may touch.
yamlyaml/50-agent/20-github-remotemcpserver.yaml
apiVersion: kagent.dev/v1alpha2
kind: RemoteMCPServer
metadata:
name: github-mcp
namespace: kagent
spec:
description: GitHub, hosted MCP server. File read/write on the report repo.
url: https://api.githubcopilot.com/mcp/
protocol: STREAMABLE_HTTP
timeout: 30s
headersFrom:
- name: Authorization
valueFrom:
type: Secret
name: github-mcp-pat # value is "Bearer <pat>", made at setup time
key: authorization
The agent is just a system message and two tools
No code, no image. type: Declarative means the behaviour is the system message plus the tools:
k8s_get_resources from the built-in kagent-tool-server to read the ConfigMap, and
get_file_contents + create_or_update_file from the GitHub MCP to read the current
file and write the new one. The target repo, branch and path come from the chat request, so nothing about a
specific repo is baked into the manifest.
yamlyaml/50-agent/30-agent.yaml (abridged)
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: port-audit-reporter
namespace: kagent
spec:
type: Declarative
declarative:
modelConfig: port-audit-reporter-model
systemMessage: |
Read the port-audit-report ConfigMap in port-audit-system, parse its
report.json, and render it to a fixed markdown layout (a table per
service). Then get_file_contents for the target path; commit with
create_or_update_file ONLY if the tables or recommendation differ,
ignoring the Generated: timestamp. Otherwise report "no change".
# full prompt in the repo
tools:
- type: McpServer
mcpServer:
apiGroup: kagent.dev
kind: RemoteMCPServer
name: kagent-tool-server
toolNames: [k8s_get_resources]
- type: McpServer
mcpServer:
apiGroup: kagent.dev
kind: RemoteMCPServer
name: github-mcp
toolNames: [get_file_contents, create_or_update_file]
On this build (kagent 0.9.4 with google-adk 2.4.0) the reporter agent runs on
claude-sonnet-4-5, the proven pairing and plenty for read, render and diff. Set the model in the
ModelConfig and every declarative agent that references it picks it up.
Install kagent and deploy the agent
One script installs OSS kagent (controller, the built-in tool server, the dashboard), creates the
github-mcp-pat Secret from GITHUB_PAT, and applies the three manifests above.
shterminal
export ANTHROPIC_API_KEY=... # the model the agent runs on
export GITHUB_PAT=... # a PAT with Contents:read+write on the repo
make kagent-setup
ends with github-mcp discovered 44 tools and the reporter agent Accepted.
Prompt it in the dashboard
Open the kagent dashboard, pick port-audit-reporter, and tell it where to publish. Headless
equivalent: make kagent-report REPO=owner/repo.
shterminal
make kagent-ui # http://localhost:8080
Then pick port-audit-reporter and send it one line, naming the repo:
promptkagent chat
Publish the port audit to tjorourke/solo-port-test at port-audit-report.md on main
first run commits the report and returns the commit URL; run it again with no traffic change and it says "no change" and commits nothing.
What lands in the repo, as GitHub renders it, emoji verdicts, a surface summary and a warning callout for the ports that should come out of the policy:
mdport-audit-report.md (source the agent commits)
# 🛡️ Ambient port audit
- **Generated:** 2026-07-16T19:57:20Z
- **Nodes reporting:** port-audit-worker, port-audit-worker2
- **Surface:** ✅ 6 used · ⚠️ 4 allowed but never used · 🚫 1 denied
## svc-b
| Port | In Service | In AuthzPolicy | Used | Verdict |
|-----:|:----------:|:--------------:|:----:|---------|
| 7070 | yes | no | no | 🚫 denied |
| 8080 | yes | yes | yes | ✅ used |
| 8083 | yes | yes | no | ⚠️ allowed, never used |
| 9094 | yes | yes | no | ⚠️ allowed, never used |
> [!WARNING]
> Remove ports 8083, 8084, 9093, 9094 from the AuthorizationPolicy — allowed
> but never used. Port 7070 has blocked probe attempts.
---
_Generated by the port-audit-reporter kagent agent._
The aggregator rewrites report.json every minute with a fresh Generated: timestamp,
so a byte compare would commit every minute forever. The agent is told to diff the tables and the
recommendation and ignore that line, so a commit means the findings changed, not that a clock ticked. That is
the whole point of a git diff here: the repo history becomes a timeline of when svc-b's real port surface
moved.
Taking it to a real cluster
-
Window vs forever. The lab accumulates from collector start. For "used in the last N days",
keep dated buckets in the node keys (
2026-07: [8080]) and drop buckets older than the window. - Past 1 MiB, shard or store. The node keys are already gzip+base64, but one ConfigMap still caps at 1 MiB across all keys. Go to one ConfigMap per node, or point the same collectors at a CRD status, a small API or a table.
- Reuse the log pipeline. If OTel, Fluent Bit or Loki already ships ztunnel stdout, the collector's jq filter becomes a query and the CronJob becomes a scheduled query.
-
Close the loop with policy. Aim for every service having an ALLOW policy whose
portsequals its observed set, withdenied_attemptsas the drift alarm.
See also
- Solo docs — Gloo Operator and ServiceMeshController
- Istio reference — AuthorizationPolicy (operation.ports)
- Related — Sidecar to Ambient Upgrade (the migration this audit usually follows)
- Related — Ambient metrics
Versions
Built and verified on:
v1.5.1hosted (api.githubcopilot.com)0.5.21.35claude-sonnet-4-51.29.3-solo2.4.00.9.4