#!/bin/bash
# Lulu Cloud Connect - privileged helper.
#
# Everything that needs root lives here and nowhere else, so the web layer's
# sudo rights are a fixed list of verbs rather than "run anything". Called by
# the FreePBX module as: sudo /usr/local/sbin/lulucpe-helper <verb>
#
# configure reads its JSON on stdin. It is never given a file path from the web
# layer - the helper builds wg0.conf itself, so a compromised web process
# cannot point it at a config of its own making.

set -u
umask 077

# Read by the module to tell whether the copy in /usr/local/sbin is the one
# that shipped with the pages calling it. A module upgrade replaces the web
# half; only bootstrap.sh (root) replaces this file, so the two can drift and
# a box then runs new pages against an old helper - which looks like the new
# code failing.
LULUCPE_HELPER_VERSION=2.4.1

# /usr/local/bin first, and explicitly: sudo's secure_path leaves it out, so
# the binaries we install ourselves are invisible to a bare "wg" without this.
# It also means our current wg wins over a distribution's 2020 one, which is
# what we want on every box - see the WG_BIN note below.
PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH

WG_DIR=/etc/wireguard
# Absolute path on purpose: sudo's secure_path does not include /usr/local/bin,
# so "command -v wireguard-go" finds nothing even when the binary is installed.
WG_GO=/usr/local/bin/wireguard-go
WG_GO_URL=https://www.luluintegrations.com/downloads/wireguard-go-amd64
# EL7 ships wireguard-tools from 2020, and "wg setconf" from that build HANGS
# against a current wireguard-go - wg-quick then never reaches "ip address add",
# so the tunnel handshakes but the interface has no address. The userspace path
# therefore brings its own matched pair rather than mixing vintages.
WG_BIN=/usr/local/bin/wg
WG_BIN_URL=https://www.luluintegrations.com/downloads/wg-amd64
WG_QUICK=/usr/local/bin/wg-quick
WG_QUICK_URL=https://www.luluintegrations.com/downloads/wg-quick-linux
IFACE=wg0
CONF="$WG_DIR/$IFACE.conf"
KEY="$WG_DIR/lulucpe.key"
PUB="$WG_DIR/lulucpe.pub"
ENV_FILE="$WG_DIR/lulucpe.env"
LOG=/var/log/lulucpe.log

log() { echo "$(date -Is) $*" >> "$LOG" 2>/dev/null; }
die() { echo "$*" >&2; log "ERROR: $*"; exit 1; }

# JSON parsing without adding a dependency: PHP is always present on FreePBX.
json() { php -r '$d=json_decode(file_get_contents("php://stdin"),true); $k=explode(".",$argv[1]); foreach($k as $p){ if(!isset($d[$p])){ echo ""; exit; } $d=$d[$p]; } echo is_scalar($d)?$d:json_encode($d);' "$1"; }

need_root() { [ "$(id -u)" -eq 0 ] || die "must run as root"; }

install_wireguard() {
  command -v wg >/dev/null 2>&1 && return 0

  log "installing wireguard"
  if command -v yum >/dev/null 2>&1; then
    yum -y install wireguard-tools >>"$LOG" 2>&1
    # EL7's wireguard-tools declares a file dependency on /usr/bin/python3,
    # which a box carrying the IUS python36u packages cannot satisfy - and
    # letting yum resolve it pulls a python3 that file-conflicts with them.
    # wg and wg-quick are C and bash; nothing we call needs python at all.
    if ! command -v wg >/dev/null 2>&1; then
      log "yum route failed, installing the rpm without its python3 dep"
      yum -y install yum-utils >>"$LOG" 2>&1
      rm -rf /tmp/lulucpe-wgrpm && mkdir -p /tmp/lulucpe-wgrpm
      yumdownloader --destdir=/tmp/lulucpe-wgrpm wireguard-tools >>"$LOG" 2>&1
      rpm -ivh --nodeps /tmp/lulucpe-wgrpm/wireguard-tools*.rpm >>"$LOG" 2>&1
      rm -rf /tmp/lulucpe-wgrpm
    fi
    if ! modprobe wireguard >/dev/null 2>&1; then
      # CentOS 7 / Sangoma 7 run 3.10 kernels, which predate in-kernel
      # WireGuard. ELRepo used to ship kmod-wireguard for EL7 and has since
      # retired it, so there is no module to install and the userspace
      # implementation below is the only route on these boxes.
      yum -y --enablerepo=elrepo install kmod-wireguard >>"$LOG" 2>&1
      modprobe wireguard >/dev/null 2>&1
    fi
  elif command -v apt-get >/dev/null 2>&1; then
    apt-get update >>"$LOG" 2>&1
    apt-get -y install wireguard wireguard-tools >>"$LOG" 2>&1
    modprobe wireguard >/dev/null 2>&1
  fi

  command -v wg >/dev/null 2>&1
}

