ztunnel, unmasked: the ambient mesh myths, busted on a live cluster

TL;DR – ztunnel is not a sidecar, and it is not a host router. It is one proxy per node that reaches a “long straw” into each pod’s network namespace and acts as that pod: same IP, same identity. Almost every ztunnel misconception comes from picturing it in the wrong place. This post fixes the mental model and disproves five common myths with output from a live ambient cluster you can rebuild in five minutes.

Who this is for

Platform, networking, and SRE folks who have heard that Istio’s ambient mode “removes the sidecar,” maybe enabled it, and now have a fuzzy picture of what ztunnel actually is and where it runs. You should know basic Kubernetes networking (pods, namespaces, a pod IP vs a node IP) and be comfortable reading ss and tcpdump output. You do not need to know Istio internals: that is the point.

What you’ll learn

  • The one mental model that makes ztunnel click: the “long straw” node proxy.
  • Where ztunnel’s sockets really live (hint: not the host).
  • What the HBONE connection looks like from inside the pod netns, and what the app on the other end sees.
  • Why the “tunnel” in ztunnel is a proxied mTLS stream, not a VXLAN/Geneve-style network tunnel.
  • Why this ztunnel data path does not carry non-DNS UDP over HBONE, and what that means for NetworkPolicy.

Every claim below is backed by output from a real cluster. Version matters here: this is Istio 1.30 with the in-pod redirection dataplane, which is the default for recent ambient. Older designs behaved differently, and I call those out where it matters.

Set up a sandbox (optional, but do it)

A kind cluster is a throwaway ambient lab. Build it, break it, delete it, no risk to anything.

# 3 nodes so we can show real cross-node traffic
kind create cluster --name ztunnel-lab --config - <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes: [ {role: control-plane}, {role: worker}, {role: worker} ]
EOF

# Install ambient (istiod + istio-cni + ztunnel), no sidecars
istioctl install --set profile=ambient -y

# A client and a server, pinned to different nodes, enrolled in the mesh
kubectl create ns demo
kubectl label ns demo istio.io/dataplane-mode=ambient
# (client = netshoot on worker, server = go-httpbin on worker2; pin them however you like)

Two workloads, one on each worker, both showing up as HBONE:

$ istioctl ztunnel-config workloads
NAMESPACE  POD NAME    ADDRESS     NODE                 PROTOCOL
demo       client-...  10.244.1.5  ztunnel-lab-worker   HBONE
demo       server-...  10.244.2.5  ztunnel-lab-worker2  HBONE

The one trick you need for the proofs below: get inside a pod’s network namespace. A kind node is just a container, so hop onto the node, find the pod’s process, and nsenter its netns:

NODE=ztunnel-lab-worker
cid=$(docker exec $NODE crictl ps --name netshoot -q | head -1)
pid=$(docker exec $NODE crictl inspect --output go-template --template '{{.info.pid}}' $cid)
docker exec $NODE nsenter -t $pid -n ss -lntp     # sockets as the pod sees them

Keep that nsenter -t $pid -n ... pattern handy. Most of the “aha” moments live in there.


The one idea: the long straw

The sidecar model was simple to picture: a dedicated Envoy inside every pod, sharing that pod’s network namespace, intercepting its traffic locally. One helper per room.

Ambient deletes that helper. So people reach for the next-simplest picture, “there must be one big proxy on the host doing it for everyone,” and that picture is what breaks. The reality is in between:

Ambient runs one ztunnel per node, but that ztunnel extends a separate “long straw” into each local pod’s network namespace (via setns) and operates from inside the pod. Because it is standing in the room, it wears the room’s badge: it sends with the pod’s own IP and presents the pod’s SPIFFE identity. For the pod-to-pod path below, the remote end sees the pod, not the ztunnel pod and not the Kubernetes node.

Hold that image. Myths 1 through 3 are all just people forgetting that the straw reaches all the way into the room.

Myth 1: “ztunnel runs in the host network namespace”

This is the most common one, and it is wrong twice over.

First, ztunnel is an ordinary pod with its own IP, not a host-networked process:

$ kubectl -n istio-system get pod -l app=ztunnel -o wide
NAME            READY   IP           NODE                 ...
ztunnel-d6dp7   1/1     10.244.1.4   ztunnel-lab-worker   ...
# hostNetwork is false; podIP 10.244.1.4 != nodeIP 172.18.0.3

Second, and this is the part people miss: look at who owns the capture sockets inside the app pod’s namespace. We nsenter into the client pod (IP 10.244.1.5) and list its listeners:

$ nsenter -t <client-pid> -n ss -lntp
LISTEN 0 128 *:15008  users:(("ztunnel",pid=1029,fd=29))
LISTEN 0 128 *:15001  users:(("ztunnel",pid=1029,fd=31))
LISTEN 0 128 *:15006  users:(("ztunnel",pid=1029,fd=30))
LISTEN 0 128 127.0.0.1:15053 users:(("ztunnel",pid=1029,...))

Those sockets are in the app’s netns, but they belong to the ztunnel process (pid 1029), which lives in the ztunnel pod on the node. That is the long straw: one ztunnel, one logical proxy task per pod, sockets bound from inside each pod’s namespace. It needs CAP_SYS_ADMIN to do it:

$ capsh --decode=$(awk '/CapEff/{print $2}' /proc/1029/status)
cap_net_admin, cap_net_raw, cap_sys_admin   # sys_admin = the setns() capability

The “host netns” picture is a memory of the earlier ambient Geneve design, which really did run in the host namespace and stitched pods in with Geneve tunnels (istioin/istioout) and routing tables 100/101. The in-pod redirection model retired all of that.

Myth 2: “HBONE dials the node IP”

If ztunnel were on the host, node-to-node tunnels would make sense. It is not, so they do not exist. Run tcpdump inside the client pod’s netns while it curls the server:

$ nsenter -t <client-pid> -n tcpdump -ni eth0 'tcp port 15008'
IP 10.244.1.5.44224 > 10.244.2.5.15008: Flags [P.], length 39
IP 10.244.2.5.15008 > 10.244.1.5.44224: Flags [P.], length 56

The HBONE connection that ztunnel opens is pod IP to pod IP, on port 15008. The node IPs (172.18.0.x) are not the HBONE source or destination in this capture. Your CNI might still do its own lower-layer routing or encapsulation below the pod network namespace; the ztunnel point is that HBONE itself is not dialing a node IP. There is no source spoofing to explain either: ztunnel’s outbound socket is in the client’s netns, so packets naturally leave with the pod’s own address. The straw wears the room’s badge.

And the server app agrees. go-httpbin echoes the peer address it sees:

