tutorial

How to Monitor strongSwan IPsec VPN Health with Vigilmon

strongSwan charon daemon crashes, IKE negotiation failures, and IPsec tunnel count drops cause VPN connectivity blackouts that are invisible until remote users report they cannot reach internal resources. Learn how to monitor charon daemon health, active SA counts, DPD failure rates, and certificate expiry with Vigilmon HTTP probes and heartbeat monitors.

strongSwan is the most widely deployed open source IPsec VPN and IKEv2 implementation — the routing backbone for site-to-site tunnels, road warrior remote access deployments, and enterprise VPN infrastructure across thousands of organizations. When the charon daemon (the IKE negotiation process at the heart of strongSwan) crashes due to a plugin initialization failure after a configuration change, all established IKE and IPsec Security Associations (SAs) are torn down simultaneously and every VPN tunnel in your deployment drops at once — remote offices lose connectivity to headquarters, remote workers lose access to internal services, and no process-level monitor catches it because charon exited cleanly; when a site-to-site tunnel's IKE_AUTH negotiation starts failing at 50% of the time because an intermediate device began blocking UDP port 4500 (NAT-T), the tunnel stays in an intermittent established/rekeying loop — traffic flows sometimes and fails other times, making the failure appear to be an application problem rather than a VPN problem; when the VPN certificate used for IKE authentication expires, new IKE sessions fail authentication but existing established tunnels continue working until their rekey period arrives, so tunnel count drops gradually over hours rather than immediately at expiry. These are VPN connectivity failures with infrastructure-wide blast radius that require external monitoring of the charon daemon, SA counts, and certificate lifecycle to catch before users report them.

Vigilmon gives you external visibility into strongSwan's IPsec health through HTTP probe monitoring and heartbeat monitors for certificate expiry and tunnel state checks. This tutorial covers both.


Why strongSwan Needs External Monitoring

strongSwan failure modes have broad VPN connectivity impact:

  • charon daemon crash: The charon process handles all IKE negotiation and installs IPsec Security Associations into the Linux kernel's XFRM subsystem; when charon crashes (plugin fault, certificate parsing failure, OOM kill), all IKE sessions are torn down and all XFRM Security Associations are removed — every VPN tunnel configured through strongSwan drops simultaneously; strongSwan does not auto-restart charon by default unless supervised by systemd with Restart=on-failure; the VICI Unix socket at /var/run/charon.vici becomes unreachable the moment charon exits
  • IKE_AUTH failure rate spike: When IKE authentication begins failing — due to certificate expiry, RADIUS authentication backend failure, EAP server unavailability, or a misconfigured PSK — new VPN tunnel establishments fail while existing established tunnels continue working until their rekey timer fires; the failure presents as "I can't connect" rather than "VPN is down" because existing sessions still work
  • Dead Peer Detection (DPD) failures: strongSwan uses DPD to detect when a remote peer has become unreachable (crashed, network path gone); excessive DPD timeouts indicate network instability between VPN endpoints; each DPD timeout forces a tunnel teardown and renegotiation, causing brief connectivity gaps even when the remote peer recovers quickly
  • XFRM policy divergence: charon programs Security Policies (SPs) and Security Associations (SAs) into the Linux kernel's XFRM subsystem; under certain race conditions (charon restart while kernel SAs are still active, missed XFRM netlink events), the policies and SAs installed in the kernel can diverge from what charon believes is established — traffic matching XFRM policies is dropped because the associated SA is missing; ip xfrm policy count and ip xfrm state count divergence is the symptom
  • Certificate expiry on VPN authentication: strongSwan uses X.509 certificates for IKEv2 peer authentication by default; when the VPN authentication certificate expires, charon rejects IKE_AUTH exchanges from peers presenting the expired certificate; existing tunnels continue until rekey, then fail silently — tunnel count drops over hours as tunnels attempt rekey and fail
  • Entropy depletion slowing IKE key generation: strongSwan's IKE key exchange (Diffie-Hellman) requires cryptographic entropy from the kernel; on embedded VPN appliances or heavily loaded VMs, /dev/random can become a bottleneck during simultaneous tunnel establishment storms, causing IKE handshakes to take 10–30 seconds instead of <1 second