# The tools, from us, when the box's own repositories cannot supply them - a
# PBX with no EPEL, an appliance image with the repos stripped, or a release
# whose mirrors have gone. These are the same static binaries the userspace
# path uses, and they work equally well against a kernel module, so a missing
# package is never a reason to stop. It used to be: preflight died on it and
# the module reported "WireGuard is not usable here", on a box where nothing
# was actually wrong except a package list.
install_wireguard_tools_static() {
  fetch_bin "$WG_BIN"   "$WG_BIN_URL"   || return 1
  fetch_bin "$WG_QUICK" "$WG_QUICK_URL" || return 1
  command -v wg >/dev/null 2>&1
}

# Userspace WireGuard, for kernels with no module. A single static binary, so
# there is nothing to compile on a customer's PBX and no dependency to conflict.
install_wireguard_go() {
  fetch_bin "$WG_GO"    "$WG_GO_URL"    || return 1
  fetch_bin "$WG_BIN"   "$WG_BIN_URL"   || return 1
  fetch_bin "$WG_QUICK" "$WG_QUICK_URL" || return 1
  return 0
}

fetch_bin() {
  local dest="$1" url="$2"
  [ -x "$dest" ] && return 0
  # We publish 64-bit Intel/AMD builds only. Fetching one onto another
  # architecture yields a binary that will not execute, and the failure that
  # follows says nothing about why.
  if [ "$(uname -m)" != "x86_64" ]; then
    log "no prebuilt $(basename "$dest") for $(uname -m)"
    return 1
  fi
  log "fetching $(basename "$dest")"
  curl -fsSL -o "$dest.tmp" "$url" >>"$LOG" 2>&1 || { rm -f "$dest.tmp"; return 1; }
  chmod 755 "$dest.tmp" && mv "$dest.tmp" "$dest"
  [ -x "$dest" ]
}

# Whichever wg-quick we should be driving: ours when we installed it, the
# distribution's when the kernel module makes that the right one.
wg_quick_cmd() {
  # Ours whenever it is here: wg and wg-quick must be of one vintage, and if
  # ours is present it is because the distribution could not supply the pair.
  if [ -x "$WG_QUICK" ]; then
    echo "$WG_QUICK"
  else
    echo "wg-quick"
  fi
}

kernel_module_present() {
  lsmod 2>/dev/null | grep -q "^wireguard" || modprobe wireguard 2>/dev/null
}