$ curl -s http://server:8080/get | grep origin
"origin": "10.244.1.5:38835"     # the CLIENT pod IP, end to end

Myth 3: “it’s one big host-to-host tunnel that all pods share”

Even once people accept pod-to-pod, they often imagine a single shared pipe per node pair. It is the opposite: tunnels are keyed by workload identity, and the application port is not part of the key at all. One ztunnel access-log line shows the whole structure:

src.addr=10.244.1.5:54430  src.identity="spiffe://cluster.local/ns/demo/sa/default"
dst.addr=10.244.2.5:15008  dst.hbone_addr=10.244.2.5:8080
dst.service="server.demo.svc.cluster.local"  direction="outbound"

Read that carefully. The L3 destination is the peer pod at :15008. The real target the app asked for, :8080, rides inside the tunnel as dst.hbone_addr (it becomes the HTTP/2 CONNECT authority). So a single identity-scoped connection between two workloads can carry multiple app ports and application connections as separate HTTP/2 streams. The tcpdump in Myth 2 showed this: four back-to-back curls all reused one outer connection (10.244.1.5:44224 to 10.244.2.5:15008). Think of the pool key as {src identity, dst identity, src pod IP, dst pod IP:15008}, never the app port.

Myth 4: “ztunnel is a network tunnel protocol, like VXLAN”

The name misleads. The “z” is for zero-trust, and the “tunnel” is a proxied, mutually authenticated TCP stream, not a kernel/network-overlay protocol like VXLAN, Geneve, or IPsec. ztunnel creates no ztunnel-owned overlay device. In this lab pod, the only interfaces are loopback and a plain veth, and there is no ztunnel istioin/istioout, vxlan, or geneve device in the pod netns:

$ nsenter -t <client-pid> -n ip -br link
lo          UNKNOWN  ...
eth0@if15   UP       ...                      # a veth, not a tunnel device

$ ip -o link show type vxlan; ip -o link show type geneve   # pod netns
# (nothing)

If your CNI uses VXLAN or Geneve, that is the CNI’s underlay/overlay choice, not a ztunnel device. HBONE is still a tunnel in the HTTP CONNECT sense: application TCP byte streams are carried inside an mTLS HTTP/2 connection. What it is not is packet-level L3 overlay tunneling: ztunnel does not add an outer IP header or create a VXLAN/Geneve device. The earlier ambient design that did use Linux tunnel devices, with Geneve istioin/istioout, is exactly the one that was retired.

Myth 5: “HBONE can tunnel anything, including UDP”

For the Istio 1.30 ztunnel path tested here, HBONE is HTTP/2 CONNECT carrying TCP streams. It does not carry non-DNS UDP application traffic. The proof is in the listener types inside the pod netns:

$ nsenter -t <client-pid> -n ss -lnt | awk '{print $4}'   # TCP
*:15008   *:15001   *:15006   127.0.0.1:15053
$ nsenter -t <client-pid> -n ss -lnu | awk '{print $4}'   # UDP
127.0.0.1:15053                                           # DNS only

The mesh ports (15008/15001/15006) are TCP. The only UDP ztunnel touches here is 15053, and only for DNS resolution, not HBONE tunneling. Non-DNS UDP from your app is not captured by ztunnel in this path: it rides your CNI as plaintext. Two practical consequences: UDP application traffic is not mTLS-protected by this ambient datapath today, and any NetworkPolicy you write must allow TCP 15008 or ambient breaks.


Cross-node ambient data path: the client app's connection is redirected in-netns to the local ztunnel on port 15001; ztunnel opens one identity-scoped HBONE (mTLS, HTTP/2 CONNECT) connection from the client pod IP to the server pod IP on port 15008; the server-side ztunnel, inside the server pod netns, terminates it and forwards to the app on localhost:8080. Node IPs are not the HBONE endpoints.

ASCII version of the data path (for RSS / no-JS readers)
   node A (worker)                                   node B (worker2)
 ┌───────────────────────────┐               ┌───────────────────────────┐
 │  client pod netns          │               │  server pod netns          │
 │  10.244.1.5                │               │  10.244.2.5                │
 │                            │               │                            │
 │  app --curl server:8080    │               │        app :8080           │
 │    │ REDIRECT (nat)        │               │           ▲ localhost      │
 │    ▼                       │               │           │                │
 │  ztunnel :15001 ───────────┼── HBONE ──────┼─► ztunnel :15008           │
 │  (long straw into pod)     │  mTLS, H2      │  (long straw into pod)     │
 │                            │  CONNECT       │                            │
 └───────────────────────────┘  :authority =  └───────────────────────────┘
   src = 10.244.1.5            10.244.2.5:8080   dst = 10.244.2.5:15008
                    (node IPs 172.18.0.x are not HBONE endpoints)

Recap

  • One node proxy, many straws. ztunnel is one pod per node that setns into each local pod and binds its capture sockets there. Not a sidecar, not a host router.
  • Pod identity end to end. In the pod-netns capture, the HBONE endpoints are pod IP to pod IP on 15008; the server app sees the client pod IP; both ends are pinned to SPIFFE identities.
  • Identity-scoped tunnels, multiplexed. The app port is not in the pool key: it rides inside the tunnel as the CONNECT authority for each application connection.
  • A proxy, not a network overlay. HBONE carries TCP streams inside HTTP/2 + mTLS; ztunnel adds no ztunnel-owned VXLAN/Geneve device.
  • TCP streams only in this path. Non-DNS UDP is not carried over HBONE here, so plan for UDP separately and allow TCP 15008 in any NetworkPolicy.

The through-line: ztunnel is a per-pod-scoped, identity-pinned, userspace TCP proxy. The kernel only steers packets to it; ztunnel does not become the pod’s address, and it does not install a VXLAN/Geneve-style network overlay.

Cheat sheet

Port Role
15001 outbound capture (app egress redirected here)
15006 inbound plaintext capture
15008 inbound HBONE (mTLS, HTTP/2 CONNECT)
15053 DNS (TCP + UDP, localhost)
15021 / 15020 / 15000 readiness / metrics / admin
# The three commands that answer most "what is ztunnel doing" questions:
istioctl ztunnel-config workloads          # who is enrolled, and as what
nsenter -t <pod-pid> -n ss -lntp           # ztunnel's sockets, inside the pod netns
kubectl -n istio-system logs <ztunnel> | grep access   # src/dst identity + hbone_addr

Further reading

  • Istio ambient docs, especially the ztunnel and traffic-redirection pages
  • The HBONE and ztunnel design notes in the istio/ztunnel repository
  • RFC 9110 (the HTTP CONNECT method), which HBONE uses to open a TCP tunnel; UDP proxying would be the separate CONNECT-UDP mechanism of RFC 9298, which this ztunnel app-traffic path does not use