External monitoring with Vigilmon adds:

  • Proactive alerting when the VICI socket becomes unreachable (charon crash indicator)
  • SA count trending to catch tunnel count drops before users report connectivity failures
  • DPD failure rate tracking through heartbeat monitors on scheduled swanctl status checks
  • Certificate expiry pre-alerting with configurable advance warning before VPN auth failures begin

Step 1: Build a strongSwan Health Endpoint

strongSwan does not expose an HTTP health endpoint. Build a sidecar that queries the VICI interface via swanctl and exposes health over HTTP.

Node.js Health Sidecar

// health/strongswan.js
const express = require('express');
const { execSync } = require('child_process');

const app = express();

const CERT_SUBJECT = process.env.STRONGSWAN_CERT_SUBJECT || '';
const VPN_HOSTNAME  = process.env.VPN_HOSTNAME || 'localhost';

function runSwanctl(args) {
  return execSync(`swanctl ${args} 2>&1`, { timeout: 10000 }).toString().trim();
}

function getIkeSaCount() {
  const output = runSwanctl('--list-sas');
  // Count IKE_SA blocks (each starts with a connection name line)
  const matches = output.match(/^\S+.*IKE_SA/gm) || [];
  return matches.length;
}

function getChildSaCount() {
  const output = runSwanctl('--list-sas');
  // CHILD_SA lines contain "INSTALLED"
  const matches = output.match(/INSTALLED/g) || [];
  return matches.length;
}

function getDpdStats() {
  // Parse swanctl --stats for DPD-related counters
  try {
    const output = runSwanctl('--stats');
    const dpd = (output.match(/dpd-peer-timeout:\s+(\d+)/) || [])[1] || '0';
    return parseInt(dpd);
  } catch {
    return -1;
  }
}

function checkViCiSocket() {
  try {
    runSwanctl('--version');
    return true;
  } catch {
    return false;
  }
}

function getCertDaysToExpiry(subject) {
  if (!subject) return null;
  try {
    // List loaded certificates and find expiry
    const output = runSwanctl('--list-certs');
    // Look for "expires:" line after the subject
    const idx = output.indexOf(subject);
    if (idx === -1) return null;
    const after = output.slice(idx);
    const expiryMatch = after.match(/expires:\s+(.+)/);
    if (!expiryMatch) return null;
    const expiry = new Date(expiryMatch[1].trim());
    return Math.floor((expiry - Date.now()) / 86400000);
  } catch {
    return null;
  }
}

app.get('/health/strongswan', (req, res) => {
  const checks = {};
  let healthy = true;

  // VICI socket / charon daemon check
  const viciOk = checkViCiSocket();
  checks.charon_vici = viciOk ? 'ok' : 'unreachable';
  if (!viciOk) {
    healthy = false;
    return res.status(503).json({ status: 'down', checks });
  }

  // Active IKE SA count
  try {
    const ikeSaCount = getIkeSaCount();
    checks.ike_sa_count = ikeSaCount;
  } catch (err) {
    checks.ike_sa_count = `error: ${err.message}`;
  }

  // Active CHILD SA (IPsec tunnel) count
  try {
    const childSaCount = getChildSaCount();
    checks.child_sa_count = childSaCount;
  } catch (err) {
    checks.child_sa_count = `error: ${err.message}`;
  }

  // DPD timeout count
  try {
    const dpdTimeouts = getDpdStats();
    checks.dpd_timeouts_total = dpdTimeouts;
  } catch (err) {
    checks.dpd_timeouts = `error: ${err.message}`;
  }

  // Certificate expiry
  if (CERT_SUBJECT) {
    const daysLeft = getCertDaysToExpiry(CERT_SUBJECT);
    if (daysLeft !== null) {
      checks.cert_days_to_expiry = daysLeft;
      if (daysLeft < 30) {
        checks.cert_warning = `VPN auth cert expires in ${daysLeft} days`;
        healthy = false;
      }
    }
  }

  // XFRM policy/state counts for divergence check
  try {
    const policies = execSync('ip xfrm policy count 2>/dev/null || echo 0',
      { timeout: 5000 }).toString().trim();
    const states = execSync('ip xfrm state count 2>/dev/null || echo 0',
      { timeout: 5000 }).toString().trim();
    checks.xfrm_policy_count = parseInt(policies) || 0;
    checks.xfrm_state_count = parseInt(states) || 0;
  } catch (err) {
    checks.xfrm = `error: ${err.message}`;
  }

  return res.status(healthy ? 200 : 503).json({
    status: healthy ? 'ok' : 'degraded',
    checks,
  });
});