case "${1:-}" in

  preflight)
    need_root
    mkdir -p "$WG_DIR"
    # The package manager is tried first and is allowed to fail: on a box with
    # a kernel module its wg is the natural one, but nothing here depends on
    # it. We ship the tools ourselves for every case it cannot serve.
    install_wireguard || log "packaged wireguard-tools unavailable, using the binaries we ship"
    mode="kernel"
    if ! kernel_module_present; then
      # No module on this kernel, so fall back to userspace - fetching it if
      # this box has never needed it before.
      install_wireguard_go \
        || die "This system has no WireGuard in its kernel, and the userspace version could not be downloaded from $WG_GO_URL. The box needs outbound HTTPS to www.luluintegrations.com, and must be 64-bit Intel/AMD (this one is $(uname -m)). See $LOG."
      mode="userspace"
    elif ! command -v wg >/dev/null 2>&1; then
      install_wireguard_tools_static \
        || die "WireGuard tools are not available from this system's repositories and could not be downloaded from $WG_BIN_URL. The box needs outbound HTTPS to www.luluintegrations.com, and must be 64-bit Intel/AMD (this one is $(uname -m)). See $LOG."
    fi
    command -v wg >/dev/null 2>&1 || die "wg is still not runnable after installation - see $LOG"
    echo "{\"wg\":\"$(wg --version 2>/dev/null | head -1)\",\"mode\":\"$mode\"}"
    ;;

  # Everything an engineer would run by hand on a box they cannot reach. A PBX
  # in a customer's cupboard has no SSH open to us and never will, so the box
  # has to be able to describe itself through its own web page.
  diagreport)
    need_root
    echo "== identity =="
    echo "helper: ${LULUCPE_HELPER_VERSION}"
    echo "arch:   $(uname -m)"
    echo "kernel: $(uname -r)"
    cat /etc/redhat-release 2>/dev/null || grep -h PRETTY_NAME /etc/os-release 2>/dev/null
    echo
    echo "== wireguard =="
    echo "wg path:   $(command -v wg 2>/dev/null || echo 'NOT FOUND')"
    echo "wg version: $(wg --version 2>&1 | head -1)"
    echo "wg-quick:  $(wg_quick_cmd)"
    for f in "$WG_BIN" "$WG_QUICK" "$WG_GO"; do
      [ -e "$f" ] && echo "ours: $f ($(stat -c %s "$f" 2>/dev/null) bytes)" || echo "ours: $f absent"
    done
    echo "kernel module: $(kernel_module_present && echo present || echo absent)"
    echo "package: $( (rpm -q wireguard-tools 2>/dev/null || dpkg -s wireguard-tools 2>/dev/null | head -1) || echo 'not installed by package manager')"
    echo
    echo "== tunnel =="
    ip -4 addr show "$IFACE" 2>/dev/null || echo "$IFACE: no such interface"
    wg show "$IFACE" 2>/dev/null | head -12
    systemctl is-enabled lulucpe-wg.service 2>&1 | sed 's/^/lulucpe-wg.service: /'
    echo
    echo "== reachability =="
    curl -fsS -m 10 -o /dev/null -w "luluintegrations.com: HTTP %{http_code} in %{time_total}s\n" \
      https://www.luluintegrations.com/downloads/lulucpe-latest.tgz 2>&1 || echo "luluintegrations.com: UNREACHABLE"
    echo
    echo "== last 60 log lines =="
    tail -60 "$LOG" 2>/dev/null || echo "(no log yet)"
    ;;

  genkey)
    need_root
    mkdir -p "$WG_DIR"
    # A fresh key every enrolment. Reusing one across devices would mean a
    # single stolen box could impersonate any other.
    wg genkey > "$KEY" 2>/dev/null || die "wg genkey failed"
    chmod 600 "$KEY"
    wg pubkey < "$KEY" > "$PUB" || die "wg pubkey failed"
    chmod 644 "$PUB"
    cat "$PUB"
    ;;

  pubkey)
    need_root
    [ -f "$PUB" ] || die "no key yet - run genkey first"
    cat "$PUB"
    ;;

  configure)
    need_root
    [ -f "$KEY" ] || die "no private key - run genkey first"
    payload=$(cat)
    [ -n "$payload" ] || die "no configuration on stdin"

    address=$(printf '%s' "$payload"   | json address)
    mtu=$(printf '%s' "$payload"       | json mtu)
    peer_key=$(printf '%s' "$payload"  | json peer.publicKey)
    endpoint=$(printf '%s' "$payload"  | json peer.endpoint)
    allowed=$(printf '%s' "$payload"   | json peer.allowedIps)
    keepalive=$(printf '%s' "$payload" | json peer.keepalive)
    cloud=$(printf '%s' "$payload"     | json peer.cloudIp)

    [ -n "$address" ]  || die "portal did not supply a tunnel address"
    [ -n "$peer_key" ] || die "portal did not supply its public key"
    [ -n "$endpoint" ] || die "portal did not supply an endpoint"
    : "${mtu:=1420}"
    : "${allowed:=10.8.0.0/16}"
    : "${keepalive:=25}"

    [ -f "$CONF" ] && cp -a "$CONF" "$CONF.bak.$(date +%s)"

    cat > "$CONF" <<EOF