Rebuild the cluster, rerun every block above, and watch the HBONE endpoints show up as pod IPs, not node IPs, from inside the pod netns. The model sticks once you have nsenter-ed into a pod and seen ztunnel’s sockets sitting inside it.

ztunnel, unmasked: the ambient mesh myths, busted on a live cluster

iptables, demystified: a mental model you’ll actually remember

TL;DRiptables -L looks like noise because it flattens two independent axes into one wall of text: where the packet is (the hooks it crosses) and what kind of job you’re doing at that point (the tables). Once you separate those two ideas, the whole system collapses into something you can hold in your head. This post builds that model from first principles and backs every claim with commands you can run yourself in a throwaway container.

Who this is for

Intermediate developers, SREs, and platform/Kubernetes engineers who have typed iptables -L, seen PREROUTING, mangle, nat, and MASQUERADE, and quietly closed the terminal. You should be comfortable with basic TCP/IP (IPs, ports, the idea of a packet). You do not need to know anything about netfilter internals — that’s the whole point.

What you’ll learn

  • Why “the stack” and “the tables” are two different things (the core confusion).
  • The five netfilter hooks and the three journeys a packet can take.
  • What the tables actually do, and why not every table has every chain.
  • How a packet walks a chain: first-match-wins, default policy, DROP vs REJECT.
  • NAT in one demo — and why conntrack, not the nat table, is the real hero.
  • The plot twist: your iptables rules are secretly nftables, and how to debug accordingly.

Set up a sandbox (optional, but do it)

Everything below was run live. A Linux container is its own network namespace, so it’s a safe, throwaway firewall lab — flush it, break it, delete it, no risk to your host.

# Linux: run directly. macOS/Windows: use podman/Docker or WSL.
podman run -d --name nf-lab --privileged --cap-add=NET_ADMIN \
nicolaka/netshoot sleep infinity
podman exec nf-lab iptables --version
# iptables v1.8.11 (nf_tables) <- remember this line; it matters in the last section

The netshoot image ships iptables, nft, conntrack, nc, and tcpdump — a complete netfilter workbench. Tear it down at the end with podman rm -f nf-lab.


The one idea: netfilter ≠ iptables

This single distinction removes most of the confusion:

  • netfilter is a framework inside the Linux kernel. It exposes 5 hooks — fixed checkpoints along a packet’s path where kernel code may inspect, modify, or drop it.
  • iptables is a userspace command that installs rules at those hooks. The rules live in the kernel; iptables just edits them. Delete the binary and your firewall keeps running.

So there are two axes, and they are independent:

AxisWhat it meansThe word people use
The journeythe path a packet takes through the kernel — a sequence in time“the stack”
The organizationhow rules are grouped at each checkpoint — a what-kind-of-job dimension“tables / chains”

iptables -L is confusing because it shows you the second axis while staying silent about the first. Let’s fix the first axis, then layer the second on top.


Step 1 — the packet’s journey: 5 hooks + one routing decision

Every packet passes through some subset of five hooks. The routing decision in the middle is the fork in the road: it decides whether a packet is for this host (→ INPUT) or just passing through (→ FORWARD).

Netfilter packet flow: a packet enters at PREROUTING, hits the routing decision, then goes either to INPUT (local) or FORWARD (transit); locally generated traffic starts at OUTPUT; everything leaving converges on POSTROUTING.

There are exactly three journeys, each just a subset of the five hooks:

  1. Inbound, for me: PREROUTING → INPUT → local process
  2. Inbound, routed through me: PREROUTING → FORWARD → POSTROUTING → out
  3. Locally generated: OUTPUT → POSTROUTING → out

A chain is simply “the rules attached to one hook, within one table.” That’s why the built-in chains are named after the hooks: INPUT, OUTPUT, FORWARD, PREROUTING, POSTROUTING. Hold onto that — it’s the bridge to the second axis.

ASCII version of the diagram (for RSS / no-JS readers)
            ┌───────────────────┐
  NIC ──►  PREROUTING  ──► [ routing decision ]
            └───────────────────┘        │
                          ┌──────────────┴───────────────┐
                          ▼                               ▼
                    (for THIS host)                 (passing through)
                        INPUT                           FORWARD
                          │                               │
                          ▼                               │
                   local process                          │
                          │                               │
                        OUTPUT                            │
                          │                               │
                          └───────────► POSTROUTING ◄─────┘ ──► NIC

Step 2 — tables: the second axis (what kind of job)

At each hook, rules are grouped into tables by the kind of work they do:

TableJob
filterallow / block — ACCEPT / DROP / REJECT (the default table)
natrewrite addresses — DNAT (inbound), SNAT/MASQUERADE (outbound)
mangleedit headers / set fwmark (TTL, DSCP, policy routing)
rawruns before connection tracking — NOTRACK to exempt a flow
securitySELinux/MAC labels (rare)

Here’s the part the tutorials gloss over: not every table has every chain. A nat table has no FORWARD; a filter table has no PREROUTING. You can see it directly — list the policy lines (-S shows the chains each table defines):

$ for t in raw mangle nat filter; do echo "--- $t ---"; iptables -t $t -S | grep '^-P'; done
--- raw ---
-P PREROUTING ACCEPT
-P OUTPUT ACCEPT
--- mangle ---
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
--- nat ---
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
--- filter ---
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT

Drop that into a grid and the second axis becomes obvious. A chain you edit is an intersection of the two axes — nat/PREROUTING means “the NAT rules at the PREROUTING hook”:

Tables-by-chains matrix: rows are tables (raw, mangle, nat, filter, security), columns are the five chains; a check mark means that table has a built-in chain at that hook. mangle has all five; raw only PREROUTING and OUTPUT; nat has no FORWARD; filter and security have no PREROUTING or POSTROUTING.
ASCII version of the matrix
            PREROUTING  INPUT  FORWARD  OUTPUT  POSTROUTING
 raw            ✓                         ✓
 mangle         ✓        ✓       ✓        ✓         ✓
 nat            ✓        ✓                ✓         ✓
 filter                  ✓       ✓        ✓
 security                ✓       ✓        ✓

One more subtlety: evaluation order at a hook

When multiple tables share a hook, they don’t run in the order you typed — they run in a fixed priority order:

raw → [conntrack] → mangle → nat(DNAT) → filter → security → nat(SNAT)

That ordering explains real behavior: at PREROUTING, DNAT happens before the routing decision and before filter, so your filter rules see the already-translated destination. At POSTROUTING, SNAT is the last thing to touch the packet before it leaves.

