Skip to content
Egress: reach services at the site

Egress: reach services at the site

Reach a database at the site

The billing-dev namespace needs the Oracle database 10.0.10.20:1521 that lives behind the site’s subnet router.

apiVersion: tsunaro.io/v1alpha1
kind: TailnetEgress
metadata:
  name: billing-dev            # = tenant name by convention; one CR per tenant namespace
  namespace: tailnet-system    # egress CRs are admin resources: always the operator namespace
spec:
  tailnetRef: { name: corp }
  consumerNamespaces: [billing-dev]
  targets:
    - { name: db-1, address: 10.0.10.20, port: 1521 }

The application connects to db-1.tailnet-system.svc:1521 — plain TCP; the tunnel is invisible.

kubectl -n tailnet-system get tailnetegress billing-dev
# NAME          TAG                      HOSTNAME             READY   AGE
# billing-dev   tag:egress-billing-dev   egress-billing-dev   True    2m

kubectl -n tailnet-system get tailnetegress billing-dev -o jsonpath='{range .status.targets[*]}{.name} port={.listenPort} present={.present} reachable={.reachable} {.reason}{"\n"}{end}'
# db-1 port=10000 present=true reachable=true

Not READY? Read the reason of the target, then the condition:

status.targets[].reason / conditionMeaningFix
TagKnown=False/Unknowntag not in tagOwnersadd it to the policy (file mode)
TagKnown=False/PolicyPendingoperator owns the policy, tag not applied yetwait a reconcile; check kubectl describe tailnet corp → PolicyApplied
NotAdvertisedno node advertises a route covering 10.0.10.20add the host (/32) or its subnet to the router’s --advertise-routes
NotApprovedadvertised, not approved (a TailnetGateway reports the same state as RoutesInSync=False/NotApproved)when the operator owns the policy: wait one policy reconcile (≤ 5 min) — it replays the missed approval; persists → the router’s tag is not in the autoApprovers entry. File mode: headscale nodes approve-routes …
NoGranttag has no grant to 10.0.10.20/32:1521add the grant (file mode) — generated automatically when the operator owns the policy
ProbeFailedthe operator could not scrape the agent (/status, /targets)agent pod down, or a NetworkPolicy between operator and agent — the other reasons are unknown until this is fixed
TargetsReachable=FalseTCP to the host fails (dial …: context deadline exceeded in the agent log)site firewall: router address → target port; host down

Isolation check — a pod in a foreign namespace must time out:

kubectl -n some-other-ns run t --rm -it --image=busybox --restart=Never -- nc -zw3 db-1.tailnet-system.svc 1521
# nc: timed out            ← correct: the NetworkPolicy allows consumerNamespaces only

Reach an HTTPS service by hostname (SNI / Host)

billing needs https://api.corp.example. The far end is a reverse proxy that routes by name, so the client must send the real hostname — api.tailnet-system.svc would get a 404 or a certificate error.

Add an alias; the operator publishes api.corp.example → <Service ClusterIP> in the DNS hosts file (DNS with CoreDNS ); the client dials the real name and TLS/SNI survive the tunnel:

  targets:
    - name: api-corp
      address: 10.0.30.10
      port: 443
      aliases: [api.corp.example, ipam.corp.example]   # several names on the same host: one target
kubectl -n tailnet-system get cm tailnet-dns-hosts -o jsonpath='{.data.hosts}'
# # managed by tailnet-operator (Tailnet corp) — do not edit
# 10.96.28.170   api.corp.example
# 10.96.28.170   ipam.corp.example

kubectl -n billing exec deploy/billing -- getent hosts api.corp.example
# 10.96.28.170    api.corp.example
kubectl -n billing exec deploy/billing -- sh -c 'printf "HEAD / HTTP/1.1\r\nHost: api.corp.example\r\n\r\n" | openssl s_client -quiet -connect api.corp.example:443 -servername api.corp.example 2>/dev/null | head -1'
# HTTP/1.1 200 OK

Notes: the alias zone must be forwarded to the resolver that mounts the hosts file (Wire the cluster CoreDNS ). Certificates come from the site (often an internal CA) — the application needs that CA in its trust store; the tunnel does not touch TLS.

Share targets between prod and dev

billing (prod) already reaches api.corp.example; billing-dev needs it too.

A DNS alias can point to one Service cluster-wide, and the SNI must be the real name — so there is no “dev alias”. Let the dev namespace consume the prod egress instead:

kind: TailnetEgress
metadata: { name: billing, namespace: tailnet-system }
spec:
  consumerNamespaces: [billing, billing-dev]     # dev traffic uses the prod proxy and its tag

Keep separate egress CRs for targets that genuinely differ between the environments (a dev database), and give those targets distinct names (db-1-dev) — target names are Service names and must be unique in the operator namespace.

Add, remove, rename a target

  • Add: append to targets; the operator adds a Service, a listener port (stable per name), a NetworkPolicy entry, the DNS aliases and — with operator-owned policy — the route approval and grant. No pod restart; status.targets[] shows the new entry within a minute.
  • Remove: delete the entry; Service, alias and grant disappear on the next reconcile. Consumers still dialling the Service name get NXDOMAIN.
  • Rename: is a remove + add — the Service name changes, consumers must follow. Prefer adding the new name first and removing the old one after the switch.
kubectl -n tailnet-system get svc -l app.kubernetes.io/instance=billing-dev
# NAME   TYPE        CLUSTER-IP     PORT(S)    AGE
# db-1   ClusterIP   10.96.78.33    1521/TCP   3d

Source: docs/how-to/egress.md at v0.18.0 — found a mistake? Tell us.