app.listen(3020, () => console.log('strongSwan health sidecar on :3020'));

Python (FastAPI) Alternative

# health/strongswan_health.py
import os
import re
import subprocess
import time
from datetime import datetime, timezone
from fastapi import FastAPI, Response
import json

app = FastAPI()

CERT_SUBJECT = os.environ.get("STRONGSWAN_CERT_SUBJECT", "")
VPN_HOSTNAME  = os.environ.get("VPN_HOSTNAME", "localhost")

def run_swanctl(args: str) -> str:
    result = subprocess.run(
        f"swanctl {args}",
        shell=True, capture_output=True, text=True, timeout=10
    )
    return result.stdout + result.stderr

def check_vici_socket() -> bool:
    try:
        run_swanctl("--version")
        return True
    except Exception:
        return False

def get_sa_counts() -> dict:
    output = run_swanctl("--list-sas")
    ike_count = len(re.findall(r"IKE_SA", output))
    child_count = len(re.findall(r"INSTALLED", output))
    return {"ike_sa_count": ike_count, "child_sa_count": child_count}

def get_dpd_stats() -> int:
    try:
        output = run_swanctl("--stats")
        match = re.search(r"dpd-peer-timeout:\s+(\d+)", output)
        return int(match.group(1)) if match else 0
    except Exception:
        return -1

def get_cert_days_to_expiry(subject: str):
    if not subject:
        return None
    try:
        output = run_swanctl("--list-certs")
        idx = output.find(subject)
        if idx == -1:
            return None
        after = output[idx:]
        match = re.search(r"expires:\s+(.+)", after)
        if not match:
            return None
        expiry_str = match.group(1).strip()
        expiry = datetime.strptime(expiry_str, "%b %d %H:%M:%S %Y")
        expiry = expiry.replace(tzinfo=timezone.utc)
        now = datetime.now(timezone.utc)
        return (expiry - now).days
    except Exception:
        return None

def get_xfrm_counts() -> dict:
    try:
        policy = subprocess.run(
            "ip xfrm policy count", shell=True,
            capture_output=True, text=True, timeout=5
        )
        state = subprocess.run(
            "ip xfrm state count", shell=True,
            capture_output=True, text=True, timeout=5
        )
        return {
            "xfrm_policy_count": int(policy.stdout.strip() or 0),
            "xfrm_state_count": int(state.stdout.strip() or 0),
        }
    except Exception as e:
        return {"xfrm_error": str(e)}

@app.get("/health/strongswan")
async def strongswan_health():
    checks = {}
    healthy = True

    vici_ok = check_vici_socket()
    checks["charon_vici"] = "ok" if vici_ok else "unreachable"
    if not vici_ok:
        healthy = False
        return Response(
            content=json.dumps({"status": "down", "checks": checks}),
            status_code=503, media_type="application/json"
        )

    try:
        sa_counts = get_sa_counts()
        checks.update(sa_counts)
    except Exception as e:
        checks["sa_counts"] = f"error: {str(e)}"

    dpd = get_dpd_stats()
    checks["dpd_timeouts_total"] = dpd

    if CERT_SUBJECT:
        days_left = get_cert_days_to_expiry(CERT_SUBJECT)
        if days_left is not None:
            checks["cert_days_to_expiry"] = days_left
            if days_left < 30:
                checks["cert_warning"] = f"VPN auth cert expires in {days_left} days"
                healthy = False

    checks.update(get_xfrm_counts())

    status_code = 200 if healthy else 503
    return Response(
        content=json.dumps({"status": "ok" if healthy else "degraded", "checks": checks}),
        status_code=status_code, media_type="application/json"
    )