Two reflexes worth burning in now:

  • iptables with no -t defaults to -t filter. That’s why “iptables” feels like “the firewall” even though it’s only one of five tables.
  • The verbose counter columns (iptables -L -v -n) are your #1 debugging tool: they answer “are packets even reaching this rule?”

Step 3 — rule anatomy, first-match-wins, and the default policy

A rule is a table, an operation, a chain, a set of matches (all ANDed together), and a target:

iptables -t filter -A INPUT -p icmp --icmp-type echo-request -j DROP
└─table──┘ └op┘└chain┘ └────────── matches (all ANDed) ─────────┘ └target┘
  • op: -A append · -I insert · -D delete · -R replace · -F flush · -L list · -C check
  • matches: -p, -s/-d, --sport/--dport, -i/-o, -m conntrack --ctstate …, …
  • target (-j): terminating (ACCEPT, DROP, REJECT), non-terminating (LOG, MARK), or NAT verdicts (DNAT, SNAT, MASQUERADE, REDIRECT).

The golden rule: within a chain, rules are evaluated top to bottom and the first terminating match wins — the rest of the chain is skipped. Order matters.

Let’s prove counters first. A rule with no -j just counts matching packets and falls through, which makes a clean meter:

$ iptables -A OUTPUT -p icmp # count outbound pings
$ iptables -A INPUT -p icmp # count inbound replies
$ ping -c 3 8.8.8.8 >/dev/null # generate traffic
$ iptables -L -v -n
Chain INPUT (policy ACCEPT 3 packets, 252 bytes)
pkts bytes target prot opt in out source destination
3 252 icmp -- * * 0.0.0.0/0 0.0.0.0/0
Chain OUTPUT (policy ACCEPT 3 packets, 252 bytes)
pkts bytes target prot opt in out source destination
3 252 icmp -- * * 0.0.0.0/0 0.0.0.0/0

Three pings out, three replies in. Now make a rule actually block something — add a DROP for echo-requests and watch the ping fail:

$ iptables -A INPUT -p icmp --icmp-type echo-request -j DROP
$ ping -c 2 -W 1 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
--- 127.0.0.1 ping statistics ---
2 packets transmitted, 0 received, 100% packet loss, time 1020ms
$ echo $?
1

Default policy: every built-in chain ends in a catch-all ACCEPT or DROP. Production firewalls are default-deny: iptables -P INPUT DROP, then explicit ACCEPTs for what you serve.

⚠️ Don’t -P INPUT DROP over SSH before adding an ACCEPT for your SSH port — you’ll lock yourself out of the box instantly. Add the allow rule first, test, then tighten the policy.

DROP vs REJECT: DROP silently blackholes the packet (the sender waits and times out — “stealth”); REJECT sends back an ICMP/RST error immediately (faster failure, but advertises that something is filtering).

The stateful pattern you’ll use everywhere:

iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

“Allow replies to connections we started” — then you only need to open NEW inbound on the specific ports you serve. Which brings us to the table everyone fears.


Step 4 — NAT in one demo (and conntrack is the real hero)

NAT just means rewriting addresses. Two directions:

RewritesHookUsed for
DNATdestinationPREROUTING (in/forward) or OUTPUT (local)port-forward, REDIRECT, k8s ClusterIP → pod
SNAT / MASQUERADEsourcePOSTROUTINGone-IP sharing, k8s / Docker egress

Here’s a self-contained REDIRECT (a special DNAT to a local port). We send traffic to :8080, but a listener on :9000 answers it:

$ iptables -t nat -A OUTPUT -p tcp --dport 8080 -j REDIRECT --to-ports 9000
$ ( echo "hello-from-9000" | nc -l -p 9000 ) & # listener on :9000
$ echo ping | nc -w1 127.0.0.1 8080 # client dials :8080
hello-from-9000 # ...answered by :9000

The magic is in the conntrack tuple. One connection is tracked as two tuples — the original direction and the reply direction — and the reply shows the rewrite:

$ conntrack -L -p tcp
tcp 6 119 TIME_WAIT \
src=127.0.0.1 dst=127.0.0.1 sport=42070 dport=8080 \ # ORIGINAL: what the client sent
src=127.0.0.1 dst=127.0.0.1 sport=9000 dport=42070 \ # REPLY: note sport=9000 (rewritten!)
[ASSURED] mark=0 use=1

This is the key insight most NAT explanations bury:

Only the first packet of a flow actually hits the nat table. conntrack records the translation, then rewrites every subsequent packet — and un-NATs the return traffic automatically — without re-consulting your rules. The nat table makes the decision; conntrack does the work.

Where you’ve already been using this without knowing:

  • Home router: a single MASQUERADE rule in POSTROUTING shares one public IP across your whole house.
  • Docker: the bridge network is MASQUERADE for egress plus DNAT for every -p published port.
  • Kubernetes: kube-proxy in iptables mode is walls of DNAT rules in nat/PREROUTING and nat/OUTPUT turning ClusterIPs into pod IPs.

A one-line mnemonic: filter decides if a packet lives; nat decides where it goes and who it’s from — first packet only; conntrack remembers the rest.


Step 5 — the plot twist: your iptables rules are nftables

Remember iptables v1.8.11 (nf_tables) from the setup step? On every modern distro, the iptables command is a compatibility front-end that translates your rules into nftables, the kernel’s current packet-classification engine. Same rule, two views:

$ iptables -A INPUT -p tcp --dport 22 -j ACCEPT # add it the "iptables" way
$ nft list ruleset # ...read it the "nftables" way
table ip filter {
chain INPUT {
type filter hook input priority filter; policy accept;
tcp dport 22 counter packets 0 bytes 0 accept
}
}
$ readlink -f $(which iptables)
/usr/sbin/xtables-nft-multi

Look at that nft chain header: type filter hook input priority filter. It spells out both axes explicitlytable = what kind of job, hook = when in the journey, priority = the fixed evaluation order from Step 2. Those priority names map to fixed numbers (raw = -300, mangle = -150, dstnat = -100, filter = 0, srcnat = 100) and the lower the number, the earlier it runs. Everything in this post is sitting right there in one line.

⚠️ The legacy/nft split-brain. Rules added with iptables-legacy live in a different place than iptables-nft rules, and each is blind to the other. On a mystery host, this is a classic “my rule is clearly there but has no effect” trap. Check both front-ends, and treat nft list ruleset as the ground truth.

Debugging toolkit

CommandUse
iptables -L -v -n --line-numberscounters; pkts=0 ⇒ traffic isn’t reaching the rule; -Z resets
iptables -C <rule…>does this exact rule exist? (exit 0/1 — great in scripts)
iptables -Sdump rules as re-create commands (diff-friendly)
iptables-save / iptables-restoreback up / atomically reload all tables
-j LOG --log-prefix "..." then dmesglog packets that match a rule
-t raw -j TRACE then dmesgfollow one packet through every table and chain
conntrack -Linspect live NAT / connection state
nft list rulesetthe real, unified view of everything