# Managed by Lulu Cloud Connect. Edits are overwritten on re-enrolment.
[Interface]
Address = $address
PrivateKey = $(cat "$KEY")
MTU = $mtu

[Peer]
PublicKey = $peer_key
Endpoint = $endpoint
AllowedIPs = $allowed
# The cloud has to be able to call INTO this box, not just receive from it, so
# the tunnel is kept alive from this side through whatever NAT is in front.
PersistentKeepalive = $keepalive
EOF
    chmod 600 "$CONF"
    echo "CLOUD_IP=$cloud" > "$ENV_FILE"

    # The distribution's wg-quick@ template is used only when the distribution
    # actually supplied one. A box whose repositories had no wireguard-tools
    # is running the binaries we fetched, and "systemctl start wg-quick@wg0"
    # there fails on a unit that does not exist.
    if kernel_module_present && systemctl list-unit-files 2>/dev/null | grep -q '^wg-quick@'; then
      systemctl enable "wg-quick@$IFACE" >>"$LOG" 2>&1
      systemctl restart "wg-quick@$IFACE" >>"$LOG" 2>&1 || die "wg-quick@$IFACE failed to start - see $LOG"
    else
      if kernel_module_present; then
        install_wireguard_tools_static || die "the WireGuard tools could not be fetched - can this box reach www.luluintegrations.com?"
      else
        install_wireguard_go || die "userspace WireGuard could not be fetched - can this box reach the internet?"
        export WG_QUICK_USERSPACE_IMPLEMENTATION="$WG_GO"
      fi

      "$WG_QUICK" down "$IFACE" >>"$LOG" 2>&1
      systemctl stop "wg-quick@$IFACE" >>"$LOG" 2>&1
      timeout 60 "$WG_QUICK" up "$IFACE" >>"$LOG" 2>&1 || die "the tunnel did not come up - see $LOG"

      # Our own unit, for both of these cases: on a userspace box the
      # distribution's wg-quick@ template would run the old binaries again on
      # the next reboot and hang exactly as before, and on a box without the
      # package there is no template to run at all.
      cat > /etc/systemd/system/lulucpe-wg.service <<EOS
[Unit]
Description=Lulu Cloud Connect tunnel ($IFACE)
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
Environment=WG_QUICK_USERSPACE_IMPLEMENTATION=$WG_GO
Environment=PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin
ExecStart=$WG_QUICK up $IFACE
ExecStop=$WG_QUICK down $IFACE

