Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

strongSwan Mesh

A routed bridge network carries the traffic between the nodes in tunnels, ipip or ip6ip6, which encrypt nothing. This page deploys strongSwan on every node to encrypt them with IPsec, its configuration rendered by the agent from the tunnels of the node, and following them as nodes join or leave and as the key rotates.

The deployment was run as written on a three-node cluster, dev2n1, dev2n2 and dev2n3, whose backend1 routed bridge tunnels its traffic over fd01:2345:6789:2900::/64. The outputs below are from that cluster.

How it works

Three objects of the system namespace:

  • system/sec/strongswan, the pre-shared key of the mesh.
  • system/cfg/strongswan, the strongSwan configuration, its swanctl.conf a template rendered on each node into one IPsec connection per tunnel of the node.
  • system/svc/strongswan, a service running on every node, which installs the configuration in a tmpfs, runs strongSwan in a container, and keeps the two up to date with two event-triggered tasks:
    • task#refresh installs the configuration again when the peers of a network change, rendered from the new tunnels.
    • task#load_all has strongSwan load the configuration again whenever an install wrote it, whatever ran the install: the refresh, a start, or a change of the key.

The pre-shared key

om system/sec/strongswan create
om system/sec/strongswan key add --name psk --value "$(openssl rand -base64 32)"
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary '[DEFAULT]' \
  "https://<node>:1215/api/object/path/system/sec/strongswan/config/file"

openssl rand -base64 32 | tr -d '\n' |
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @- \
  "https://<node>:1215/api/object/path/system/sec/strongswan/data/key?name=psk"

The configuration

swanctl.conf is a template ranging over the tunnels of the node, each tunnel once, with the addresses of its two ends:

# Rendered by om on {{ref "nodename"}}: one transport mode connection per
# tunnel of the networks of the node, encrypting what it carries.
connections {
{{- range tunnels }}
    {{.Nodename}} {
        version      = 2
        local_addrs  = {{.Local}}
        remote_addrs = {{.Remote}}
        local {
            auth = psk
        }
        remote {
            auth = psk
        }
        children {
            om-tunnel {
                mode         = transport
                local_ts     = dynamic[4],dynamic[41]
                remote_ts    = dynamic[4],dynamic[41]
                start_action = trap
            }
        }
    }
{{- end }}
}
secrets {
    ike-mesh {
        secret = {{printf "%q" (key "./sec/strongswan" "psk")}}
    }
}
  • The connections are in transport mode, and select the protocols 4 and 41, the ipip and ip6ip6 tunnels: what is encrypted is the tunnel traffic, between the two ends. With tunnel_mode = gre, select 47.
  • start_action = trap negotiates a connection on its first packet, so a node joining needs no action of its peers. The packets sent while it is negotiated are dropped.
  • key reads the pre-shared key from the sec. The rendered file holds it, so its install line sets mode 600.

strongswan.conf has strongSwan load the configuration when it starts:

charon {
    start-scripts {
        swanctl-load = swanctl --load-all
    }
}
om system/cfg/strongswan create
om system/cfg/strongswan key add --name swanctl.conf --from swanctl.conf
om system/cfg/strongswan key add --name strongswan.conf --from strongswan.conf
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary '[DEFAULT]' \
  "https://<node>:1215/api/object/path/system/cfg/strongswan/config/file"

for key in swanctl.conf strongswan.conf
do
  curl -s -X POST -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @$key \
    "https://<node>:1215/api/object/path/system/cfg/strongswan/data/key?name=$key"
done

The service

strongswan.svc.conf:

[DEFAULT]
topology = flex
flex_min = {#nodes}
orchestrate = ha
nodes = {clusternodes}

[fs#1]
type = tmpfs
mnt = /srv/{fqdn}
size = 1m
mode = 700
install = /swanctl.conf from ./cfg/{name} key swanctl.conf mode 600 template required
    /strongswan.conf from ./cfg/{name} key strongswan.conf mode 644 required

[container#swanctl]
type = oci
image = mberner/strongswan:5.9.11
rm = true
detach = true
privileged = true
netns = host
volume_mounts = {fs#1.mnt}/swanctl.conf:/etc/swanctl/swanctl.conf:ro {fs#1.mnt}/strongswan.conf:/etc/strongswan.conf:ro

[task#refresh]
type = host
install = fs#1
on = NetworkPeersUpdated
pace = @30s backoff @1m

[task#load_all]
type = host
container = container#swanctl
command = swanctl --load-all
log = true
on = FileInstalled,rid=fs#1
  • flex_min = {#nodes} runs an instance on every node, each one encrypting the tunnels of its node.
  • The configuration is installed in a tmpfs, so the rendered key never reaches a disk.
  • The container shares the network namespace of the node, where the tunnels and the IPsec policies are. Its image is an example: any image with strongSwan’s charon and swanctl does.
  • task#refresh is a host task with no command: its work is the install. pace runs it once for the several networks a node joining updates.
  • task#load_all runs swanctl --load-all in the container, whatever its runtime, on the installs that wrote the files.
om system/svc/strongswan create --config strongswan.svc.conf --provision --wait
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @strongswan.svc.conf \
  "https://<node>:1215/api/object/path/system/svc/strongswan/config/file"

curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  "https://<node>:1215/api/object/path/system/svc/strongswan/action/provision"
$ om system/svc/strongswan instance ls
OBJECT                 NODE    AVAIL
system/svc/strongswan  dev2n1  up
system/svc/strongswan  dev2n2  up
system/svc/strongswan  dev2n3  up

Check

Each node renders its own peers. On dev2n1, the secret masked:

connections {
    dev2n2 {
        version      = 2
        local_addrs  = fd01:2345:6789:2900::11
        remote_addrs = fd01:2345:6789:2900::12
        ...
    }
    dev2n3 {
        version      = 2
        local_addrs  = fd01:2345:6789:2900::11
        remote_addrs = fd01:2345:6789:2900::13
        ...
    }
}

The first traffic in the tunnels negotiates the connections:

$ docker exec system..strongswan.container.swanctl swanctl --list-sas
dev2n2: #2, ESTABLISHED, IKEv2, ea2b32a62ceaa7c3_i 5d09b564f3813dee_r*
  local  'fd01:2345:6789:2900::11' @ fd01:2345:6789:2900::11[4500]
  remote 'fd01:2345:6789:2900::12' @ fd01:2345:6789:2900::12[4500]
  AES_CBC-128/HMAC_SHA2_256_128/PRF_HMAC_SHA2_256/ECP_256
  om-tunnel: #4, reqid 1, INSTALLED, TRANSPORT, ESP:AES_GCM_16-128
    local  fd01:2345:6789:2900::11/128[ipencap] fd01:2345:6789:2900::11/128[ipv6]
    remote fd01:2345:6789:2900::12/128[ipencap] fd01:2345:6789:2900::12/128[ipv6]
dev2n3: #1, ESTABLISHED, IKEv2, 44ae77070c3fa61d_i 5d835693278b8de3_r*
  ...

and the kernel encrypts the tunnel traffic with ESP:

$ ip xfrm state | grep -A1 ^src
src fd01:2345:6789:2900::11 dst fd01:2345:6789:2900::12
	proto esp spi 0xc5333751 reqid 1 mode transport
src fd01:2345:6789:2900::12 dst fd01:2345:6789:2900::11
	proto esp spi 0xc5894520 reqid 1 mode transport
...

Rotate the key

A change of the pre-shared key installs swanctl.conf again on every node, as the template read it, and the install has strongSwan load it:

om system/sec/strongswan key change --name psk --value "$(openssl rand -base64 32)"
openssl rand -base64 32 | tr -d '\n' |
curl -s -X PUT -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @- \
  "https://<node>:1215/api/object/path/system/sec/strongswan/data/key?name=psk"
$ om system/svc/strongswan logs --node '*'
...
2026-10-11T22:33:45.687392+02:00 INF dev2n1: instance: system/svc/strongswan: fs#1: reinstall key swanctl.conf from system/cfg/strongswan to /srv/strongswan.system.svc.reliable-leopard/swanctl.conf with owner : perm -rw-------
2026-10-11T22:33:45.888706+02:00 INF dev2n1: daemon: scheduler: system/svc/strongswan: task#load_all: run: on FileInstalled,node=dev2n1,path=system/svc/strongswan,rid=fs#1: due now
2026-10-11T22:33:46.053920+02:00 INF dev2n2: instance: system/svc/strongswan: fs#1: reinstall key swanctl.conf from system/cfg/strongswan to /srv/strongswan.system.svc.reliable-leopard/swanctl.conf with owner : perm -rw-------
2026-10-11T22:33:46.058449+02:00 INF dev2n3: instance: system/svc/strongswan: fs#1: reinstall key swanctl.conf from system/cfg/strongswan to /srv/strongswan.system.svc.reliable-leopard/swanctl.conf with owner : perm -rw-------
2026-10-11T22:33:46.256092+02:00 INF dev2n2: daemon: scheduler: system/svc/strongswan: task#load_all: run: on FileInstalled,node=dev2n2,path=system/svc/strongswan,rid=fs#1: due now
2026-10-11T22:33:46.259537+02:00 INF dev2n3: daemon: scheduler: system/svc/strongswan: task#load_all: run: on FileInstalled,node=dev2n3,path=system/svc/strongswan,rid=fs#1: due now

The connections established keep their keys until they are negotiated again. The ones negotiated after the load use the new pre-shared key.

Nodes joining and leaving

A node leaving the cluster changes the peers of the routed bridges of the others. Their network setup publishes one NetworkPeersUpdated per network, task#refresh runs at the first and once more for the others, and renders the tunnels left:

On dev2n1, as dev2n3 was evicted, the labels left out:

$ om daemon events --filter NetworkPeersUpdated --filter FileInstalled -o json
{"kind":"NetworkPeersUpdated","id":0,"at":"2026-10-11T21:11:04.34949462+02:00","data":{"node":"dev2n1","name":"backend1"}}
{"kind":"NetworkPeersUpdated","id":1,"at":"2026-10-11T21:11:04.349501204+02:00","data":{"node":"dev2n1","name":"backend2"}}
{"kind":"NetworkPeersUpdated","id":2,"at":"2026-10-11T21:11:04.355136718+02:00","data":{"node":"dev2n1","name":"backend3"}}
{"kind":"NetworkPeersUpdated","id":3,"at":"2026-10-11T21:11:04.355946578+02:00","data":{"node":"dev2n1","name":"backend4"}}
{"kind":"NetworkPeersUpdated","id":4,"at":"2026-10-11T21:11:04.356517017+02:00","data":{"node":"dev2n1","name":"backend5"}}
{"kind":"FileInstalled","id":5,"at":"2026-10-11T21:11:04.746653447+02:00","data":{"node":"dev2n1","path":"system/svc/strongswan","rid":"fs#1","files":["/swanctl.conf"],"at":"2026-10-11T21:11:04.544314706+02:00"}}

The file was installed within a second of the network setup, and loaded: the connection to dev2n3 was gone. The run the other networks asked for, 30 seconds later, rendered the same file, installed nothing, and loaded nothing.

A node joining is the same the other way round: the others render the tunnel to it as soon as it is in the cluster nodes, and its own instance renders all the tunnels when it starts.

➡️ See Also