One silent footgun: interface matches only work on hooks where that interface is known. -i (in-interface) is valid only in PREROUTING/INPUT/FORWARD; -o (out-interface) only in FORWARD/OUTPUT/POSTROUTING. Use the wrong one and the rule never matches — with no error.


Recap

  • netfilter ≠ iptables. The kernel has the hooks; iptables just edits rules at them.
  • Two axes. The journey (5 hooks, split by one routing decision) and the tables (what kind of job). A chain is their intersection.
  • First-match-wins, then a default policy catch-all. Lead with the stateful ESTABLISHED,RELATED accept.
  • conntrack is the hero of NAT — the nat table only touches the first packet.
  • It’s all nftables underneath. table / hook / priority is the mental model, made literal.

Cheat sheet

# Spin up a throwaway lab
podman run -d --name nf-lab --privileged --cap-add=NET_ADMIN nicolaka/netshoot sleep infinity
podman exec -it nf-lab bash
# Look around
iptables -L -v -n --line-numbers # filter table + counters
for t in raw mangle nat filter; do iptables -t $t -S | grep '^-P'; done # chains per table
# Allow/block (filter)
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -P INPUT DROP # default-deny — add the SSH ACCEPT FIRST
# NAT + state
iptables -t nat -A OUTPUT -p tcp --dport 8080 -j REDIRECT --to-ports 9000
conntrack -L
# Ground truth + backup
nft list ruleset
iptables-save > rules.v4
# Teardown
podman rm -f nf-lab

Further reading

  • man iptables, man iptables-extensions, man conntrack
  • The nftables wiki — especially the legacy-vs-nft notes
  • The canonical netfilter packet-flow diagram (Wikipedia) — keep it open the first few times
  • iptables-translate <rule> — paste any iptables rule, get the nftables equivalent

Spin up the container, run every block above, break things, and delete it. The model sticks when the counters move in front of you.

iptables, demystified: a mental model you’ll actually remember

A local dev setup with nginx TLS pass-through