[Install]
WantedBy=multi-user.target
EOS
      systemctl daemon-reload >>"$LOG" 2>&1
      systemctl enable lulucpe-wg.service >>"$LOG" 2>&1
      log "tunnel up via $WG_QUICK (kernel module: $(kernel_module_present && echo yes || echo no))"
    fi

    sleep 2
    # The address matters as much as the interface: a handshaking tunnel with
    # no address carries nothing, and that is exactly how the mismatched
    # wireguard-tools failure presented.
    ip -4 addr show "$IFACE" 2>/dev/null | grep -q "inet " \
      || die "$IFACE came up but has no address - see $LOG"
    log "configured tunnel $address -> $endpoint"
    echo "{\"ok\":true,\"address\":\"$address\"}"
    ;;

  verify)
    need_root
    wg show "$IFACE" >/dev/null 2>&1 || die "tunnel $IFACE is not up"
    hs=$(wg show "$IFACE" latest-handshakes | awk '{print $2}' | head -1)
    [ -n "$hs" ] && [ "$hs" != "0" ] || die "no WireGuard handshake yet - check UDP reachability to the endpoint"
    age=$(( $(date +%s) - hs ))
    cloud=""
    [ -f "$ENV_FILE" ] && . "$ENV_FILE" && cloud="$CLOUD_IP"
    if [ -n "$cloud" ]; then
      ping -c 2 -W 3 "$cloud" >/dev/null 2>&1 || die "handshake ok but $cloud does not answer ping across the tunnel"
    fi
    echo "handshake ${age}s ago; cloud ${cloud:-unknown} reachable"
    ;;

  status)
    need_root
    if ! wg show "$IFACE" >/dev/null 2>&1; then
      echo '{"up":false}'
      exit 0
    fi
    hs=$(wg show "$IFACE" latest-handshakes | awk '{print $2}' | head -1)
    tx=$(wg show "$IFACE" transfer | awk '{print $3}' | head -1)
    rx=$(wg show "$IFACE" transfer | awk '{print $2}' | head -1)
    ep=$(wg show "$IFACE" endpoints | awk '{print $2}' | head -1)
    addr=$(ip -4 -o addr show "$IFACE" 2>/dev/null | awk '{print $4}')
    now=$(date +%s)
    age=$(( now - ${hs:-0} ))
    [ "${hs:-0}" = "0" ] && age=-1
    echo "{\"up\":true,\"address\":\"$addr\",\"endpoint\":\"$ep\",\"handshakeAge\":$age,\"rx\":${rx:-0},\"tx\":${tx:-0}}"
    ;;

  netdetect)
    need_root
    # Every interface, what it currently holds, and whether the box's default
    # route leaves through it. The installer picks the carrier's cable from
    # this list, and the default one is the cable they must NOT pick.
    defdev=$(ip route | awk '/^default/ {print $5; exit}')
    out=""
    for dev in $(ls /sys/class/net | grep -v '^lo$'); do
      # Ethernet only (ARPHRD_ETHER = 1). A carrier handoff arrives on a network
      # cable; tunnels and tap devices are type 65534 and must never appear in
      # this list. Our own wg0 did, was taken for the carrier's cable, and had
      # the tunnel address replaced with the carrier's - which broke both.
      # VLANs and bonds are type 1 and stay, because a handoff may well be one.
      [ "$(cat "/sys/class/net/$dev/type" 2>/dev/null)" = "1" ] || continue
      [ "$dev" = "$IFACE" ] && continue
      state=$(cat "/sys/class/net/$dev/operstate" 2>/dev/null)
      mac=$(cat "/sys/class/net/$dev/address" 2>/dev/null)
      addr=$(ip -4 -o addr show "$dev" 2>/dev/null | awk '{print $4}' | paste -sd, -)
      isdef="false"; [ "$dev" = "$defdev" ] && isdef="true"
      out="$out{\"dev\":\"$dev\",\"state\":\"$state\",\"mac\":\"$mac\",\"addr\":\"$addr\",\"isDefault\":$isdef},"
    done
    echo "{\"interfaces\":[${out%,}],\"defaultDev\":\"$defdev\"}"
    ;;

  netconfig)
    need_root
    payload=$(cat)
    [ -n "$payload" ] || die "no configuration on stdin"

    iface=$(printf '%s' "$payload"  | json iface)
    mode=$(printf '%s' "$payload"   | json mode)
    address=$(printf '%s' "$payload"| json address)
    prefix=$(printf '%s' "$payload" | json prefix)
    gateway=$(printf '%s' "$payload"| json gateway)
    routes=$(printf '%s' "$payload" | json routes)

    [ -n "$iface" ] || die "no interface given"
    [ -e "/sys/class/net/$iface" ] || die "interface $iface does not exist on this machine"
    # Refused here as well as filtered in netdetect: this branch rewrites an
    # interface outright, and doing that to the tunnel destroys the very link
    # the box is being reached over.
    [ "$iface" = "$IFACE" ] && die "$iface is the cloud tunnel, not the carrier's cable"
    [ "$(cat "/sys/class/net/$iface/type" 2>/dev/null)" = "1" ] \
      || die "$iface is not an Ethernet interface - the carrier handoff arrives on a network cable"

    defdev=$(ip route | awk '/^default/ {print $5; exit}')

    # A site with one network socket, with the carrier handoff and the office
    # LAN meeting on the same switch. The carrier's addressing is ADDED to the
    # interface alongside whatever it already has; its existing configuration
    # file is never opened, let alone rewritten. Reconfiguring the only cable a
    # remote box has is how that box is lost.
    if [ "$mode" = "alias" ] || { [ "$iface" = "$defdev" ] && [ "$mode" = "static" ]; }; then
      [ -n "$address" ] || die "the carrier's address is required"
      [ -n "$prefix" ]  || prefix=24
      [ -n "$gateway" ] || die "the carrier's gateway is required"

      routelines=""
      for host in $(printf '%s' "$routes" | tr -d '[]"' | tr ',' ' '); do
        [ -n "$host" ] && routelines="$routelines\nExecStart=/sbin/ip route replace $host/32 via $gateway dev $iface"
        [ -n "$host" ] && stoplines="${stoplines:-}\nExecStop=-/sbin/ip route del $host/32 via $gateway dev $iface"
      done

      # A unit rather than an ifcfg edit: additive, reversible, and it survives
      # a reboot without owning the interface.
      printf '%b\n' "[Unit]\nDescription=Lulu Cloud Connect - carrier addressing on $iface\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/sbin/ip address replace $address/$prefix dev $iface${routelines}\nExecStop=-/sbin/ip address del $address/$prefix dev $iface${stoplines:-}\n\n[Install]\nWantedBy=multi-user.target" \
        > /etc/systemd/system/lulucpe-carrier-net.service

      systemctl daemon-reload >>"$LOG" 2>&1
      systemctl enable lulucpe-carrier-net.service >>"$LOG" 2>&1
      systemctl restart lulucpe-carrier-net.service >>"$LOG" 2>&1 \
        || die "could not add the carrier addressing to $iface - see $LOG"

      sleep 1
      ip -4 addr show "$iface" | grep -q "${address}/" \
        || die "$address did not appear on $iface - is the carrier cable in the switch?"
      log "carrier addressing $address/$prefix added alongside existing config on $iface"
      echo "{\"ok\":true,\"iface\":\"$iface\",\"address\":\"$address/$prefix\",\"mode\":\"alias\"}"
      exit 0
    fi

    # Dedicated cable: this interface is the carrier's alone, so it may be
    # configured outright - but never the one carrying the default route.
    [ "$iface" = "$defdev" ] && die "$iface carries this machine's default route - use alias mode, or the carrier's own cable."

    if [ "$mode" = "static" ]; then
      [ -n "$address" ] || die "a static address is required"
      [ -n "$prefix" ] || prefix=24
    fi

    ts=$(date +%s)
    if [ -d /etc/sysconfig/network-scripts ]; then
      CFG="/etc/sysconfig/network-scripts/ifcfg-$iface"
      RTE="/etc/sysconfig/network-scripts/route-$iface"
      [ -f "$CFG" ] && cp -a "$CFG" "$CFG.lulucpe-bak.$ts"
      [ -f "$RTE" ] && cp -a "$RTE" "$RTE.lulucpe-bak.$ts"

      {
        echo "# Managed by Lulu Cloud Connect - carrier circuit"
        echo "DEVICE=$iface"
        echo "ONBOOT=yes"
        echo "NM_CONTROLLED=no"
        # The carrier circuit must never become the default route, and must
        # never supply DNS: it reaches the operator's SBCs and nothing else.
        echo "DEFROUTE=no"
        echo "PEERDNS=no"
        echo "PEERROUTES=no"
        if [ "$mode" = "static" ]; then
          echo "BOOTPROTO=none"
          echo "IPADDR=$address"
          echo "PREFIX=$prefix"
        else
          echo "BOOTPROTO=dhcp"
          echo "DHCLIENT_SET_DEFAULT_ROUTE=no"
        fi
      } > "$CFG"

      # Host routes to the operator's SBCs, out of this cable only.
      : > "$RTE"
      if [ -n "$gateway" ] && [ -n "$routes" ]; then
        for host in $(printf '%s' "$routes" | tr -d '[]"' | tr ',' ' '); do
          [ -n "$host" ] && echo "$host/32 via $gateway dev $iface" >> "$RTE"
        done
      fi

      ifdown "$iface" >>"$LOG" 2>&1
      if ! ifup "$iface" >>"$LOG" 2>&1; then
        [ -f "$CFG.lulucpe-bak.$ts" ] && cp -a "$CFG.lulucpe-bak.$ts" "$CFG"
        [ -f "$RTE.lulucpe-bak.$ts" ] && cp -a "$RTE.lulucpe-bak.$ts" "$RTE"
        ifup "$iface" >>"$LOG" 2>&1
        die "$iface did not come up with that configuration - the previous settings were restored"
      fi

    elif [ -d /etc/netplan ]; then
      CFG="/etc/netplan/60-lulucpe-$iface.yaml"
      [ -f "$CFG" ] && cp -a "$CFG" "$CFG.lulucpe-bak.$ts"
      {
        echo "# Managed by Lulu Cloud Connect - carrier circuit"
        echo "network:"
        echo "  version: 2"
        echo "  ethernets:"
        echo "    $iface:"
        if [ "$mode" = "static" ]; then
          echo "      dhcp4: false"
          echo "      addresses: [$address/$prefix]"
        else
          echo "      dhcp4: true"
          echo "      dhcp4-overrides:"
          echo "        use-routes: false"
        fi
        if [ -n "$gateway" ] && [ -n "$routes" ]; then
          echo "      routes:"
          for host in $(printf '%s' "$routes" | tr -d '[]"' | tr ',' ' '); do
            [ -n "$host" ] && { echo "        - to: $host/32"; echo "          via: $gateway"; }
          done
        fi
      } > "$CFG"
      chmod 600 "$CFG"
      if ! netplan apply >>"$LOG" 2>&1; then
        rm -f "$CFG"
        [ -f "$CFG.lulucpe-bak.$ts" ] && cp -a "$CFG.lulucpe-bak.$ts" "$CFG"
        netplan apply >>"$LOG" 2>&1
        die "netplan refused that configuration - the previous settings were restored"
      fi
    else
      die "unrecognised network configuration system - configure $iface by hand"
    fi

    sleep 2
    got=$(ip -4 -o addr show "$iface" | awk '{print $4}' | paste -sd, -)
    [ -n "$got" ] || die "$iface came up but has no address"
    log "configured carrier interface $iface as $got"
    echo "{\"ok\":true,\"iface\":\"$iface\",\"address\":\"$got\"}"
    ;;

  teardown)
    need_root
    systemctl disable --now lulucpe-wg.service >>"$LOG" 2>&1
    systemctl disable --now "wg-quick@$IFACE" >>"$LOG" 2>&1
    [ -x "$WG_QUICK" ] && WG_QUICK_USERSPACE_IMPLEMENTATION="$WG_GO" PATH=/usr/local/bin:$PATH "$WG_QUICK" down "$IFACE" >>"$LOG" 2>&1
    [ -f "$CONF" ] && mv "$CONF" "$CONF.removed.$(date +%s)"
    rm -f "$ENV_FILE"
    log "tunnel torn down"
    echo "{\"ok\":true}"
    ;;

  *)
    die "usage: lulucpe-helper {preflight|genkey|pubkey|configure|verify|status|teardown|netdetect|netconfig|diagreport}"
    ;;
esac