Step 2: Configure Vigilmon Monitoring

HTTP Monitor — strongSwan Health Endpoint

In your Vigilmon dashboard, create an HTTP monitor for the strongSwan sidecar:

| Field | Value | |-------|-------| | Monitor name | strongSwan VPN Health | | URL | http://vpn-gateway.internal:3020/health/strongswan | | Method | GET | | Check interval | Every 1 minute | | Expected status | 200 | | Alert threshold | 2 consecutive failures | | Regions | Select 2+ regions for consensus |

Heartbeat Monitor — IKE SA Count Check

Monitor the active IKE session count and alert on unexpected drops. Configure a 5-minute timeout heartbeat:

#!/bin/bash
# /etc/cron.d/strongswan-sa-count — runs every 3 minutes
# Alert if IKE SA count drops below expected minimum

HEARTBEAT_URL="https://vigilmon.online/hb/your-strongswan-sa-count-heartbeat"
MIN_IKE_SAS="${STRONGSWAN_MIN_IKE_SAS:-1}"

IKE_COUNT=$(swanctl --list-sas 2>/dev/null | grep -c "IKE_SA" || echo 0)

if [ "$IKE_COUNT" -ge "$MIN_IKE_SAS" ]; then
  curl -fsS --max-time 10 "$HEARTBEAT_URL" > /dev/null 2>&1
else
  echo "strongSwan IKE SA count dropped to ${IKE_COUNT} (expected >=${MIN_IKE_SAS})" >&2
  # Optionally notify ops channel
fi

Configure the heartbeat with a 5-minute timeout to catch drops within one check cycle.

Heartbeat Monitor — DPD Failure Rate

Watch for Dead Peer Detection failures indicating network instability:

#!/bin/bash
# /etc/cron.d/strongswan-dpd-check — runs every 10 minutes
HEARTBEAT_URL="https://vigilmon.online/hb/your-strongswan-dpd-heartbeat"
LOG_FILE="/var/log/strongswan.log"
MAX_DPD_PER_HOUR=10

# Count DPD timeout messages in the last 60 minutes
DPD_COUNT=$(grep "DPD" "$LOG_FILE" 2>/dev/null | \
  awk -v cutoff="$(date -d '60 minutes ago' '+%Y-%m-%d %H:%M')" \
  '$0 > cutoff' | grep -c "peer not responding" || echo 0)

if [ "$DPD_COUNT" -lt "$MAX_DPD_PER_HOUR" ]; then
  curl -fsS --max-time 10 "$HEARTBEAT_URL" > /dev/null 2>&1
else
  echo "strongSwan DPD failure rate: ${DPD_COUNT} timeouts in last hour (threshold: ${MAX_DPD_PER_HOUR})" >&2
fi

Configure with a 15-minute timeout (runs every 10 minutes with 5-minute slack).

Heartbeat Monitor — Certificate Expiry Pre-Alert

#!/bin/bash
# /etc/cron.d/strongswan-cert-check — runs daily
HEARTBEAT_URL="https://vigilmon.online/hb/your-strongswan-cert-heartbeat"
CERT_FILE="${STRONGSWAN_CERT_FILE:-/etc/ipsec.d/certs/vpn-cert.pem}"