A common principle to avoid or at least minimize the chances of “It works on my machine :(” awkwardness, is to reduce as many config/setup differences between dev and prod environments as possible. E.g. testing locally on a dev box with https://www-dev.fake.io is much better than http://localhost:8080. If you don’t agree, stop reading. 🙂

A scenario

So we have three web apps/services that need to talk to each other to work end to end. They are https://sso.fake.io, https://api.fake.io, and https://www.fake.io in production. On a dev box, we want them to run and accessible as https://sso-dev.fake.io, https://api-dev.fake.io, and https://www-dev.fake.io. From the user interaction point of view, the only difference between dev and prod is the -dev suffix in the domain names.

The problem

Behind the scene though, all three web services are running on the same box, specifically, the same host network namespace with different ports.

  • That means we’d require a reverse proxy solution that fans out the requests based on the domain name
  • The proxy needs to be super lightweight in terms of both setup effort and performance. We want minimal dev effort to reach the maximum dev/prod similarity
  • We need to pass TLS through as the three web services are already running with HTTPS

A solution

The idea looks like this:

Linux box

To reach the lightweight goal, there is no other solution but to run an Nginx reverse proxy as a container. Nginx is super light and fast. A single command launching it via a docker container is minimal dev effort. In fact zero if this command is part of the inner loop script.

docker run --name dev-nginx --mount type=bind,source="${PWD}/docker/dev-nginx/nginx_linux.conf",target=/etc/nginx/nginx.conf --network host -d nginx:alpine

For a Linux dev box, with the great --network host option, things are easier as Nginx lives in the same host network namespace as the three web services do. No explicit port mapping is needed either. Inside Nginx, the upstream web services can be reached via loopback/127.0.0.1 as well.

stream {
    map $ssl_preread_server_name $name {
        sso-dev.fake.io sso_backend;
        api-dev.fake.io api_backend;
        www-dev.fake.io www_backend;
    }

    upstream sso_backend {
        server 127.0.0.1:5000; # host.docker.internal
    }

    upstream api_backend {
        server 127.0.0.1:5001; # host.docker.internal
    }

    upstream www_backend {
        server 127.0.0.1:5002; # host.docker.internal
    }

    server {
        listen        443;
        proxy_pass  $name;
        ssl_preread    on;
    }
}

For win/mac users, however, because docker runs inside a virtual machine, there isn’t the luxury of host network mode. So port mapping (-p 443:443) is required between the host and the container. Additionally, for the Nginx container to talk to the host network namespaced web services, one has to use the magic domain name host.docker.internal instead of the nice loopback address.

win/mac box
docker run --name dev-nginx --mount type=bind,source="${PWD}/docker/dev-nginx/nginx.conf",target=/etc/nginx/nginx.conf -p 80:80 -p 443:443 -d nginx:alpine

Nginx supports domain name based virtual server like a no-brainer. But more importantly, Nginx can pass TLS through easily with the support of ngx_stream_core_module which is available since 1.9.

Lastly, mapping the -dev names to 127.0.0.1 and viola, on your local dev box, you have a setup super close to production experience, from the users’ perspective.

>_ cat /etc/hosts
127.0.0.1    sso-dev.fake.io api-dev.fake.io www-dev.fake.io

Hope this helps.

A local dev setup with nginx TLS pass-through

One kubectl context per shell session

Context switching is no fun. Be it the kernel/user mode switching, multiple tasking at work or changing between kubectl contexts. It’s an overhead that should be eliminated or at least reduced.

I deal with multiple Kubernetes clusters on a daily basis. Without some nice tools/tricks, switching between these clusters is tedious and dangerous. If not careful, a deadly command could be carried out on a totally unexpected cluster.

What I have today, in terms of tools, are kube-ps1, kubectx and kubens. In zsh, it looks like:

These are all good. No kidding, these goodies have been making my life so much easier. It’s very easy to switch between contexts and it always shows in the prompt. But, there is one problem it doesn’t solve. It’s the constant context switching. Even it’s little effort to switch, it adds up when you have to do it hundreds of times.

Every kubectl context switch is global. It is an actual change of the kubeconfig file after all. It would be really nice if we can stick to one context in a shell session and a different contex in another session. Recently, I have discovered a technique that achieves this. Simply adding this snippet to the .zshrc file:

# kubeconfig per session
file="$(mktemp -t "kubectx.XXXXXX")"
export KUBECONFIG="${file}:${KUBECONFIG}"
cat <<EOF >"${file}"
apiVersion: v1
kind: Config
current-context: ""
EOF

It’ll create a temporary kubeconfig file for each zsh session and contains the context information without impacting any other sessions.

A game changer.

One kubectl context per shell session

Execute a bash script via C#/.NET Core

With .NET Core now being a cross-platform framework, it’s very easy to invoke a Bash script from C# codes. It’s not a common practice, but in cases that are lack of a .NET library or REST/rpc API, being able to run a script out-of-process is valuable. So here is a nice extension method that I wrote and found it a joy to call.

public static class ShellHelper
{
public static Task<int> Bash(this string cmd, ILogger logger)
{
var source = new TaskCompletionSource<int>();
var escapedArgs = cmd.Replace("\"", "\\\"");
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "bash",
Arguments = $"-c \"{escapedArgs}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
process.Exited += (sender, args) =>
{
logger.LogWarning(process.StandardError.ReadToEnd());
logger.LogInformation(process.StandardOutput.ReadToEnd());
if (process.ExitCode == 0)
{
source.SetResult(0);
}
else
{
source.SetException(new Exception($"Command `{cmd}` failed with exit code `{process.ExitCode}`"));
}
process.Dispose();
};
try
{
process.Start();
}
catch (Exception e)
{
logger.LogError(e, "Command {} failed", cmd);
source.SetException(e);
}
return source.Task;
}
}
view raw bash_helper.cs hosted with ❤ by GitHub

To call the method, one can simply do, e.g.:

await $"scripts/00magic.sh --param {arg}".Bash(this.logger);

Execute a bash script via C#/.NET Core

echo vs printf

Apps-Terminal-Pc-104-icon

In bash, or generally the family of shell languages, echo and printf are often used to output messages to the screen (or terminal, or tty, or stdout, to earn a few more geek points…). It mostly doesn’t matter when to use which. In fact, most of the time, echo is used.

But here is a case that will bite if one doesn’t understand a little more about the details.

When preparing a Kubernetes secret yaml file, the secret data itself needs to be base64ed. E.g. the password value in the below yaml snippet.

apiVersion: v1

kind: Secret

metadata:

  name: mysql-pass

data:

  password: bWFnaWNfcGFzc3dvcmQ=

To generate this base64 string, one often does this in a shell: echo "magic_password" | base64 -, then copy the output to the yaml file. Guess what, soon after applying this yaml to the cluster, one would be into long hours of investigation of authentication failures.

How could the heck the password be bad? S/he asked while scratching her/his head.

The devil is in the details. Notice the differences between these two commands:

> echo "magic_password"
magic_password
> echo -n "magic_password"
magic_password%

The % in the second command output means that it’s a partial line. In another word, it’s ending with no newline character. It also means that the first command ended with one which would contribute to the downstream base64 command. Now you see where the problem is? An invisible newline char finds its way into the password by echo. No wonder why all those password errors.

So the -n switch solves the problem? Yes, but not really recommended. The -n behavior is not always consistent across different systems. The true way of doing this correctly is, printf "magic_password". printf never outputs an extra newline without being forced to by an explicit format like printf "hello\n".

Enjoy!

echo vs printf

An alternative of kubectl patch

Usually if a resource needs to be updated in place in Kubernetes, A few options are available. If the resource was created using kubectl create/apply -f, one just need to update the yaml file and apply -f it again. I see this option is used mostly.

However, I have a secret created --from-literal before. I didn’t have a yaml file for it. In this case, lots of articles suggest to kubectl patch it. Looking at the documentation, oh boy, I don’t know you my friend, I literally have a headache.

So I quickly moved on and figured this little but nice trick to complete the task:

kubectl create secret generic my-super-password --from-literal=password=12345678 --dry-run --output yaml | k apply -f -

An alternative of kubectl patch

Negation in ssh config

A little trick I learned today.

So Yubikey can’t be cooler when it comes to securing your private key and you know, all sorts of identity/authentication related stuff. At work, it’s enfored via ~/.ssh/config using PKCS11Provider /Library/OpenSC/lib/opensc-pkcs11.so.

But there are cases that Yubikey is not used. E.g., to git clone from a repo where SSH is used with a regular key pair. A simple negation entry can solve this need:

# Use my regular identity for Azure DevOps
Host !ssh.dev.azure.com *
    PKCS11Provider /Library/OpenSC/lib/opensc-pkcs11.so

Negation in ssh config

A lazy loading solution for Angular 1.x

In this post, I’m going to show you a solution of lazy loading Angular 1.x modules. Angular 1.x doesn’t have it supported out of the box and it’s a very critical feature for many large applications dealing serious businesses.

The demo project used for this post can be found from here https://github.com/jack4it/angular-1x-lazy-load.

Aren’t this problem already solved by Angular 2 and Aurelia?

Some of you might ask, given that Angular 2 is already in beta stage, and also there is another even better framework called Aurelia almost ready for its first release, why do we still need to care about Angular 1.x? There indeed are some valid reasons for that.

  • Many existing Angular 1.x projects will just not migrate to the new framework
  • Both Angular 2 and Aurelia are just in beta stage and it’ll take time for the majority to be confident enough to start to use them on new critical projects
  • etc.

So this solution will still be helpful for at least a while.

And a bonus point, in this solution, I’m also gonna show you how to write ES6/ES2015 codes and use systemjs loader even for today’s Angular 1.x projects. Another bonus point, the lazy loaded modules are also well bundled using systemjs-builder. So that you can have a seamless workflow for both development and production environments.

In the rest of this post, if not explicitly declared, by the term Angular, I’ll just mean Angular 1.x.

Why does it matter?

It’s funny that Angular fosters modular design/separation of concern for large client applications, but doesn’t provide a lazy loading story. The module meta language it provides is far from ideal, but it still works (plain ES6/ES2015 module is the one true king of module kingdom).

Modular design helps with a lot of things including team collaboration, maintainability, and etc. But it doesn’t really help in production if the good modules all have to be loaded entirely beforehand for the app to run.

In reality, we want to load only the needed modules initially for a faster boot experience and lazily load the other modules when user triggers the related functionality of the app. And this really matters for most serious applications regarding performance.

All right then, how?

So you are still interested in this offering. Great, let’s get to the details. In order to achieve this lazy loading goal, three problems have to be solved:

  1. When, where and how is a module going be triggered to load?
  2. How is a module going to be actually loaded?
  3. Once the module is loaded, how should it be registered to Angular, so that it can be used down the road?
I’ll give all answers to these three questions later in following sections. But first let’s imagine a demo project, so that we can code it up and it’ll be much easier to see the real working codes than just read a dry post.

The little demo project

We’ll have this structure for the demo. Logically the app will have a homepage (the initial load) where we can link to other two lazy-loaded pages (powered by Angular). They are the contact page and about page.

The app.* will serve for homepage purpose as the main entry point of the app. In each lazy-loaded module, we’ll have all their Angular resources defined in a self-contained way and wire them all up in the respective module.js which you’ll see later also serves the purpose of bundling point.

2015-12-29_12-51-27

Without further due, let’s get to resolve the three problems to lazy load Angular modules.

The trigger

In a JavaScript client app, it usually takes a router component to serve the navigation purpose. It is natural to think if we can somehow extend the router, then we can trigger the actual loading when a navigation is requested and register the loaded modules to Angular. And this is indeed true for our solution. We’ll use ui-router to easily define the lazy loading points and seamlessly wire up with systemjs to do the actual loading work.

We favor ui-router over ng-route because it provides more convenient ways of providing lazy loading support which in turn comes from the ui-router-extras project, the future states. Following is a snippet of how the wire-up looks like.


export let app = angular.module("app", ["ui.router", "ct.ui.router.extras"])
.service("SystemLazyLoadService", SystemLazyLoadService)
.config(["$stateProvider", "$urlRouterProvider", "$futureStateProvider", ($stateProvider, $urlRouterProvider, $futureStateProvider) => {
$stateProvider.state('home', {
url: "",
template: template
});
$urlRouterProvider.otherwise("");
$futureStateProvider.stateFactory("systemLazy", ["SystemLazyLoadService", "futureState", (loadService, futureState) => {
return loadService.load(futureState.src, futureState.moduleExportKey);
}]);
// These are the lazy module (future state) declarations
addSystemLazyState($futureStateProvider, "contact", "/contact", "contact/module.js", "contact");
addSystemLazyState($futureStateProvider, "about", "/about", "about/module.js", "about");
}])
function addSystemLazyState($futureStateProvider, stateName, url, src, moduleExportKey) {
$futureStateProvider.futureState({
stateName: stateName,
url: url,
type: "systemLazy",
src: src,
moduleExportKey: moduleExportKey
});
}

The key pieces to notice in the above snippet are:

  • A state factory called systemLazy is created by using $futureStateProvider.stateFactory function. This state factory delegates the state preparation (the lazy loading) to a service called SystemLazyLoadService. More on the details of this service in next section
  • Then we add two future states, the contact and about modules using function addSystemLazyState which in turn calls function $futureStateProvider.futureState. Notice how we take care of the state name, the routing Url, the source location of the JavaScript module and optionally the export key of the Angular module (respectively contact and about found in the module.js files)

The loading and registration

Now let’s talk about the actual module loading and the registration of the loaded Angular module. As I mentioned above, this is achieved by the SystemLazyLoadService which looks like below snippet.


export class SystemLazyLoadService {
static $inject = ["$ocLazyLoad"];
constructor($ocLazyLoad) {
this.$ocLazyLoad = $ocLazyLoad;
}
load(src, moduleExportKey) {
let loader = this.$ocLazyLoad;
return System.import(src)
.then(module => {
var angularModule = module[moduleExportKey || 'default'];
if (!angularModule) {
console.info(module);
throw new Error("Unexpected angular module");
}
return loader.load(angularModule);
})
.then(() => {
return null; // !!! critical here; this is needed to trick future state infra
});
}
}

You may noticed that this is just a regular ES6/ES2015 module which is also registered as an Angular service. The logic is fairly straightforward. It mainly does two things:

  1. Loading: On line 11, we are doing System.import and let systemjs take care of the actual loading business. Thanks to the great systemjs loader, this single line of code is all we need for the loading part
  2. Registration: Once the module is loaded back via systemjs, the next big thing is to register the module into Angular, so that we can use the module down the road. We are using a nice library called ocLazyLoad to take of this part of the business. Again, while it is just one line of code on line 18, ocLazyLoad is actually doing a lot of work behind the scene. With ocLazyLoad’s help, we can stay away from dealing with Angular’s variety of providers to register all lazy loaded Angular resources

The last and important matter: bundling

Now we have solved the three problems in order to enable lazy loading of Angular modules. By integrating all these libraries, we now can seamlessly define the lazy loading points and load the respective module only when it is needed. Nice, but there is one last very important thing before we can call this solution complete. It is the bundling. As I mentioned above, the well crafted modules will not help in a production environment if we don’t have a bundle story.

By using systemjs-builder, we have also achieved this goal easily. Following is an excerpt of the bundle.js file you can find from the demo project.


var appRoot = "/";
var Builder = require('systemjs-builder');
// optional constructor options
// sets the baseURL and loads the configuration file
var builder = new Builder("/", 'config.js');
function build(entry, output) {
var message = entry + " –> " + output;
var begin = new Date();
console.log("—- Build started @ " + begin.toLocaleTimeString() + " # " + message);
builder
.bundle(entry, output, {
minify: true,
mangle: true
})
.then(function (output) {
var index = 1;
output.modules.forEach(function (m) {
////output.modules.sort().forEach(function (m) {
console.log(" #" + index++ + " " + m);
});
logEnd(begin, message);
})
.catch(function (err) {
console.log('!!! error');
console.log(err);
logEnd(begin, message);
throw err;
});
}
function logEnd(begin, message) {
var end = new Date();
console.log("—- Build completed @ " + end.toLocaleTimeString() + " (" + (end – begin) + " ms) # " + message);
}
build(appRoot + 'app.js', __dirname + '/build/app-bundle.js')
build(appRoot + 'contact/module.js', __dirname + '/build/app-bundle-contact.js')
build(appRoot + 'about/module.js', __dirname + '/build/app-bundle-about.js')

view raw

bundle.js

hosted with ❤ by GitHub

Notice at the bottom of the script we have three separate bundles generated, namely the app entry point (the initial loading), the contact module and the about module. These modules are corresponding to the future states defined in app.js.

Following is a config sample to enable the usage of the generated bundle files. With this config, systemjs will be able to load the bundles instead of the actual individual module files.


System.config({
bundles: {
"build/app-bundle.js": ['app.js'],
"build/app-bundle-contact.js": ['contact/module.js'],
"build/app-bundle-about.js": ['about/module.js']
}
});

Summary

In this post, I presented a solution to enable lazy loading for Angular 1.x modules. This solution will help a lot regarding app boot performance when the app functionalities grow along the road.

While the next generation JavaScript frameworks like Angular 2 and Aurelia are great and almost ready to release, I see there are still a large base of existing apps that will just stay with Angular 1.x and this lazy loading solution can be of a great support for their maintenances.

The accompanied demo project can be found from here https://github.com/jack4it/angular-1x-lazy-load.

Hope this helps,

-Jack

A lazy loading solution for Angular 1.x

A LESS plugin for systemjs/builder

In previous post, I have briefly mentioned that systemjs/builder has a great support of extensibiity by providing a plug-in mechanism. In this post, I will show you how we can leverage this and make loading/bundling LESS files work on top of the systemjs loading pipeline. We are essentially aiming for two goals:

  1. During development time, we should be able to save and refresh to see the results of LESS file changes
  2. During producing time, we should be able to compile and bundle the generated CSS into the bundle file

The github repository of this plug-in and its usage can be found from here https://github.com/jack4it/system-less.

A brief word of LESS

According to its official website: Less is a CSS pre-processor, meaning that it extends the CSS language, adding features that allow variables, mixins, functions and many other techniques that allow you to make CSS that is more maintainable, themable and extendable.

LESS can run in multiple different environments, most importantly, in browser and node.js. These are the two exact environments that our plug-in will need to support. However, unlike the usuall cases, we will invoke LESS API programmatically, instead of running the node.js CLI or using a <script /> to include it on a web page.

The entry point of LESS API looks likes below:


less.render(lessInput, options)
.then(function(output) {
// output.css = string of css
// output.map = string of sourcemap
// output.imports = array of string filenames of the imports referenced
},
function(error) {
});

view raw

less.api.js

hosted with ❤ by GitHub

A quick overview of the plug-in mechanim of systemjs

According to systemjs documentation:

A plugin is just a set of overrides for the loader hooks of the ES6 module specification. The hooks plugins can override are locate, fetch, translate and instantiate.

The behavior of the hooks is:

  • Locate: Overrides the location of the plugin resource
  • Fetch: Called with third argument representing default fetch function, has full control of fetch output.
  • Translate: Returns the translated source from load.source, can also set load.metadata.sourceMap for full source maps support.
  • Instantiate: Providing this hook as a promise or function allows the plugin to hook instantiate. Any return value becomes the defined custom module object for the plugin call.

In our case, we are going to override the Translate hook and another undocumented but obviously necessary one for the bundling scenario. It’s called bundle.

The implementation of system-less, a LESS plug-in for systemjs

Our first goal is to be able to load LESS files and apply the generated CSS styles on the fly during development time. We implement this by overriding the Translate hook like this:


exports.translate = function (load) {
return System.import("less/lib/less-browser")
.then(function (lesscWrapper) {
return lesscWrapper(window, {
async: true,
errorReporting: "Console"
});
})
.then(function (lessc) {
return lessc.render(load.source, {
filename: load.name.replace(/^file:(\/+)?/i, '')
});
})
.then(function (output) {
// output.css = string of css
// output.map = string of sourcemap
// output.imports = array of string filenames of the imports referenced
var style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.textContent = output.css;
document.getElementsByTagName('head')[0].appendChild(style);
load.metadata.format = 'defined';
});
};

There are three major parts of this implementation. First, we import the LESS browser compilation module less/lib/less-browser. This module is a wrapper of the core LESS logic. Second, we call the render method to compile the loaded LESS file content. Notice that the file content is already loaded by the systemjs pipeline, so that we don’t need to worry about the network loading part of it. Third, once we get the compiled results, the CSS styles, we need to inject them to the DOM, so that the browser will be able to pick them up and render the related markups with the new styles.

It’s a fairly straightforward logic to compile and apply LESS files in browsers.

Now it comes to the second goal of being able to compile and bundle LESS into the bundle file. This is a must-have goal for today’s web landscape. We can’t afford to load and compile LESS on the fly for a production system. That would be a kill of perfromance. Unlike loading LESS in browser, bundling via systemjs-builder happens in node.js environment. So the logic will be a bit different. Here is what it looks like:


var cssInject = "(function(c){var d=document,a='appendChild',i='styleSheet',s=d.createElement('style');s.type='text/css';d.getElementsByTagName('head')[0][a](s);s[i]?s[i].cssText=c:s[a](d.createTextNode(c));})";
var escape = function (source) {
return source
.replace(/(["\\])/g, '\\$1')
.replace(/[\f]/g, '\\f')
.replace(/[\b]/g, '\\b')
.replace(/[\n]/g, '\\n')
.replace(/[\t]/g, '\\t')
.replace(/[\r]/g, '\\r')
.replace(/[\ufeff]/g, '')
.replace(/[\u2028]/g, '\\u2028')
.replace(/[\u2029]/g, '\\u2029');
};
exports.translate = function (load) {
load.metadata.format = 'defined';
};
exports.bundle = function (loads, compileOpts, outputOpts) {
var stubDefines = loads.map(function (load) {
return (compileOpts.systemGlobal || "System") + ".register('" + load.name + "', [], false, function() {});";
}).join('\n');
var lessc = System._nodeRequire("less");
var compilePromise = function (load) {
return lessc.render(load.source, {
filename: load.name.replace(/^file:(\/+)?/i, '')
})
.then(function (output) {
// output.css = string of css
// output.map = string of sourcemap
// output.imports = array of string filenames of the imports referenced
return output.css;
})
};
var cssOptimize = outputOpts.minify && outputOpts.cssOptimize !== false;
var CleanCSS = System._nodeRequire("clean-css");
var cleaner = new CleanCSS({
advanced: cssOptimize,
agressiveMerging: cssOptimize,
mediaMerging: cssOptimize,
restructuring: cssOptimize,
shorthandCompacting: cssOptimize,
////sourceMap: !!outputOpts.sourceMaps,
////sourceMapInlineSources: outputOpts.sourceMapContents
});
return Promise.all(loads.map(compilePromise))
.then(function (cssResults) {
var all = cssResults.join("");
var minified = cleaner.minify(all).styles;
return [stubDefines, cssInject, "('" + escape(minified) + "');"].join('\n');
});
};

view raw

hook.bundle.js

hosted with ❤ by GitHub

There a few different things to notice from this implementation. First, we have a minified version of the injection logic which will be inlined into the bundle. It is to be called to inject the CSS styles when systemjs loads the bundles. Second, now we have stubs of system.register for each of the LESS/CSS files. This will be interpretated correctly by systemjs during the load time. Third, optionally for this post but a must-have for a real plug-in, we use clean-css to optimize the generated CSS styles. With this implementation, during producing time, systemjs-builder will be able to figure out the LESS files and compile and bundle them into the bundle file together with other resources.

Summary

In this post, I walked through the process of developing a systemjs/builder plug-in for LESS resources. This plug-in mechanism is a powerful tool to extend the systemjs/builder functionality. In fact, there are already quite a few great plug-ins developed and can be used directly in your project. With these plug-ins, we can easily set up a seamless workflow that easily save and refresh for the development time and optimize the loading performance for production time using bundling.

Hope this helps,

-Jack

A LESS plugin for systemjs/builder