DAYS_LEFT=$(openssl x509 -noout -enddate -in "$CERT_FILE" 2>/dev/null | \
  sed 's/notAfter=//' | \
  python3 -c "
import sys
from datetime import datetime, timezone
expiry = datetime.strptime(sys.stdin.read().strip(), '%b %d %H:%M:%S %Y %Z')
expiry = expiry.replace(tzinfo=timezone.utc)
from datetime import datetime as dt2
now = dt2.now(timezone.utc)
print((expiry - now).days)
" 2>/dev/null)

if [ -n "$DAYS_LEFT" ] && [ "$DAYS_LEFT" -gt 30 ]; then
  curl -fsS --max-time 10 "$HEARTBEAT_URL" > /dev/null 2>&1
else
  echo "strongSwan VPN certificate expires in ${DAYS_LEFT:-unknown} days" | \
    mail -s "ALERT: strongSwan VPN Certificate Expiry" ops@your-org.example.com
fi

Configure with a 25-hour timeout for daily checks with slack.


Step 3: Configure Alerting

Alert Policies

charon Daemon Down Alert

  • Trigger: Health endpoint returns 503 with charon_vici: unreachable
  • Severity: Critical — P1
  • Channels: PagerDuty (immediate) + Slack #infrastructure-critical
  • Message: "strongSwan charon daemon is unreachable. All IKE SAs and IPsec tunnels have been torn down. VPN connectivity is completely unavailable. Restart charon via systemctl restart strongswan and investigate logs."

IKE SA Count Drop Alert

  • Trigger: Heartbeat not received for 5 minutes (SA count below minimum)
  • Severity: High
  • Channels: PagerDuty + Slack #vpn-alerts
  • Message: "strongSwan IKE SA count has dropped below expected minimum. One or more VPN tunnels have disconnected. Check swanctl --list-sas for current state and /var/log/strongswan.log for authentication or negotiation errors."

DPD Failure Rate Alert

  • Trigger: Heartbeat not received for 15 minutes (>10 DPD timeouts/hour)
  • Severity: High
  • Channels: Slack #vpn-alerts + email to network team
  • Message: "strongSwan Dead Peer Detection failure rate is elevated. VPN peers are not responding to DPD probes. Check network path stability between VPN endpoints and verify UDP 500/4500 are not being blocked."

VPN Certificate Expiry Alert

  • Trigger: Heartbeat not received for 25 hours (cert <30 days to expiry)
  • Severity: High
  • Channels: Slack #infrastructure-alerts + email to ops
  • Message: "strongSwan VPN authentication certificate expires within 30 days. Renew the certificate using your CA before expiry causes IKE_AUTH failures for all new tunnel establishments."

Key Metrics Summary

| Metric | Alert Threshold | Impact | |--------|----------------|--------| | charon daemon / VICI socket | Any failure | All VPN tunnels torn down immediately | | Active IKE SA count | Drop below expected baseline | VPN session loss; remote access failures | | Active CHILD SA (IPsec tunnel) count | Drop below expected baseline | Traffic-level tunnel failures | | IKE_AUTH failure rate | >10% of attempts | New VPN connections failing; rekey failures begin | | DPD timeout rate | >10/hour sustained | Network instability causing tunnel disruption | | VPN certificate days to expiry | <30 days | Scheduled IKE_AUTH failures at expiry | | XFRM policy vs SA count divergence | Any divergence | Encrypted traffic silently dropped | | Tunnel throughput (bytes/SA) | Drop >50% vs baseline | Routing or upstream network issue | | EAP authentication failure rate | >5% of attempts | Remote access (road warrior) failures | | Kernel entropy pool | Depletion | IKE key exchange slows; handshakes timeout |


Conclusion

strongSwan is the VPN backbone for site-to-site connectivity and remote access across enterprise and cloud deployments — when the charon daemon crashes or VPN certificates expire, every tunnel in the deployment is affected. Vigilmon HTTP probes on the strongSwan health sidecar catch charon process failures the moment the VICI socket becomes unreachable, while heartbeat monitors on SA count checks, DPD failure rates, and certificate expiry pre-alerts give you coverage of the gradual failures that process monitors miss. Configure the monitors in this tutorial and your network team will receive alerts about VPN problems before remote offices and workers report they cannot reach internal resources.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →